feat(meal-plan): add servings field to MealPlanEntry and update related functionality
feat(products): implement bulk update for product categories feat(recipes): add servings input to WriteRecipePage and update MealPlanClient for servings management refactor(types): enhance Product and Category types with additional properties
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import type { Product } from '../../../features/inventory/types';
|
||||
import { useState, useMemo, useEffect, useTransition } from 'react';
|
||||
import type { Product, Category } from '../../../features/inventory/types';
|
||||
import EditProductForm from './EditProductForm';
|
||||
import { bulkSetCategory } from './actions';
|
||||
|
||||
type CategoryNode = Category & { children: CategoryNode[] };
|
||||
|
||||
type Props = {
|
||||
products: Product[];
|
||||
@@ -13,21 +16,48 @@ const sortOptions = [
|
||||
{ value: 'nameAsc', label: 'Namn A–Ö' },
|
||||
];
|
||||
|
||||
function flattenTree(nodes: CategoryNode[], depth = 0): { id: number; label: string }[] {
|
||||
const result: { id: number; label: string }[] = [];
|
||||
for (const node of nodes) {
|
||||
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({ products }: Props) {
|
||||
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 [isPending, startTransition] = useTransition();
|
||||
const [bulkError, setBulkError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/categories')
|
||||
.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 = q
|
||||
? products.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
(p.canonicalName ?? '').toLowerCase().includes(q) ||
|
||||
(p.normalizedName ?? '').toLowerCase().includes(q),
|
||||
)
|
||||
: [...products];
|
||||
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) =>
|
||||
@@ -38,31 +68,61 @@ export default function AdminProductList({ products }: Props) {
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [products, search, sort]);
|
||||
}, [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 = () => {
|
||||
setBulkError(null);
|
||||
const ids = Array.from(selectedIds);
|
||||
if (ids.length === 0) return;
|
||||
const categoryId = bulkCategoryId === '' ? null : bulkCategoryId === '__remove__' ? null : Number(bulkCategoryId);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await bulkSetCategory(ids, categoryId);
|
||||
setSelectedIds(new Set());
|
||||
setBulkCategoryId('');
|
||||
} catch (err) {
|
||||
setBulkError(err instanceof Error ? err.message : 'Fel vid uppdatering');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '1rem',
|
||||
alignItems: 'center',
|
||||
marginBottom: '1rem',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{/* 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',
|
||||
}}
|
||||
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' }}>
|
||||
@@ -84,38 +144,115 @@ export default function AdminProductList({ products }: Props) {
|
||||
{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>
|
||||
</div>
|
||||
|
||||
{search && (
|
||||
<span style={{ color: '#666', fontSize: '0.9rem', whiteSpace: 'nowrap' }}>
|
||||
{filtered.length} av {products.length} produkter
|
||||
</span>
|
||||
)}
|
||||
<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={isPending}
|
||||
style={{ padding: '0.4rem 0.9rem', background: '#0070f3', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '0.9rem' }}
|
||||
>
|
||||
{isPending ? '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: '1px solid #ddd',
|
||||
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>
|
||||
<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.category && (
|
||||
<span style={{ marginLeft: '0.5rem', fontSize: '0.8rem', background: '#eee', borderRadius: '999px', padding: '0.15rem 0.5rem', color: '#555' }}>
|
||||
{product.category}
|
||||
</span>
|
||||
)}
|
||||
<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>
|
||||
@@ -129,3 +266,15 @@ export default function AdminProductList({ products }: Props) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem', color: '#888' }}>
|
||||
Normalized: {product.normalizedName}
|
||||
</div>
|
||||
<EditProductForm product={product} />
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,3 +88,20 @@ export async function resetAllProducts() {
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
revalidatePath('/admin/products');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user