feat: add TypeScript definitions for next-auth session with accessToken and user details
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { auth } from '../../../auth';
|
||||
import Navigation from '../../Navigation';
|
||||
import AiAdminClient from './AiAdminClient';
|
||||
import type { AiModelInfo } from './AiAdminClient';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
|
||||
|
||||
export default async function AiAdminPage() {
|
||||
const session = await auth();
|
||||
if ((session?.user as any)?.role !== 'admin') {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
const key = process.env.MISTRAL_API_KEY ?? '';
|
||||
const hasKey = key.length > 0;
|
||||
const keyHint = key.length >= 4 ? key.slice(-4) : '????';
|
||||
|
||||
let aiFunctions: AiModelInfo[] = [];
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/ai/models`, { cache: 'no-store' });
|
||||
if (res.ok) aiFunctions = await res.json();
|
||||
} catch {
|
||||
// backend ej nåbart — visa tom lista
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navigation />
|
||||
<main style={{ maxWidth: '900px', margin: '0 auto', padding: '0 1rem 2rem' }}>
|
||||
<h1 style={{ fontSize: '1.4rem', marginBottom: '0.25rem' }}>🤖 AI-konfiguration</h1>
|
||||
<p style={{ color: '#666', marginBottom: '2rem', fontSize: '0.9rem' }}>
|
||||
Översikt över implementerade AI-funktioner och API-nyckelstatus.
|
||||
</p>
|
||||
<AiAdminClient keyHint={keyHint} hasKey={hasKey} aiFunctions={aiFunctions} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import type { Product, Category } from '../../../features/inventory/types';
|
||||
import EditProductForm from './EditProductForm';
|
||||
|
||||
type CategoryNode = Category & { children: CategoryNode[] };
|
||||
|
||||
type AiSuggestion = {
|
||||
productId: number;
|
||||
productName: string;
|
||||
suggestion: {
|
||||
categoryId: number;
|
||||
categoryName: string;
|
||||
path: string;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
usedFallback: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'createdDesc', label: 'Senast tillagda' },
|
||||
{ value: 'nameAsc', label: 'Namn A–Ö' },
|
||||
];
|
||||
|
||||
function flattenTree(nodes: CategoryNode[], depth = 0): { id: number; label: string }[] {
|
||||
const result: { id: number; label: string }[] = [];
|
||||
const sorted = [...nodes].sort((a, b) => a.name.localeCompare(b.name, 'sv'));
|
||||
for (const node of sorted) {
|
||||
result.push({ id: node.id, label: '\u00a0\u00a0'.repeat(depth) + (depth > 0 ? '↳ ' : '') + node.name });
|
||||
if (node.children?.length) result.push(...flattenTree(node.children, depth + 1));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export default function AdminProductList() {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [productsLoading, setProductsLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sort, setSort] = useState('createdDesc');
|
||||
const [showUncategorizedOnly, setShowUncategorizedOnly] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [bulkCategoryId, setBulkCategoryId] = useState<string>('');
|
||||
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
|
||||
const [isBulkPending, setIsBulkPending] = useState(false);
|
||||
const [bulkError, setBulkError] = useState<string | null>(null);
|
||||
|
||||
// AI-kategorisering state
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
const [aiError, setAiError] = useState<string | null>(null);
|
||||
const [aiSuggestions, setAiSuggestions] = useState<AiSuggestion[] | null>(null);
|
||||
const [aiApproved, setAiApproved] = useState<Set<number>>(new Set());
|
||||
const [aiApplying, setAiApplying] = useState(false);
|
||||
|
||||
const refetchProducts = useCallback(() => {
|
||||
fetch('/api/products')
|
||||
.then(async (r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (Array.isArray(data)) setProducts(data);
|
||||
})
|
||||
.catch((e) => console.error('[AdminProductList] refetchProducts error:', e))
|
||||
.finally(() => setProductsLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refetchProducts();
|
||||
}, [refetchProducts]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => refetchProducts();
|
||||
window.addEventListener('product-created', handler);
|
||||
window.addEventListener('product-list-changed', handler);
|
||||
return () => {
|
||||
window.removeEventListener('product-created', handler);
|
||||
window.removeEventListener('product-list-changed', handler);
|
||||
};
|
||||
}, [refetchProducts]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/categories?tree')
|
||||
.then((r) => r.json())
|
||||
.then((data) => { if (Array.isArray(data)) setCategoryTree(data); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const categoryOptions = useMemo(() => flattenTree(categoryTree), [categoryTree]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
|
||||
let result = products.filter((p) => {
|
||||
if (showUncategorizedOnly && p.categoryId != null) return false;
|
||||
if (q) {
|
||||
return (
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
(p.canonicalName ?? '').toLowerCase().includes(q) ||
|
||||
(p.normalizedName ?? '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (sort === 'nameAsc') {
|
||||
result.sort((a, b) =>
|
||||
(a.canonicalName || a.name).localeCompare(b.canonicalName || b.name, 'sv'),
|
||||
);
|
||||
} else {
|
||||
result.sort((a, b) => b.id - a.id);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [products, search, sort, showUncategorizedOnly]);
|
||||
|
||||
const allVisibleSelected = filtered.length > 0 && filtered.every((p) => selectedIds.has(p.id));
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (allVisibleSelected) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
filtered.forEach((p) => next.delete(p.id));
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
filtered.forEach((p) => next.add(p.id));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelect = (id: number) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleBulkApply = async () => {
|
||||
setBulkError(null);
|
||||
const ids = Array.from(selectedIds);
|
||||
if (ids.length === 0) return;
|
||||
const categoryId = bulkCategoryId === '' ? null : bulkCategoryId === '__remove__' ? null : Number(bulkCategoryId);
|
||||
setIsBulkPending(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/bulk-set-category', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids, categoryId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.error || 'Fel vid uppdatering');
|
||||
}
|
||||
setSelectedIds(new Set());
|
||||
setBulkCategoryId('');
|
||||
refetchProducts();
|
||||
} catch (err) {
|
||||
setBulkError(err instanceof Error ? err.message : 'Fel vid uppdatering');
|
||||
} finally {
|
||||
setIsBulkPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAiCategorize = async () => {
|
||||
setAiLoading(true);
|
||||
setAiError(null);
|
||||
setAiSuggestions(null);
|
||||
try {
|
||||
const res = await fetch('/api/admin/bulk-categorize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error ?? 'AI-kategorisering misslyckades');
|
||||
setAiSuggestions(data);
|
||||
setAiApproved(new Set(data.map((r: AiSuggestion) => r.productId)));
|
||||
} catch (err) {
|
||||
setAiError(err instanceof Error ? err.message : 'AI-kategorisering misslyckades');
|
||||
} finally {
|
||||
setAiLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAiApply = async () => {
|
||||
if (!aiSuggestions) return;
|
||||
setAiApplying(true);
|
||||
try {
|
||||
const approved = aiSuggestions.filter((s) => aiApproved.has(s.productId));
|
||||
const grouped = new Map<number, number[]>();
|
||||
for (const s of approved) {
|
||||
const cid = s.suggestion.categoryId;
|
||||
if (!grouped.has(cid)) grouped.set(cid, []);
|
||||
grouped.get(cid)!.push(s.productId);
|
||||
}
|
||||
for (const [categoryId, ids] of grouped.entries()) {
|
||||
const res = await fetch('/api/admin/bulk-set-category', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids, categoryId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.error || 'Fel vid tillämpning');
|
||||
}
|
||||
}
|
||||
setAiSuggestions(null);
|
||||
setAiApproved(new Set());
|
||||
refetchProducts();
|
||||
} catch (err) {
|
||||
setAiError(err instanceof Error ? err.message : 'Fel vid tillämpning');
|
||||
} finally {
|
||||
setAiApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (productsLoading) {
|
||||
return <p style={{ color: '#888', marginTop: '1rem' }}>Laddar produkter…</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sök + sortering + filter */}
|
||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Sök produkt…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ flex: '1 1 200px', padding: '0.5rem 0.75rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '1rem' }}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
|
||||
{sortOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setSort(opt.value)}
|
||||
style={{
|
||||
padding: '0.45rem 0.75rem',
|
||||
borderRadius: '999px',
|
||||
border: '1px solid #ddd',
|
||||
background: sort === opt.value ? '#efefef' : '#fff',
|
||||
fontWeight: sort === opt.value ? 600 : 400,
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowUncategorizedOnly((v) => !v); setSelectedIds(new Set()); }}
|
||||
style={{
|
||||
padding: '0.45rem 0.75rem',
|
||||
borderRadius: '999px',
|
||||
border: '1px solid ' + (showUncategorizedOnly ? '#f59e0b' : '#ddd'),
|
||||
background: showUncategorizedOnly ? '#fffbeb' : '#fff',
|
||||
fontWeight: showUncategorizedOnly ? 600 : 400,
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
color: showUncategorizedOnly ? '#92400e' : 'inherit',
|
||||
}}
|
||||
>
|
||||
Okategoriserade
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAiCategorize}
|
||||
disabled={aiLoading}
|
||||
style={{
|
||||
padding: '0.45rem 0.75rem',
|
||||
borderRadius: '999px',
|
||||
border: '1px solid #a78bfa',
|
||||
background: aiLoading ? '#f5f3ff' : '#ede9fe',
|
||||
color: '#5b21b6',
|
||||
fontWeight: 600,
|
||||
cursor: aiLoading ? 'wait' : 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
{aiLoading ? '⏳ AI arbetar…' : '✨ AI-kategorisera okategoriserade'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span style={{ color: '#666', fontSize: '0.9rem', whiteSpace: 'nowrap' }}>
|
||||
{filtered.length} av {products.length} produkter
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Bulk-åtgärd */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div style={{
|
||||
display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap',
|
||||
padding: '0.75rem 1rem', marginBottom: '1rem',
|
||||
background: '#f0f7ff', border: '1px solid #bfdbfe', borderRadius: '8px',
|
||||
}}>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>{selectedIds.size} valda</span>
|
||||
<select
|
||||
value={bulkCategoryId}
|
||||
onChange={(e) => setBulkCategoryId(e.target.value)}
|
||||
style={{ padding: '0.4rem 0.6rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem', minWidth: '200px' }}
|
||||
>
|
||||
<option value="">Välj kategori…</option>
|
||||
<option value="__remove__">— Ta bort kategori —</option>
|
||||
{categoryOptions.map((opt) => (
|
||||
<option key={opt.id} value={opt.id}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBulkApply}
|
||||
disabled={isBulkPending}
|
||||
style={{ padding: '0.4rem 0.9rem', background: '#0070f3', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '0.9rem' }}
|
||||
>
|
||||
{isBulkPending ? 'Sparar…' : 'Sätt kategori'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
style={{ padding: '0.4rem 0.6rem', background: 'transparent', border: '1px solid #ddd', borderRadius: '6px', cursor: 'pointer', fontSize: '0.9rem' }}
|
||||
>
|
||||
Avmarkera
|
||||
</button>
|
||||
{bulkError && <span style={{ color: '#dc2626', fontSize: '0.85rem' }}>{bulkError}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Välj alla synliga */}
|
||||
{filtered.length > 0 && (
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.875rem', color: '#555' }}>
|
||||
<input type="checkbox" checked={allVisibleSelected} onChange={toggleSelectAll} />
|
||||
Välj alla synliga ({filtered.length})
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||
{filtered.map((product) => (
|
||||
<article
|
||||
key={product.id}
|
||||
style={{
|
||||
border: selectedIds.has(product.id) ? '1px solid #93c5fd' : '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
padding: '1rem',
|
||||
display: 'grid',
|
||||
gap: '0.5rem',
|
||||
background: selectedIds.has(product.id) ? '#f0f7ff' : undefined,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.6rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(product.id)}
|
||||
onChange={() => toggleSelect(product.id)}
|
||||
style={{ marginTop: '3px', flexShrink: 0 }}
|
||||
/>
|
||||
<div>
|
||||
<strong>{product.canonicalName || product.name}</strong>
|
||||
{product.canonicalName && product.canonicalName !== product.name && (
|
||||
<span style={{ color: '#666', fontSize: '0.85rem', marginLeft: '0.5rem' }}>({product.name})</span>
|
||||
)}
|
||||
{product.categoryRef ? (
|
||||
<span style={{ marginLeft: '0.5rem', fontSize: '0.8rem', background: '#e0f2fe', borderRadius: '999px', padding: '0.15rem 0.5rem', color: '#0369a1' }}>
|
||||
{[product.categoryRef.parent?.parent?.name, product.categoryRef.parent?.name, product.categoryRef.name].filter(Boolean).join(' › ')}
|
||||
</span>
|
||||
) : product.category ? (
|
||||
<span style={{ marginLeft: '0.5rem', fontSize: '0.8rem', background: '#eee', borderRadius: '999px', padding: '0.15rem 0.5rem', color: '#555' }}>
|
||||
{product.category}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ marginLeft: '0.5rem', fontSize: '0.8rem', color: '#f59e0b', fontStyle: 'italic' }}>Okategoriserad</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ color: '#aaa', fontSize: '0.8rem' }}>ID: {product.id}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem', color: '#888' }}>
|
||||
Normalized: {product.normalizedName}
|
||||
</div>
|
||||
<EditProductForm
|
||||
product={product}
|
||||
onSaved={(updated) => setProducts((prev) => prev.map((p) => p.id === updated.id ? updated : p))}
|
||||
onDeleted={(id) => setProducts((prev) => prev.filter((p) => p.id !== id))}
|
||||
/>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* AI-kategorisering modal */}
|
||||
{(aiError || aiSuggestions) && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
|
||||
<div style={{ background: '#fff', borderRadius: 10, padding: '1.5rem', maxWidth: 700, width: '95%', maxHeight: '85vh', display: 'flex', flexDirection: 'column', gap: '1rem', boxShadow: '0 8px 32px rgba(0,0,0,0.18)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ margin: 0 }}>✨ AI-kategoriförslag</h3>
|
||||
<button onClick={() => { setAiSuggestions(null); setAiError(null); }} style={{ background: 'none', border: 'none', fontSize: 20, cursor: 'pointer', color: '#64748b' }}>✕</button>
|
||||
</div>
|
||||
|
||||
{aiError && <div style={{ color: '#dc2626', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 6, padding: '0.6rem 1rem', fontSize: 14 }}>{aiError}</div>}
|
||||
|
||||
{aiSuggestions && (
|
||||
<>
|
||||
<p style={{ margin: 0, fontSize: 13, color: '#475569' }}>
|
||||
AI har analyserat {aiSuggestions.length} okategoriserade produkter. Avmarkera rader du inte vill godkänna, klicka sedan "Godkänn valda".
|
||||
</p>
|
||||
<div style={{ overflowY: 'auto', flex: 1 }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f1f5f9', textAlign: 'left' }}>
|
||||
<th style={{ padding: '0.5rem 0.6rem', borderBottom: '2px solid #e2e8f0' }}>
|
||||
<input type="checkbox" checked={aiApproved.size === aiSuggestions.length} onChange={() => setAiApproved(aiApproved.size === aiSuggestions.length ? new Set() : new Set(aiSuggestions.map((s) => s.productId)))} />
|
||||
</th>
|
||||
{['Produkt', 'AI-förslag', 'Säkerhet'].map((h) => <th key={h} style={{ padding: '0.5rem 0.6rem', borderBottom: '2px solid #e2e8f0' }}>{h}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{aiSuggestions.map((s) => {
|
||||
const approved = aiApproved.has(s.productId);
|
||||
const isLow = s.suggestion.confidence === 'low' || s.suggestion.usedFallback;
|
||||
return (
|
||||
<tr key={s.productId} style={{ borderBottom: '1px solid #e2e8f0', background: isLow ? '#fffbeb' : approved ? '#f0fdf4' : '#fff', opacity: approved ? 1 : 0.5 }}>
|
||||
<td style={{ padding: '0.5rem 0.6rem' }}>
|
||||
<input type="checkbox" checked={approved} onChange={() => setAiApproved((prev) => { const next = new Set(prev); if (next.has(s.productId)) next.delete(s.productId); else next.add(s.productId); return next; })} />
|
||||
</td>
|
||||
<td style={{ padding: '0.5rem 0.6rem', fontWeight: 500 }}>{s.productName}</td>
|
||||
<td style={{ padding: '0.5rem 0.6rem', color: isLow ? '#92400e' : '#15803d' }}>
|
||||
{isLow ? '⚠ ' : '✓ '}{s.suggestion.path}
|
||||
</td>
|
||||
<td style={{ padding: '0.5rem 0.6rem' }}>
|
||||
<span style={{ display: 'inline-block', padding: '0.15rem 0.5rem', borderRadius: 999, fontSize: 12, fontWeight: 600, background: isLow ? '#fef3c7' : s.suggestion.confidence === 'high' ? '#dcfce7' : '#dbeafe', color: isLow ? '#92400e' : s.suggestion.confidence === 'high' ? '#15803d' : '#1d4ed8' }}>
|
||||
{isLow ? 'Låg' : s.suggestion.confidence === 'high' ? 'Hög' : 'Medium'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => { setAiSuggestions(null); setAiError(null); }} style={{ padding: '0.5rem 1rem', background: '#e2e8f0', border: 'none', borderRadius: 6, cursor: 'pointer', fontWeight: 500 }}>Avbryt</button>
|
||||
<button onClick={handleAiApply} disabled={aiApplying || aiApproved.size === 0} style={{ padding: '0.5rem 1.2rem', background: '#7c3aed', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer', fontWeight: 600 }}>
|
||||
{aiApplying ? 'Sparar…' : `Godkänn valda (${aiApproved.size})`}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
type DeletedProduct = {
|
||||
id: number;
|
||||
name: string;
|
||||
normalizedName: string;
|
||||
canonicalName?: string | null;
|
||||
category?: string | null;
|
||||
brand?: string | null;
|
||||
deletedAt?: string | null;
|
||||
};
|
||||
|
||||
export default function DeletedProductsView() {
|
||||
const [products, setProducts] = useState<DeletedProduct[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pendingId, setPendingId] = useState<number | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const fetchDeleted = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetch('/api/admin/deleted-products')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (Array.isArray(data)) setProducts(data);
|
||||
else setError('Kunde inte ladda raderade produkter.');
|
||||
})
|
||||
.catch(() => setError('Nätverksfel. Försök igen.'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchDeleted(); }, [fetchDeleted]);
|
||||
|
||||
const handleRestore = async (id: number) => {
|
||||
if (!confirm('Återställ produkten?')) return;
|
||||
setPendingId(id);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/deleted-products/${id}`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
setError(d?.error ?? 'Kunde inte återställa produkten.');
|
||||
} else {
|
||||
setProducts((prev) => prev.filter((p) => p.id !== id));
|
||||
}
|
||||
} catch {
|
||||
setError('Nätverksfel. Försök igen.');
|
||||
} finally {
|
||||
setPendingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePermanentDelete = async (id: number, name: string) => {
|
||||
if (!confirm(`Radera "${name}" permanent? Detta kan inte ångras.`)) return;
|
||||
setPendingId(id);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/deleted-products/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
setError(d?.error ?? 'Kunde inte radera produkten permanent.');
|
||||
} else {
|
||||
setProducts((prev) => prev.filter((p) => p.id !== id));
|
||||
}
|
||||
} catch {
|
||||
setError('Nätverksfel. Försök igen.');
|
||||
} finally {
|
||||
setPendingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = products.filter((p) =>
|
||||
p.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(p.canonicalName ?? '').toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
if (loading) return <p style={{ color: '#888' }}>Laddar raderade produkter...</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p style={{ color: '#555', marginBottom: '1rem' }}>
|
||||
Här visas produkter som mjukraderats. Du kan återställa dem eller radera dem permanent.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div style={{ color: '#c00', background: '#fff0f0', border: '1px solid #fcc', borderRadius: 6, padding: '0.5rem 1rem', marginBottom: '1rem' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Sök på namn..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ padding: '0.4rem 0.75rem', borderRadius: 6, border: '1px solid #ccc', fontSize: '0.9rem', minWidth: 220 }}
|
||||
/>
|
||||
<span style={{ color: '#888', fontSize: '0.85rem' }}>
|
||||
{filtered.length} av {products.length} produkter
|
||||
</span>
|
||||
<button
|
||||
onClick={fetchDeleted}
|
||||
style={{ marginLeft: 'auto', padding: '0.35rem 0.75rem', borderRadius: 6, border: '1px solid #ccc', background: '#f8f8f8', cursor: 'pointer', fontSize: '0.85rem' }}
|
||||
>
|
||||
Uppdatera
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p style={{ color: '#888', fontStyle: 'italic' }}>Inga raderade produkter hittades.</p>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.9rem' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f2f2f2', textAlign: 'left' }}>
|
||||
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ddd' }}>ID</th>
|
||||
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ddd' }}>Namn</th>
|
||||
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ddd' }}>Canonical name</th>
|
||||
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ddd' }}>Kategori</th>
|
||||
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ddd' }}>Raderades</th>
|
||||
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ddd' }}>Åtgärder</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((p) => (
|
||||
<tr key={p.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '0.5rem 0.75rem', color: '#888' }}>{p.id}</td>
|
||||
<td style={{ padding: '0.5rem 0.75rem', fontWeight: 500 }}>{p.name}</td>
|
||||
<td style={{ padding: '0.5rem 0.75rem', color: '#666' }}>{p.canonicalName || '–'}</td>
|
||||
<td style={{ padding: '0.5rem 0.75rem', color: '#666' }}>{p.category || '–'}</td>
|
||||
<td style={{ padding: '0.5rem 0.75rem', color: '#999', fontSize: '0.8rem' }}>
|
||||
{p.deletedAt ? new Date(p.deletedAt).toLocaleDateString('sv-SE') : '–'}
|
||||
</td>
|
||||
<td style={{ padding: '0.5rem 0.75rem' }}>
|
||||
<div style={{ display: 'flex', gap: '0.4rem' }}>
|
||||
<button
|
||||
disabled={pendingId === p.id}
|
||||
onClick={() => handleRestore(p.id)}
|
||||
style={{
|
||||
padding: '0.25rem 0.6rem',
|
||||
borderRadius: 5,
|
||||
border: '1px solid #3a7d44',
|
||||
background: '#eafaf1',
|
||||
color: '#276032',
|
||||
cursor: pendingId === p.id ? 'not-allowed' : 'pointer',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Återställ
|
||||
</button>
|
||||
<button
|
||||
disabled={pendingId === p.id}
|
||||
onClick={() => handlePermanentDelete(p.id, p.name)}
|
||||
style={{
|
||||
padding: '0.25rem 0.6rem',
|
||||
borderRadius: 5,
|
||||
border: '1px solid #c00',
|
||||
background: '#fff0f0',
|
||||
color: '#c00',
|
||||
cursor: pendingId === p.id ? 'not-allowed' : 'pointer',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Radera permanent
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Product } from '../../../features/inventory/types';
|
||||
|
||||
type CategoryNode = {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number | null;
|
||||
children: CategoryNode[];
|
||||
};
|
||||
|
||||
type AiSuggestion = {
|
||||
categoryId: number;
|
||||
categoryName: string;
|
||||
path: string;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
usedFallback: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
product: Product;
|
||||
onSaved: (updated: Product) => void;
|
||||
onDeleted: (id: number) => void;
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: '0.5rem 0.75rem',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '1rem',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
export default function EditProductForm({ product, onSaved, onDeleted }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [tagInput, setTagInput] = useState(
|
||||
product.tags?.map((pt) => pt.tag.name).join(', ') ?? ''
|
||||
);
|
||||
|
||||
// Kategoriträd från API
|
||||
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | ''>(
|
||||
(product as any).categoryId ?? ''
|
||||
);
|
||||
|
||||
// AI-suggestion state
|
||||
const [aiSuggestion, setAiSuggestion] = useState<AiSuggestion | null>(null);
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
const [aiError, setAiError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && categoryTree.length === 0) {
|
||||
fetch('/api/categories?tree')
|
||||
.then((r) => r.json())
|
||||
.then((data: unknown) => {
|
||||
if (Array.isArray(data)) setCategoryTree(data as CategoryNode[]);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Bygg flat lista för select med indragna nivåer
|
||||
function flattenTree(nodes: CategoryNode[], depth = 0): { id: number; name: string; label: string }[] {
|
||||
const result: { id: number; name: string; label: string }[] = [];
|
||||
const sorted = [...nodes].sort((a, b) => a.name.localeCompare(b.name, 'sv'));
|
||||
for (const node of sorted) {
|
||||
const prefix = depth === 0 ? '' : depth === 1 ? '\u00a0\u00a0\u00a0↳ ' : '\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0↳ ';
|
||||
result.push({ id: node.id, name: node.name, label: prefix + node.name });
|
||||
if (node.children?.length) result.push(...flattenTree(node.children, depth + 1));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const flatCategories = flattenTree(categoryTree);
|
||||
|
||||
async function handleAiSuggest() {
|
||||
setAiLoading(true);
|
||||
setAiError(null);
|
||||
setAiSuggestion(null);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/suggest-category/${product.id}`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error ?? 'AI-kategorisering misslyckades');
|
||||
setAiSuggestion(data);
|
||||
} catch (err) {
|
||||
setAiError(err instanceof Error ? err.message : 'AI-kategorisering misslyckades');
|
||||
} finally {
|
||||
setAiLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const rawTags = tagInput.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean);
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/product/${product.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: formData.get('name'),
|
||||
canonicalName: formData.get('canonicalName') ?? '',
|
||||
category: formData.get('category') ?? '',
|
||||
subcategory: formData.get('subcategory') ?? '',
|
||||
brand: formData.get('brand') ?? '',
|
||||
categoryId: selectedCategoryId !== '' ? selectedCategoryId : null,
|
||||
tags: rawTags,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data?.error ?? 'Okänt fel');
|
||||
return;
|
||||
}
|
||||
setSuccess(true);
|
||||
setIsOpen(false);
|
||||
onSaved(data as Product);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Okänt fel');
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm(`Ta bort "${product.name}"? Detta är en mjukradering och kan återställas.`)) return;
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
setIsPending(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/product/${product.id}`, { method: 'DELETE' });
|
||||
console.log('[EditProductForm] handleDelete: HTTP', res.status);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data?.error ?? 'Kunde inte ta bort produkt');
|
||||
return;
|
||||
}
|
||||
onDeleted(product.id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Okänt fel');
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setIsOpen(!isOpen); setError(null); setSuccess(false); }}
|
||||
style={{
|
||||
padding: '0.4rem 1rem',
|
||||
border: '1px solid #0070f3',
|
||||
borderRadius: '4px',
|
||||
background: isOpen ? '#0070f3' : '#fff',
|
||||
color: isOpen ? '#fff' : '#0070f3',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{isOpen ? 'Stäng' : 'Redigera'}
|
||||
</button>
|
||||
{success && <span style={{ color: 'green', fontSize: '0.9rem' }}>✓ Sparat!</span>}
|
||||
</div>
|
||||
|
||||
{error && <div style={{ color: 'crimson', marginTop: '0.5rem', fontSize: '0.9rem' }}>{error}</div>}
|
||||
|
||||
{isOpen && (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{ marginTop: '0.75rem', display: 'grid', gap: '0.75rem', maxWidth: '480px' }}
|
||||
>
|
||||
<input type="hidden" name="id" value={product.id} />
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.25rem', fontSize: '0.9rem' }}>
|
||||
<span style={{ fontWeight: 600 }}>Namn</span>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
defaultValue={product.name}
|
||||
required
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.25rem', fontSize: '0.9rem' }}>
|
||||
<span style={{ fontWeight: 600 }}>Canonical name</span>
|
||||
<input
|
||||
name="canonicalName"
|
||||
type="text"
|
||||
defaultValue={product.canonicalName ?? ''}
|
||||
style={inputStyle}
|
||||
placeholder="Lämna tomt för att använda namn"
|
||||
/>
|
||||
<span style={{ color: '#666', fontSize: '0.8rem' }}>
|
||||
Används för att gruppera liknande produkter (t.ex. "Kyckling" för alla kycklingvarianter)
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.25rem', fontSize: '0.9rem' }}>
|
||||
<span style={{ fontWeight: 600 }}>Kategori (ny hierarki)</span>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<select
|
||||
value={selectedCategoryId}
|
||||
onChange={(e) => { setSelectedCategoryId(e.target.value === '' ? '' : Number(e.target.value)); setAiSuggestion(null); }}
|
||||
style={{ ...inputStyle, flex: 1, minWidth: 180 }}
|
||||
>
|
||||
<option value="">— Ingen kategori —</option>
|
||||
{flatCategories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>{cat.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAiSuggest}
|
||||
disabled={aiLoading}
|
||||
title="Låt AI föreslå kategori"
|
||||
style={{ padding: '0.45rem 0.75rem', background: '#ede9fe', border: '1px solid #a78bfa', borderRadius: 4, cursor: aiLoading ? 'wait' : 'pointer', fontSize: 13, color: '#5b21b6', fontWeight: 600, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{aiLoading ? '⏳' : '✨ Fråga AI'}
|
||||
</button>
|
||||
</div>
|
||||
{aiError && <span style={{ color: '#dc2626', fontSize: 12 }}>{aiError}</span>}
|
||||
{aiSuggestion && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: 4, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 12, padding: '0.2rem 0.6rem', borderRadius: 999, fontWeight: 600, background: aiSuggestion.usedFallback ? '#fef3c7' : aiSuggestion.confidence === 'high' ? '#dcfce7' : '#dbeafe', color: aiSuggestion.usedFallback ? '#92400e' : aiSuggestion.confidence === 'high' ? '#15803d' : '#1d4ed8', border: `1px solid ${aiSuggestion.usedFallback ? '#fcd34d' : aiSuggestion.confidence === 'high' ? '#86efac' : '#93c5fd'}` }}>
|
||||
{aiSuggestion.usedFallback ? '⚠ AI osäker — ' : 'AI föreslår: '}{aiSuggestion.path}
|
||||
</span>
|
||||
<button type="button" onClick={() => { setSelectedCategoryId(aiSuggestion.categoryId); setAiSuggestion(null); }} style={{ padding: '0.2rem 0.5rem', background: '#16a34a', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 12 }}>✓</button>
|
||||
<button type="button" onClick={() => setAiSuggestion(null)} style={{ padding: '0.2rem 0.5rem', background: '#e2e8f0', border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 12 }}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.25rem', fontSize: '0.9rem' }}>
|
||||
<span style={{ fontWeight: 600 }}>Varumärke</span>
|
||||
<input
|
||||
name="brand"
|
||||
type="text"
|
||||
defaultValue={product.brand ?? ''}
|
||||
style={inputStyle}
|
||||
placeholder="T.ex. Arla, ICA, Överlopps"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.25rem', fontSize: '0.9rem' }}>
|
||||
<span style={{ fontWeight: 600 }}>Taggar</span>
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder="t.ex. svensk, ekologisk, glutenfri"
|
||||
/>
|
||||
<span style={{ color: '#666', fontSize: '0.8rem' }}>Kommaseparerade taggar (gemener)</span>
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'grid', gap: '0.25rem', fontSize: '0.85rem', color: '#888' }}>
|
||||
<span><strong style={{ color: '#555' }}>Normaliserat namn:</strong> {product.normalizedName}</span>
|
||||
<span><strong style={{ color: '#555' }}>Aktiv:</strong> {product.isActive ? 'Ja' : 'Nej'}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
style={{
|
||||
padding: '0.6rem 1.25rem',
|
||||
background: '#0070f3',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: isPending ? 'not-allowed' : 'pointer',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.9rem',
|
||||
opacity: isPending ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{isPending ? 'Sparar...' : 'Spara'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={isPending}
|
||||
style={{
|
||||
padding: '0.6rem 1.25rem',
|
||||
background: '#fff',
|
||||
color: '#c00',
|
||||
border: '1px solid #c00',
|
||||
borderRadius: '4px',
|
||||
cursor: isPending ? 'not-allowed' : 'pointer',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.9rem',
|
||||
opacity: isPending ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
Ta bort (mjukradering)
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import ProductForm from '../../inventory/ProductForm';
|
||||
|
||||
export default function ExpandableCreateProductSection() {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
border: '2px solid #0070f3',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '1.5rem',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '1rem',
|
||||
background: '#0070f3',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.target as HTMLElement).style.background = '#0059cc';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.target as HTMLElement).style.background = '#0070f3';
|
||||
}}
|
||||
>
|
||||
<span>➕ Skapa produkt</span>
|
||||
<span style={{ fontSize: '1.5rem', transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}>
|
||||
▼
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div style={{ padding: '1rem', background: '#f9f9f9' }}>
|
||||
<ProductForm />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition, useEffect } from 'react';
|
||||
import type { MergePreview, Product } from '../../../features/inventory/types';
|
||||
|
||||
export default function MergePreviewForm() {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [sourceProductId, setSourceProductId] = useState('');
|
||||
const [targetProductId, setTargetProductId] = useState('');
|
||||
const [preview, setPreview] = useState<MergePreview | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && products.length === 0) {
|
||||
fetch('/api/products')
|
||||
.then((r) => r.json())
|
||||
.then((data) => { if (Array.isArray(data)) setProducts(data); })
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [isExpanded]);
|
||||
|
||||
const fetchPreview = () => {
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
setPreview(null);
|
||||
setIsConfirming(false);
|
||||
|
||||
if (!sourceProductId || !targetProductId) {
|
||||
setError('Välj både source och target.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceProductId === targetProductId) {
|
||||
setError('Source och target kan inte vara samma produkt.');
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/merge-preview-proxy?sourceProductId=${sourceProductId}&targetProductId=${targetProductId}`,
|
||||
{
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || 'Kunde inte hämta preview.');
|
||||
}
|
||||
|
||||
const data: MergePreview = await res.json();
|
||||
setPreview(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Okänt fel');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const confirmMerge = () => {
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
if (!preview) {
|
||||
setError('Ingen preview finns att bekräfta.');
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/merge-products', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourceProductId: preview.source.id,
|
||||
targetProductId: preview.target.id,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.error || 'Sammanslagning misslyckades');
|
||||
}
|
||||
|
||||
setSuccessMessage(
|
||||
`Produkten "${preview.source.canonicalName || preview.source.name}" slogs ihop med "${preview.target.canonicalName || preview.target.name}".`,
|
||||
);
|
||||
setPreview(null);
|
||||
setIsConfirming(false);
|
||||
setSourceProductId('');
|
||||
setTargetProductId('');
|
||||
window.dispatchEvent(new CustomEvent('product-list-changed'));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Okänt fel');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
border: '2px solid #10b981',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '1.5rem',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '1rem',
|
||||
background: '#10b981',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.target as HTMLElement).style.background = '#059669';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.target as HTMLElement).style.background = '#10b981';
|
||||
}}
|
||||
>
|
||||
<span>🔄 Förhandsgranska merge</span>
|
||||
<span style={{ fontSize: '1.5rem', transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}>
|
||||
▼
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div style={{ padding: '1rem', background: '#f9fafb', display: 'grid', gap: '1rem' }}>
|
||||
<div style={{ display: 'grid', gap: '0.75rem', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))' }}>
|
||||
<label style={{ display: 'grid', gap: '0.3rem' }}>
|
||||
<span style={{ fontWeight: 500, fontSize: '0.9rem' }}>Source product (ska bort)</span>
|
||||
<select
|
||||
value={sourceProductId}
|
||||
onChange={(e) => setSourceProductId(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '1rem',
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
>
|
||||
<option value="">Välj source</option>
|
||||
{products.map((product) => (
|
||||
<option key={product.id} value={product.id}>
|
||||
{product.canonicalName || product.name} (ID {product.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: '0.3rem' }}>
|
||||
<span style={{ fontWeight: 500, fontSize: '0.9rem' }}>Target product (ska behållas)</span>
|
||||
<select
|
||||
value={targetProductId}
|
||||
onChange={(e) => setTargetProductId(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '1rem',
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
>
|
||||
<option value="">Välj target</option>
|
||||
{products.map((product) => (
|
||||
<option key={product.id} value={product.id}>
|
||||
{product.canonicalName || product.name} (ID {product.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchPreview}
|
||||
disabled={isPending}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
background: '#0070f3',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem',
|
||||
minHeight: '44px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{isPending ? 'Hämtar preview...' : 'Förhandsgranska merge'}
|
||||
</button>
|
||||
|
||||
{preview ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsConfirming((prev) => !prev)}
|
||||
disabled={isPending}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
background: '#f0f0f0',
|
||||
color: '#333',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem',
|
||||
minHeight: '44px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{isConfirming ? 'Avbryt bekräftelse' : 'Gå vidare till bekräftelse'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? <p style={{ color: 'crimson', margin: 0 }}>{error}</p> : null}
|
||||
{successMessage ? <p style={{ color: 'green', margin: 0 }}>{successMessage}</p> : null}
|
||||
|
||||
{preview ? (
|
||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: '1rem',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||
}}
|
||||
>
|
||||
<article style={{ border: '1px solid #ddd', borderRadius: '8px', padding: '1rem' }}>
|
||||
<h3 style={{ marginTop: 0 }}>Source</h3>
|
||||
<div><strong>ID:</strong> {preview.source.id}</div>
|
||||
<div><strong>Namn:</strong> {preview.source.name}</div>
|
||||
<div><strong>Canonical:</strong> {preview.source.canonicalName || 'Saknas'}</div>
|
||||
<div><strong>Normalized:</strong> {preview.source.normalizedName}</div>
|
||||
<div><strong>Aktiv:</strong> {preview.source.isActive ? 'Ja' : 'Nej'}</div>
|
||||
<div><strong>Inventory count:</strong> {preview.source.inventoryCount}</div>
|
||||
</article>
|
||||
|
||||
<article style={{ border: '1px solid #ddd', borderRadius: '8px', padding: '1rem' }}>
|
||||
<h3 style={{ marginTop: 0 }}>Target</h3>
|
||||
<div><strong>ID:</strong> {preview.target.id}</div>
|
||||
<div><strong>Namn:</strong> {preview.target.name}</div>
|
||||
<div><strong>Canonical:</strong> {preview.target.canonicalName || 'Saknas'}</div>
|
||||
<div><strong>Normalized:</strong> {preview.target.normalizedName}</div>
|
||||
<div><strong>Aktiv:</strong> {preview.target.isActive ? 'Ja' : 'Nej'}</div>
|
||||
<div><strong>Inventory count:</strong> {preview.target.inventoryCount}</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article
|
||||
style={{
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
padding: '1rem',
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginTop: 0 }}>Det här kommer att hända</h3>
|
||||
<div>
|
||||
<strong>Inventory som flyttas:</strong> {preview.outcome.inventoryItemsToMove}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Source soft-deletas:</strong>{' '}
|
||||
{preview.outcome.sourceWillBeSoftDeleted ? 'Ja' : 'Nej'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Target förblir aktiv:</strong>{' '}
|
||||
{preview.outcome.targetWillRemainActive ? 'Ja' : 'Nej'}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{isConfirming ? (
|
||||
<article
|
||||
style={{
|
||||
border: '1px solid #e0b4b4',
|
||||
borderRadius: '8px',
|
||||
padding: '1rem',
|
||||
background: '#fff6f6',
|
||||
display: 'grid',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginTop: 0 }}>Bekräfta merge</h3>
|
||||
<p style={{ margin: 0 }}>
|
||||
Du är på väg att slå ihop{' '}
|
||||
<strong>{preview.source.canonicalName || preview.source.name}</strong> in i{' '}
|
||||
<strong>{preview.target.canonicalName || preview.target.name}</strong>.
|
||||
</p>
|
||||
<p style={{ margin: 0 }}>
|
||||
Source-produkten kommer att soft-deletas och kan återställas senare, men
|
||||
inventory flyttas till target.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirmMerge}
|
||||
disabled={isPending}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
background: '#c0392b',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem',
|
||||
minHeight: '44px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{isPending ? 'Slår ihop...' : 'Bekräfta merge'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsConfirming(false)}
|
||||
disabled={isPending}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
background: '#f0f0f0',
|
||||
color: '#333',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem',
|
||||
minHeight: '44px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Avbryt
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function ResetProductsButton() {
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleClick() {
|
||||
if (
|
||||
!confirm(
|
||||
'⚠️ Detta raderar ALLA produkter, inventory, taggar, kvitto-alias och pantry.\n\nKategorier och användare behålls.\n\nÄr du säker?',
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
setError(null);
|
||||
setIsPending(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/reset-products', { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.error || 'Återställning misslyckades');
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('product-list-changed'));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Okänt fel');
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={isPending}
|
||||
style={{
|
||||
padding: '0.6rem 1.25rem',
|
||||
background: isPending ? '#ccc' : '#fff',
|
||||
color: '#c00',
|
||||
border: '1px solid #c00',
|
||||
borderRadius: '4px',
|
||||
cursor: isPending ? 'not-allowed' : 'pointer',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
{isPending ? 'Återställer...' : '🗑 Återställ alla produkter'}
|
||||
</button>
|
||||
{error && (
|
||||
<p style={{ color: 'crimson', marginTop: '0.5rem', fontSize: '0.9rem' }}>{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { API_BASE } from '../../../lib/api';
|
||||
import { getAuthHeaders } from '../../../lib/auth-headers';
|
||||
|
||||
export async function updateProduct(formData: FormData) {
|
||||
const id = Number(formData.get('id'));
|
||||
const name = String(formData.get('name') || '').trim();
|
||||
const canonicalName = String(formData.get('canonicalName') || '').trim();
|
||||
const category = String(formData.get('category') || '').trim();
|
||||
const subcategory = String(formData.get('subcategory') || '').trim();
|
||||
const brand = String(formData.get('brand') || '').trim();
|
||||
const categoryIdRaw = formData.get('categoryId');
|
||||
const categoryId = categoryIdRaw !== '' && categoryIdRaw != null ? Number(categoryIdRaw) : null;
|
||||
|
||||
if (!name) throw new Error('Namn får inte vara tomt.');
|
||||
if (name.length > 100) throw new Error('Namn får inte vara längre än 100 tecken.');
|
||||
if (canonicalName.length > 100) throw new Error('Canonical name får inte vara längre än 100 tecken.');
|
||||
if (category.length > 100) throw new Error('Kategori får inte vara längre än 100 tecken.');
|
||||
if (subcategory.length > 100) throw new Error('Underkategori får inte vara längre än 100 tecken.');
|
||||
if (brand.length > 100) throw new Error('Varumärke får inte vara längre än 100 tecken.');
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/products/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...(await getAuthHeaders()) },
|
||||
body: JSON.stringify({
|
||||
name: name || undefined,
|
||||
canonicalName: canonicalName || undefined,
|
||||
category: category || null,
|
||||
subcategory: subcategory || null,
|
||||
brand: brand || null,
|
||||
categoryId,
|
||||
}),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Kunde inte uppdatera produkt: ${text}`);
|
||||
}
|
||||
|
||||
revalidatePath('/admin/products');
|
||||
}
|
||||
|
||||
export async function setProductTags(productId: number, tags: string[]) {
|
||||
const res = await fetch(`${API_BASE}/api/products/${productId}/tags`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...(await getAuthHeaders()) },
|
||||
body: JSON.stringify({ tags }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Kunde inte uppdatera taggar: ${text}`);
|
||||
}
|
||||
|
||||
revalidatePath('/admin/products');
|
||||
}
|
||||
|
||||
export async function updateProductWithTags(formData: FormData, tags: string[]) {
|
||||
const id = Number(formData.get('id'));
|
||||
const name = String(formData.get('name') || '').trim();
|
||||
const canonicalName = String(formData.get('canonicalName') || '').trim();
|
||||
const category = String(formData.get('category') || '').trim();
|
||||
const subcategory = String(formData.get('subcategory') || '').trim();
|
||||
const brand = String(formData.get('brand') || '').trim();
|
||||
const categoryIdRaw = formData.get('categoryId');
|
||||
const categoryId = categoryIdRaw !== '' && categoryIdRaw != null ? Number(categoryIdRaw) : null;
|
||||
|
||||
if (!name) throw new Error('Namn får inte vara tomt.');
|
||||
if (name.length > 100) throw new Error('Namn får inte vara längre än 100 tecken.');
|
||||
if (canonicalName.length > 100) throw new Error('Canonical name får inte vara längre än 100 tecken.');
|
||||
if (category.length > 100) throw new Error('Kategori får inte vara längre än 100 tecken.');
|
||||
if (subcategory.length > 100) throw new Error('Underkategori får inte vara längre än 100 tecken.');
|
||||
if (brand.length > 100) throw new Error('Varumärke får inte vara längre än 100 tecken.');
|
||||
|
||||
const authHeaders = await getAuthHeaders();
|
||||
const res = await fetch(`${API_BASE}/api/products/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders },
|
||||
body: JSON.stringify({
|
||||
name: name || undefined,
|
||||
canonicalName: canonicalName || undefined,
|
||||
category: category || null,
|
||||
subcategory: subcategory || null,
|
||||
brand: brand || null,
|
||||
categoryId,
|
||||
}),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Kunde inte uppdatera produkt: ${text}`);
|
||||
}
|
||||
|
||||
const updatedProduct = await res.json();
|
||||
|
||||
const tagsRes = await fetch(`${API_BASE}/api/products/${id}/tags`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders },
|
||||
body: JSON.stringify({ tags }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!tagsRes.ok) {
|
||||
const text = await tagsRes.text();
|
||||
throw new Error(`Kunde inte uppdatera taggar: ${text}`);
|
||||
}
|
||||
|
||||
// Fetch the complete product
|
||||
const fullRes = await fetch(`${API_BASE}/api/products/${id}`, {
|
||||
headers: authHeaders,
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!fullRes.ok) return updatedProduct;
|
||||
const result = await fullRes.json();
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function deleteProduct(id: number) {
|
||||
const res = await fetch(`${API_BASE}/api/products/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { ...(await getAuthHeaders()) },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Kunde inte ta bort produkt: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetAllProducts() {
|
||||
const res = await fetch(`${API_BASE}/api/products/reset-all`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await getAuthHeaders()) },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Kunde inte återställa produkter: ${text}`);
|
||||
}
|
||||
|
||||
revalidatePath('/admin/products');
|
||||
}
|
||||
|
||||
export async function bulkSetCategory(ids: number[], categoryId: number | null) {
|
||||
if (ids.length === 0) return;
|
||||
const res = await fetch(`${API_BASE}/api/products/bulk-update`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(await getAuthHeaders()) },
|
||||
body: JSON.stringify({ ids, categoryId }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Kunde inte uppdatera produkter: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function suggestProductCategory(productId: number) {
|
||||
const res = await fetch(`${API_BASE}/api/products/${productId}/suggest-category`, {
|
||||
method: 'GET',
|
||||
headers: { ...(await getAuthHeaders()) },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`AI-kategorisering misslyckades: ${text}`);
|
||||
}
|
||||
|
||||
return res.json() as Promise<{
|
||||
categoryId: number;
|
||||
categoryName: string;
|
||||
path: string;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
usedFallback: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function suggestBulkCategories(productIds?: number[]) {
|
||||
const res = await fetch(`${API_BASE}/api/products/ai-categorize-bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(await getAuthHeaders()) },
|
||||
body: JSON.stringify({ productIds }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Bulk-AI-kategorisering misslyckades: ${text}`);
|
||||
}
|
||||
|
||||
return res.json() as Promise<
|
||||
{
|
||||
productId: number;
|
||||
productName: string;
|
||||
suggestion: {
|
||||
categoryId: number;
|
||||
categoryName: string;
|
||||
path: string;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
usedFallback: boolean;
|
||||
};
|
||||
}[]
|
||||
>;
|
||||
}
|
||||
|
||||
export async function setProductStatus(id: number, status: 'active' | 'rejected') {
|
||||
const res = await fetch(`${API_BASE}/api/products/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...(await getAuthHeaders()) },
|
||||
body: JSON.stringify({ status }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Kunde inte uppdatera status: ${text}`);
|
||||
}
|
||||
|
||||
revalidatePath('/admin/products');
|
||||
revalidatePath('/admin/products/pending');
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import MergePreviewForm from './MergePreviewForm';
|
||||
import AdminProductList from './AdminProductList';
|
||||
import Navigation from '../../Navigation';
|
||||
import ExpandableCreateProductSection from './ExpandableCreateProductSection';
|
||||
import ResetProductsButton from './ResetProductsButton';
|
||||
|
||||
export default async function AdminProductsPage() {
|
||||
return (
|
||||
<main style={{ padding: '1rem', maxWidth: '1100px', margin: '0 auto' }}>
|
||||
<Navigation />
|
||||
<h1 style={{ marginBottom: '1.5rem' }}>Admin: Produkter</h1>
|
||||
<p>Här kan du granska och standardisera produktnamn.</p>
|
||||
|
||||
<ExpandableCreateProductSection />
|
||||
|
||||
<ResetProductsButton />
|
||||
|
||||
<MergePreviewForm />
|
||||
|
||||
<AdminProductList />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
type PendingProduct = {
|
||||
id: number;
|
||||
name: string;
|
||||
canonicalName: string | null;
|
||||
createdAt: string;
|
||||
categoryRef?: { name: string; parent?: { name: string } } | null;
|
||||
owner?: { id: number; username: string } | null;
|
||||
};
|
||||
|
||||
export default function PendingProductsClient({ products: initial }: { products: PendingProduct[] }) {
|
||||
const [products, setProducts] = useState<PendingProduct[]>(initial);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [processing, setProcessing] = useState<number | null>(null);
|
||||
|
||||
function handleAction(id: number, status: 'active' | 'rejected') {
|
||||
setError(null);
|
||||
setProcessing(id);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/product-status/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.error || 'Fel vid uppdatering');
|
||||
}
|
||||
setProducts((prev) => prev.filter((p) => p.id !== id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fel vid uppdatering');
|
||||
} finally {
|
||||
setProcessing(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (products.length === 0) {
|
||||
return (
|
||||
<div style={{ color: '#64748b', background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 8, padding: '2rem', textAlign: 'center' }}>
|
||||
Inga väntande produktförslag 🎉
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && (
|
||||
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 6, padding: '0.6rem 1rem', color: '#dc2626', marginBottom: '1rem', fontSize: 14 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f1f5f9', textAlign: 'left' }}>
|
||||
{['Produkt', 'Kategori (AI)', 'Föreslagen av', 'Datum', 'Åtgärd'].map((h) => (
|
||||
<th key={h} style={{ padding: '0.6rem 0.8rem', borderBottom: '2px solid #e2e8f0' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{products.map((p) => {
|
||||
const isProcessing = processing === p.id && isPending;
|
||||
const categoryPath = [p.categoryRef?.parent?.name, p.categoryRef?.name].filter(Boolean).join(' › ');
|
||||
return (
|
||||
<tr key={p.id} style={{ borderBottom: '1px solid #e2e8f0', opacity: isProcessing ? 0.5 : 1 }}>
|
||||
<td style={{ padding: '0.6rem 0.8rem' }}>
|
||||
<div style={{ fontWeight: 500 }}>{p.canonicalName ?? p.name}</div>
|
||||
{p.canonicalName && p.canonicalName !== p.name && (
|
||||
<div style={{ fontSize: 12, color: '#94a3b8' }}>{p.name}</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '0.6rem 0.8rem' }}>
|
||||
{categoryPath ? (
|
||||
<span style={{ fontSize: 12, background: '#e0f2fe', borderRadius: 999, padding: '0.15rem 0.5rem', color: '#0369a1' }}>{categoryPath}</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 12, color: '#94a3b8' }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '0.6rem 0.8rem', color: '#475569' }}>{p.owner?.username ?? '—'}</td>
|
||||
<td style={{ padding: '0.6rem 0.8rem', color: '#94a3b8', fontSize: 12 }}>
|
||||
{new Date(p.createdAt).toLocaleDateString('sv-SE')}
|
||||
</td>
|
||||
<td style={{ padding: '0.6rem 0.8rem' }}>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
onClick={() => handleAction(p.id, 'active')}
|
||||
disabled={isProcessing}
|
||||
style={{ padding: '0.3rem 0.7rem', background: '#dcfce7', border: '1px solid #86efac', borderRadius: 4, cursor: 'pointer', fontSize: 12, color: '#15803d', fontWeight: 600 }}
|
||||
>
|
||||
✓ Godkänn
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction(p.id, 'rejected')}
|
||||
disabled={isProcessing}
|
||||
style={{ padding: '0.3rem 0.7rem', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 4, cursor: 'pointer', fontSize: 12, color: '#dc2626', fontWeight: 600 }}
|
||||
>
|
||||
✕ Avvisa
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { auth } from '../../../../auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
import Navigation from '../../../Navigation';
|
||||
import { getAuthHeaders } from '../../../../lib/auth-headers';
|
||||
import PendingProductsClient from './PendingProductsClient';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL ?? 'http://recipe-api:8080';
|
||||
|
||||
export default async function PendingProductsPage() {
|
||||
const session = await auth();
|
||||
if (!session || (session.user as any)?.role !== 'admin') redirect('/');
|
||||
|
||||
const headers = await getAuthHeaders();
|
||||
const res = await fetch(`${API_BASE}/api/products/pending`, { headers, cache: 'no-store' });
|
||||
const products = res.ok ? await res.json() : [];
|
||||
|
||||
return (
|
||||
<main style={{ padding: '1rem', maxWidth: '1100px', margin: '0 auto' }}>
|
||||
<Navigation />
|
||||
<h1 style={{ marginBottom: '0.5rem' }}>Väntande produktförslag</h1>
|
||||
<p style={{ color: '#64748b', marginBottom: '1.5rem' }}>
|
||||
Produkter som användare har föreslagit. Godkänn för att göra dem tillgängliga i katalogen, eller avvisa för att ta bort dem.
|
||||
</p>
|
||||
<PendingProductsClient products={products} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
type User = {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
users: User[];
|
||||
currentUserId: string;
|
||||
};
|
||||
|
||||
export default function UserAdminClient({ users: initial, currentUserId }: Props) {
|
||||
const [users, setUsers] = useState(initial);
|
||||
const [loading, setLoading] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function toggleRole(user: User) {
|
||||
const newRole = user.role === 'admin' ? 'user' : 'admin';
|
||||
setLoading(user.id);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/admin-users/${user.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role: newRole }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.message ?? 'Okänt fel');
|
||||
}
|
||||
const updated: User = await res.json();
|
||||
setUsers((prev) => prev.map((u) => (u.id === updated.id ? { ...u, role: updated.role } : u)));
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-800 rounded">{error}</div>
|
||||
)}
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 text-left">
|
||||
<th className="p-2 border">Användarnamn</th>
|
||||
<th className="p-2 border">E-post</th>
|
||||
<th className="p-2 border">Namn</th>
|
||||
<th className="p-2 border">Roll</th>
|
||||
<th className="p-2 border">Skapad</th>
|
||||
<th className="p-2 border">Åtgärd</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => {
|
||||
const isSelf = String(user.id) === currentUserId;
|
||||
return (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="p-2 border font-medium">{user.username}</td>
|
||||
<td className="p-2 border">{user.email}</td>
|
||||
<td className="p-2 border">
|
||||
{[user.firstName, user.lastName].filter(Boolean).join(' ') || '—'}
|
||||
</td>
|
||||
<td className="p-2 border">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs font-semibold ${
|
||||
user.role === 'admin' ? 'bg-purple-100 text-purple-800' : 'bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2 border text-gray-500">
|
||||
{new Date(user.createdAt).toLocaleDateString('sv-SE')}
|
||||
</td>
|
||||
<td className="p-2 border">
|
||||
{isSelf ? (
|
||||
<span className="text-gray-400 text-xs">Du själv</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => toggleRole(user)}
|
||||
disabled={loading === user.id}
|
||||
className="px-3 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading === user.id
|
||||
? 'Sparar…'
|
||||
: user.role === 'admin'
|
||||
? 'Sätt som user'
|
||||
: 'Sätt som admin'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
redirect('/profil?tab=anvandare');
|
||||
}
|
||||
|
||||
@@ -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' } });
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 på 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 "{row.rawName}" 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' };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' }}>
|
||||
Gå till varor som finns hemma
|
||||
</Link>
|
||||
<Link href="/recipes" style={{ padding: '0.5rem', background: '#eee', borderRadius: '4px', textDecoration: 'none', color: '#222' }}>
|
||||
Gå 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import type { Recipe } from '../../features/inventory/types';
|
||||
|
||||
function RecipePlaceholder({ name }: { name: string }) {
|
||||
const initial = name.trim().charAt(0).toUpperCase() || '?';
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '160px',
|
||||
background: '#e9ecef',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '2.5rem',
|
||||
fontWeight: 700,
|
||||
color: '#868e96',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RecipeGrid({ recipes }: { recipes: Recipe[] }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [sort, setSort] = useState<'name' | 'newest' | 'oldest' | 'ingredients'>('newest');
|
||||
const [onlyWithImage, setOnlyWithImage] = useState(false);
|
||||
|
||||
const filtered = recipes
|
||||
.filter((r) => r.name.toLowerCase().includes(search.toLowerCase()))
|
||||
.filter((r) => !onlyWithImage || !!r.imageUrl)
|
||||
.sort((a, b) => {
|
||||
if (sort === 'name') return a.name.localeCompare(b.name, 'sv');
|
||||
if (sort === 'newest') return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
if (sort === 'oldest') return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
if (sort === 'ingredients') return (b.ingredients?.length ?? 0) - (a.ingredients?.length ?? 0);
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Sök efter recept..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{
|
||||
flex: '1 1 200px',
|
||||
padding: '0.6rem 1rem',
|
||||
fontSize: '1rem',
|
||||
border: '1px solid #ced4da',
|
||||
borderRadius: '24px',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as typeof sort)}
|
||||
style={{
|
||||
padding: '0.55rem 0.75rem',
|
||||
fontSize: '0.9rem',
|
||||
border: '1px solid #ced4da',
|
||||
borderRadius: '8px',
|
||||
background: '#fff',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value="newest">Senast tillagda</option>
|
||||
<option value="oldest">Äldst först</option>
|
||||
<option value="name">Namn (A–Ö)</option>
|
||||
<option value="ingredients">Flest ingredienser</option>
|
||||
</select>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.9rem', cursor: 'pointer', userSelect: 'none' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyWithImage}
|
||||
onChange={(e) => setOnlyWithImage(e.target.checked)}
|
||||
/>
|
||||
Endast med bild
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<p style={{ color: '#868e96', textAlign: 'center', marginTop: '2rem' }}>
|
||||
{search || onlyWithImage ? 'Inga recept matchar filtren.' : 'Inga recept tillagda ännu.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
{filtered.map((recipe) => (
|
||||
<Link
|
||||
key={recipe.id}
|
||||
href={`/recipes/${recipe.id}`}
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #dee2e6',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
transition: 'box-shadow 0.15s',
|
||||
background: '#fff',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
((e.currentTarget as HTMLDivElement).style.boxShadow = '0 4px 12px rgba(0,0,0,0.12)')
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
((e.currentTarget as HTMLDivElement).style.boxShadow = 'none')
|
||||
}
|
||||
>
|
||||
{recipe.imageUrl ? (
|
||||
<img
|
||||
src={recipe.imageUrl}
|
||||
alt={recipe.name}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '160px',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<RecipePlaceholder name={recipe.name} />
|
||||
)}
|
||||
<div style={{ padding: '0.75rem 1rem 0.85rem' }}>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '1rem',
|
||||
fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{recipe.name}
|
||||
</h3>
|
||||
{recipe.description && (
|
||||
<p
|
||||
style={{
|
||||
margin: '0.25rem 0 0.5rem',
|
||||
fontSize: '0.85rem',
|
||||
color: '#868e96',
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{recipe.description}
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '0.75rem', marginTop: recipe.description ? 0 : '0.4rem', fontSize: '0.78rem', color: '#adb5bd' }}>
|
||||
{recipe.ingredients?.length > 0 && (
|
||||
<span>{recipe.ingredients.length} ingredienser</span>
|
||||
)}
|
||||
<span>{new Date(recipe.createdAt).toLocaleDateString('sv-SE')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { parseErrorResponse } from '../../lib/error-handler';
|
||||
import type {
|
||||
Recipe,
|
||||
RecipeInventoryPreview,
|
||||
} from '../../features/inventory/types';
|
||||
|
||||
type Props = {
|
||||
recipes: Recipe[];
|
||||
};
|
||||
|
||||
function getStatusStyle(status: 'enough' | 'missing' | 'unit_mismatch') {
|
||||
if (status === 'enough') {
|
||||
return {
|
||||
label: 'Räcker',
|
||||
color: '#1f5f2c',
|
||||
background: '#ecf8ee',
|
||||
border: '#b9e0bf',
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'missing') {
|
||||
return {
|
||||
label: 'Saknas',
|
||||
color: '#8b0000',
|
||||
background: '#ffeaea',
|
||||
border: '#f1b5b5',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: 'Enhetskonflikt',
|
||||
color: '#8a4b00',
|
||||
background: '#fff4e5',
|
||||
border: '#f0cf9b',
|
||||
};
|
||||
}
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) return null;
|
||||
return new Date(value).toLocaleDateString('sv-SE');
|
||||
}
|
||||
|
||||
function isWeightUnit(unit: string): boolean {
|
||||
return ['kg', 'g', 'mg', 'ml', 'l'].includes(unit.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function isPieceUnit(unit: string): boolean {
|
||||
return ['st', 'stycke'].includes(unit.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export default function RecipePreview({ recipes }: Props) {
|
||||
const [selectedRecipeId, setSelectedRecipeId] = useState('');
|
||||
const [preview, setPreview] = useState<RecipeInventoryPreview | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const selectAndLoad = (id: string) => {
|
||||
setSelectedRecipeId(id);
|
||||
setError(null);
|
||||
setPreview(null);
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/recipe-preview-proxy?id=${id}`, {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = await parseErrorResponse(res);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const data: RecipeInventoryPreview = await res.json();
|
||||
setPreview(data);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Ett okänt fel inträffade.';
|
||||
setError(message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const loadPreview = () => {
|
||||
setError(null);
|
||||
setPreview(null);
|
||||
|
||||
if (!selectedRecipeId) {
|
||||
setError('Välj ett recept.');
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/recipe-preview-proxy?id=${selectedRecipeId}`, {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = await parseErrorResponse(res);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const data: RecipeInventoryPreview = await res.json();
|
||||
setPreview(data);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Ett okänt fel inträffade.';
|
||||
setError(message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const listedRecipes = recipes.slice(0, 10);
|
||||
|
||||
return (
|
||||
<section style={{ display: 'grid', gap: '1rem' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '1rem', alignItems: 'start' }}>
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
padding: '1rem',
|
||||
display: 'grid',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0 }}>Recept mot hemmavaror</h2>
|
||||
|
||||
<label>
|
||||
Recept
|
||||
<br />
|
||||
<select
|
||||
value={selectedRecipeId}
|
||||
onChange={(e) => setSelectedRecipeId(e.target.value)}
|
||||
style={{ width: '100%', padding: '0.5rem' }}
|
||||
>
|
||||
<option value="">Välj recept</option>
|
||||
{recipes.map((recipe) => (
|
||||
<option key={recipe.id} value={recipe.id}>
|
||||
{recipe.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadPreview}
|
||||
disabled={isPending}
|
||||
style={{ padding: '0.6rem 1rem' }}
|
||||
>
|
||||
{isPending ? 'Hämtar preview...' : 'Visa preview'}
|
||||
</button>
|
||||
{selectedRecipeId && (
|
||||
<Link
|
||||
href={`/recipes/${selectedRecipeId}/edit`}
|
||||
style={{
|
||||
padding: '0.6rem 1rem',
|
||||
background: '#f0f0f0',
|
||||
color: '#333',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
textDecoration: 'none',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
Redigera recept
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? <p style={{ color: 'crimson', margin: 0 }}>{error}</p> : null}
|
||||
</div>
|
||||
|
||||
{/* Receptlista till höger */}
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
padding: '1rem',
|
||||
minWidth: '180px',
|
||||
maxWidth: '220px',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: '0 0 0.75rem 0', fontSize: '1rem' }}>Mina recept</h3>
|
||||
<ul style={{ margin: 0, padding: 0, listStyle: 'none', display: 'grid', gap: '0.4rem' }}>
|
||||
{listedRecipes.map((recipe) => (
|
||||
<li key={recipe.id}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => selectAndLoad(String(recipe.id))}
|
||||
style={{
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
padding: '0.4rem 0.6rem',
|
||||
background: String(recipe.id) === selectedRecipeId ? '#e8f0fe' : 'transparent',
|
||||
border: `1px solid ${String(recipe.id) === selectedRecipeId ? '#4285f4' : '#eee'}`,
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: String(recipe.id) === selectedRecipeId ? 600 : 400,
|
||||
color: String(recipe.id) === selectedRecipeId ? '#1a56db' : '#333',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
{recipe.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const selected = recipes.find((r) => String(r.id) === selectedRecipeId);
|
||||
if (!selected?.instructions) return null;
|
||||
return (
|
||||
<article
|
||||
style={{
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
padding: '1rem',
|
||||
display: 'grid',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: 0 }}>Instruktioner – {selected.name}</h3>
|
||||
<p style={{ margin: 0, whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
|
||||
{selected.instructions}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
})()}
|
||||
|
||||
{preview && preview.summary.missingCount > 0 && (
|
||||
<article
|
||||
style={{
|
||||
border: '1px solid #f1b5b5',
|
||||
borderRadius: '8px',
|
||||
padding: '1rem',
|
||||
display: 'grid',
|
||||
gap: '0.5rem',
|
||||
background: '#ffeaea',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: 0, color: '#8b0000' }}>
|
||||
Saknade ingredienser ({preview.summary.missingCount})
|
||||
</h3>
|
||||
<ul style={{ margin: 0, paddingLeft: '1.25rem', display: 'grid', gap: '0.25rem' }}>
|
||||
{preview.ingredients
|
||||
.filter((ing) => ing.status === 'missing')
|
||||
.map((ing) => (
|
||||
<li key={ing.ingredientId}>
|
||||
<strong>{ing.productName}</strong> — saknas{' '}
|
||||
{ing.missingQuantity} {ing.requiredUnit}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
)}
|
||||
|
||||
{preview ? (
|
||||
<section style={{ display: 'grid', gap: '1rem' }}>
|
||||
<article
|
||||
style={{
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
padding: '1rem',
|
||||
display: 'grid',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: 0 }}>{preview.recipe.name}</h3>
|
||||
{preview.recipe.description ? <div>{preview.recipe.description}</div> : null}
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<span>Ingredienser: {preview.summary.totalIngredients}</span>
|
||||
<span>Räcker: {preview.summary.enoughCount}</span>
|
||||
<span>Saknas: {preview.summary.missingCount}</span>
|
||||
<span>Enhetskonflikter: {preview.summary.unitMismatchCount}</span>
|
||||
<strong>
|
||||
{preview.summary.canCookExactly
|
||||
? 'Kan lagas exakt'
|
||||
: 'Kan inte lagas exakt ännu'}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
{preview.summary.unitMismatchCount > 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: '0.75rem',
|
||||
background: '#fff4e5',
|
||||
border: '1px solid #f0cf9b',
|
||||
borderRadius: '4px',
|
||||
color: '#8a4b00',
|
||||
fontSize: '0.9rem',
|
||||
marginTop: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<strong>⚠️ Enhetskonflikt!</strong> {preview.summary.unitMismatchCount} ingrediens
|
||||
{preview.summary.unitMismatchCount !== 1 ? 'er har' : ' har'} olika enheter än vad som finns i hemmavaror.
|
||||
<br />
|
||||
<span style={{ fontSize: '0.85rem', marginTop: '0.25rem', display: 'block' }}>
|
||||
T.ex. receptet säger "0.5 st" men du har lagrat "1.3 kg". Du kan antingen:
|
||||
<ul style={{ margin: '0.5rem 0 0 1rem', paddingLeft: '1rem' }}>
|
||||
<li>Redigera receptet för att matcha dina enheter</li>
|
||||
<li>Lagra ingrediensen med samma enhet som receptet använder</li>
|
||||
</ul>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
||||
{preview.ingredients.map((ingredient) => {
|
||||
const statusStyle = getStatusStyle(ingredient.status);
|
||||
|
||||
return (
|
||||
<article
|
||||
key={ingredient.ingredientId}
|
||||
style={{
|
||||
border: `1px solid ${statusStyle.border}`,
|
||||
borderRadius: '8px',
|
||||
padding: '1rem',
|
||||
display: 'grid',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: '1rem',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>{ingredient.productName}</strong>
|
||||
<div>
|
||||
Krävs: {ingredient.requiredQuantity} {ingredient.requiredUnit}
|
||||
</div>
|
||||
{ingredient.note ? <div>Notering: {ingredient.note}</div> : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: '0.3rem 0.6rem',
|
||||
borderRadius: '999px',
|
||||
background: statusStyle.background,
|
||||
color: statusStyle.color,
|
||||
border: `1px solid ${statusStyle.border}`,
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{statusStyle.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: '0.35rem' }}>
|
||||
<div>
|
||||
Tillgängligt i jämförbar enhet: {ingredient.availableQuantity}{' '}
|
||||
{ingredient.availableUnit || ''}
|
||||
</div>
|
||||
|
||||
{ingredient.status === 'missing' ? (
|
||||
<div>
|
||||
Saknas: {ingredient.missingQuantity} {ingredient.requiredUnit}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{ingredient.status === 'unit_mismatch' ? (
|
||||
<div
|
||||
style={{
|
||||
padding: '0.5rem',
|
||||
background: '#fff4e5',
|
||||
border: '1px solid #f0cf9b',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.9rem',
|
||||
marginTop: '0.25rem',
|
||||
}}
|
||||
>
|
||||
<strong>Enhetsproblem:</strong> Receptet kräver {ingredient.requiredUnit} men hemmavaror lagras i andra enheter.
|
||||
Uppdatera receptet eller lagra ingrediensen med rätt enhet.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{ingredient.matchingInventoryItems.length > 0 ? (
|
||||
<div style={{ display: 'grid', gap: '0.35rem' }}>
|
||||
<strong>Matchande inventory</strong>
|
||||
{ingredient.matchingInventoryItems.map((item) => (
|
||||
<div key={item.id}>
|
||||
#{item.id}: {item.quantity} {item.unit}
|
||||
{item.brand ? `, ${item.brand}` : ''}
|
||||
{item.location ? `, ${item.location}` : ''}
|
||||
{item.bestBeforeDate ? `, bäst före ${formatDate(item.bestBeforeDate)}` : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{ingredient.otherInventoryItems && ingredient.otherInventoryItems.length > 0 ? (
|
||||
<div style={{ display: 'grid', gap: '0.35rem' }}>
|
||||
<strong>Andra enheter:</strong>
|
||||
{ingredient.otherInventoryItems.map((item) => {
|
||||
const weight = isWeightUnit(item.unit);
|
||||
const pieces = isPieceUnit(ingredient.requiredUnit);
|
||||
const pieces2 = isPieceUnit(item.unit);
|
||||
const weight2 = isWeightUnit(ingredient.requiredUnit);
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
#{item.id}: {item.quantity} {item.unit}
|
||||
{item.canConvert ? (
|
||||
<span> ≈ {(item.convertedQuantity || 0).toFixed(2)} {ingredient.requiredUnit}</span>
|
||||
) : (
|
||||
<span>
|
||||
{weight && pieces
|
||||
? ' (kan inte konvertera vikt till stycken)'
|
||||
: pieces2 && weight2
|
||||
? ' (kan inte konvertera stycken till vikt)'
|
||||
: ' (kan inte konvertera)'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user