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:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `MealPlanEntry` ADD COLUMN `servings` INTEGER NULL;
|
||||||
@@ -181,6 +181,7 @@ model MealPlanEntry {
|
|||||||
date DateTime @db.Date
|
date DateTime @db.Date
|
||||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||||
recipeId Int
|
recipeId Int
|
||||||
|
servings Int?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsDateString, IsInt, IsPositive } from 'class-validator';
|
import { IsDateString, IsInt, IsOptional, IsPositive, Min } from 'class-validator';
|
||||||
|
|
||||||
export class CreateMealPlanEntryDto {
|
export class CreateMealPlanEntryDto {
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
@@ -7,4 +7,9 @@ export class CreateMealPlanEntryDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
@IsPositive()
|
@IsPositive()
|
||||||
recipeId: number;
|
recipeId: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
servings?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const recipeSelect = {
|
|||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
imageUrl: true,
|
imageUrl: true,
|
||||||
|
servings: true,
|
||||||
ingredients: {
|
ingredients: {
|
||||||
select: {
|
select: {
|
||||||
quantity: true,
|
quantity: true,
|
||||||
@@ -36,8 +37,8 @@ export class MealPlanService {
|
|||||||
const date = new Date(dto.date);
|
const date = new Date(dto.date);
|
||||||
return this.prisma.mealPlanEntry.upsert({
|
return this.prisma.mealPlanEntry.upsert({
|
||||||
where: { date },
|
where: { date },
|
||||||
create: { date, recipeId: dto.recipeId },
|
create: { date, recipeId: dto.recipeId, servings: dto.servings ?? null },
|
||||||
update: { recipeId: dto.recipeId },
|
update: { recipeId: dto.recipeId, servings: dto.servings ?? null },
|
||||||
include: { recipe: { select: recipeSelect } },
|
include: { recipe: { select: recipeSelect } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -55,13 +56,16 @@ export class MealPlanService {
|
|||||||
async shoppingList(from: string, to: string) {
|
async shoppingList(from: string, to: string) {
|
||||||
const entries = await this.findByRange(from, to);
|
const entries = await this.findByRange(from, to);
|
||||||
|
|
||||||
// Summera ingredienser per produkt+enhet
|
// Summera ingredienser per produkt+enhet (skalat per portionsantal)
|
||||||
const map = new Map<string, { productId: number; name: string; quantity: number; unit: string }>();
|
const map = new Map<string, { productId: number; name: string; quantity: number; unit: string }>();
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
|
const recipeServings = (entry.recipe as any).servings as number | null;
|
||||||
|
const entryServings = (entry as any).servings as number | null;
|
||||||
|
const scale = recipeServings && entryServings ? entryServings / recipeServings : 1;
|
||||||
for (const ing of entry.recipe.ingredients) {
|
for (const ing of entry.recipe.ingredients) {
|
||||||
const key = `${ing.product.id}-${ing.unit}`;
|
const key = `${ing.product.id}-${ing.unit}`;
|
||||||
const existing = map.get(key);
|
const existing = map.get(key);
|
||||||
const qty = Number(ing.quantity);
|
const qty = Number(ing.quantity) * scale;
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.quantity += qty;
|
existing.quantity += qty;
|
||||||
} else {
|
} else {
|
||||||
@@ -82,12 +86,15 @@ export class MealPlanService {
|
|||||||
async inventoryCompare(from: string, to: string) {
|
async inventoryCompare(from: string, to: string) {
|
||||||
const entries = await this.findByRange(from, to);
|
const entries = await this.findByRange(from, to);
|
||||||
|
|
||||||
// Aggregera ingredienser per produkt+enhet
|
// Aggregera ingredienser per produkt+enhet (skalat per portionsantal)
|
||||||
const map = new Map<string, { productId: number; name: string; required: number; unit: string }>();
|
const map = new Map<string, { productId: number; name: string; required: number; unit: string }>();
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
|
const recipeServings = (entry.recipe as any).servings as number | null;
|
||||||
|
const entryServings = (entry as any).servings as number | null;
|
||||||
|
const scale = recipeServings && entryServings ? entryServings / recipeServings : 1;
|
||||||
for (const ing of entry.recipe.ingredients) {
|
for (const ing of entry.recipe.ingredients) {
|
||||||
const key = `${ing.product.id}-${ing.unit}`;
|
const key = `${ing.product.id}-${ing.unit}`;
|
||||||
const qty = Number(ing.quantity);
|
const qty = Number(ing.quantity) * scale;
|
||||||
const existing = map.get(key);
|
const existing = map.get(key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.required += qty;
|
existing.required += qty;
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { IsArray, IsInt, IsNumber, IsOptional, ArrayMinSize } from 'class-validator';
|
||||||
|
|
||||||
|
export class BulkUpdateProductsDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@IsInt({ each: true })
|
||||||
|
ids: number[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
categoryId?: number | null;
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ import { ProductsService } from './products.service';
|
|||||||
import { MergeProductsDto } from './dto/merge-products.dto';
|
import { MergeProductsDto } from './dto/merge-products.dto';
|
||||||
import { UpdateCanonicalNameDto } from './dto/update-canonical-name.dto';
|
import { UpdateCanonicalNameDto } from './dto/update-canonical-name.dto';
|
||||||
import { SetTagsDto } from './dto/set-tags.dto';
|
import { SetTagsDto } from './dto/set-tags.dto';
|
||||||
import { UpsertNutritionDto } from './dto/upsert-nutrition.dto';
|
import { BulkUpdateProductsDto } from './dto/bulk-update-products.dto';
|
||||||
|
|
||||||
@Controller('products')
|
@Controller('products')
|
||||||
export class ProductsController {
|
export class ProductsController {
|
||||||
@@ -116,4 +116,10 @@ export class ProductsController {
|
|||||||
resetAll() {
|
resetAll() {
|
||||||
return this.productsService.resetAll();
|
return this.productsService.resetAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('bulk-update')
|
||||||
|
@HttpCode(200)
|
||||||
|
bulkUpdate(@Body() body: BulkUpdateProductsDto) {
|
||||||
|
return this.productsService.bulkUpdate(body.ids, { categoryId: body.categoryId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -397,4 +397,14 @@ export class ProductsService {
|
|||||||
]);
|
]);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async bulkUpdate(ids: number[], data: { categoryId?: number | null }) {
|
||||||
|
const updateData: Record<string, any> = {};
|
||||||
|
if ('categoryId' in data) {
|
||||||
|
updateData.categoryId = data.categoryId;
|
||||||
|
}
|
||||||
|
if (Object.keys(updateData).length === 0) return { updated: 0 };
|
||||||
|
await this.prisma.product.updateMany({ where: { id: { in: ids } }, data: updateData });
|
||||||
|
return { updated: ids.length };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo, useEffect, useTransition } from 'react';
|
||||||
import type { Product } from '../../../features/inventory/types';
|
import type { Product, Category } from '../../../features/inventory/types';
|
||||||
import EditProductForm from './EditProductForm';
|
import EditProductForm from './EditProductForm';
|
||||||
|
import { bulkSetCategory } from './actions';
|
||||||
|
|
||||||
|
type CategoryNode = Category & { children: CategoryNode[] };
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
products: Product[];
|
products: Product[];
|
||||||
@@ -13,21 +16,48 @@ const sortOptions = [
|
|||||||
{ value: 'nameAsc', label: 'Namn A–Ö' },
|
{ 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) {
|
export default function AdminProductList({ products }: Props) {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [sort, setSort] = useState('createdDesc');
|
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 filtered = useMemo(() => {
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
|
|
||||||
let result = q
|
let result = products.filter((p) => {
|
||||||
? products.filter(
|
if (showUncategorizedOnly && p.categoryId != null) return false;
|
||||||
(p) =>
|
if (q) {
|
||||||
|
return (
|
||||||
p.name.toLowerCase().includes(q) ||
|
p.name.toLowerCase().includes(q) ||
|
||||||
(p.canonicalName ?? '').toLowerCase().includes(q) ||
|
(p.canonicalName ?? '').toLowerCase().includes(q) ||
|
||||||
(p.normalizedName ?? '').toLowerCase().includes(q),
|
(p.normalizedName ?? '').toLowerCase().includes(q)
|
||||||
)
|
);
|
||||||
: [...products];
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
if (sort === 'nameAsc') {
|
if (sort === 'nameAsc') {
|
||||||
result.sort((a, b) =>
|
result.sort((a, b) =>
|
||||||
@@ -38,31 +68,61 @@ export default function AdminProductList({ products }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
{/* Sök + sortering + filter */}
|
||||||
style={{
|
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||||
display: 'flex',
|
|
||||||
gap: '1rem',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: '1rem',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Sök produkt…"
|
placeholder="Sök produkt…"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
style={{
|
style={{ flex: '1 1 200px', padding: '0.5rem 0.75rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '1rem' }}
|
||||||
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' }}>
|
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
|
||||||
@@ -84,39 +144,116 @@ export default function AdminProductList({ products }: Props) {
|
|||||||
{opt.label}
|
{opt.label}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{search && (
|
|
||||||
<span style={{ color: '#666', fontSize: '0.9rem', whiteSpace: 'nowrap' }}>
|
<span style={{ color: '#666', fontSize: '0.9rem', whiteSpace: 'nowrap' }}>
|
||||||
{filtered.length} av {products.length} produkter
|
{filtered.length} av {products.length} produkter
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
</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' }}>
|
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||||
{filtered.map((product) => (
|
{filtered.map((product) => (
|
||||||
<article
|
<article
|
||||||
key={product.id}
|
key={product.id}
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid #ddd',
|
border: selectedIds.has(product.id) ? '1px solid #93c5fd' : '1px solid #ddd',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
padding: '1rem',
|
padding: '1rem',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: '0.5rem',
|
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', 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>
|
<div>
|
||||||
<strong>{product.canonicalName || product.name}</strong>
|
<strong>{product.canonicalName || product.name}</strong>
|
||||||
{product.canonicalName && product.canonicalName !== product.name && (
|
{product.canonicalName && product.canonicalName !== product.name && (
|
||||||
<span style={{ color: '#666', fontSize: '0.85rem', marginLeft: '0.5rem' }}>({product.name})</span>
|
<span style={{ color: '#666', fontSize: '0.85rem', marginLeft: '0.5rem' }}>({product.name})</span>
|
||||||
)}
|
)}
|
||||||
{product.category && (
|
{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' }}>
|
<span style={{ marginLeft: '0.5rem', fontSize: '0.8rem', background: '#eee', borderRadius: '999px', padding: '0.15rem 0.5rem', color: '#555' }}>
|
||||||
{product.category}
|
{product.category}
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ marginLeft: '0.5rem', fontSize: '0.8rem', color: '#f59e0b', fontStyle: 'italic' }}>Okategoriserad</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<span style={{ color: '#aaa', fontSize: '0.8rem' }}>ID: {product.id}</span>
|
<span style={{ color: '#aaa', fontSize: '0.8rem' }}>ID: {product.id}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.8rem', color: '#888' }}>
|
<div style={{ fontSize: '0.8rem', color: '#888' }}>
|
||||||
@@ -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');
|
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');
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ const DAYS_SV = ['Måndag', 'Tisdag', 'Onsdag', 'Torsdag', 'Fredag', 'Lördag',
|
|||||||
type MealPlanEntry = {
|
type MealPlanEntry = {
|
||||||
id: number;
|
id: number;
|
||||||
date: string;
|
date: string;
|
||||||
|
servings: number | null;
|
||||||
recipe: Pick<Recipe, 'id' | 'name' | 'imageUrl'> & {
|
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 } }[];
|
ingredients: { quantity: string; unit: string; note: string | null; product: { id: number; name: string; canonicalName: string | null } }[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -89,10 +91,11 @@ export default function MealPlanClient({ recipes }: { recipes: Recipe[] }) {
|
|||||||
if (!recipeId) {
|
if (!recipeId) {
|
||||||
await fetch(`/api/meal-plan-proxy?date=${date}`, { method: 'DELETE' });
|
await fetch(`/api/meal-plan-proxy?date=${date}`, { method: 'DELETE' });
|
||||||
} else {
|
} else {
|
||||||
|
const existing = entryForDate(date);
|
||||||
await fetch('/api/meal-plan-proxy', {
|
await fetch('/api/meal-plan-proxy', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ date, recipeId: Number(recipeId) }),
|
body: JSON.stringify({ date, recipeId: Number(recipeId), servings: existing?.servings ?? null }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await load();
|
await load();
|
||||||
@@ -103,6 +106,17 @@ export default function MealPlanClient({ recipes }: { recipes: Recipe[] }) {
|
|||||||
|
|
||||||
const plannedCount = weekDates.filter((d) => entryForDate(d)).length;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Veckonavigering */}
|
{/* Veckonavigering */}
|
||||||
@@ -173,6 +187,29 @@ export default function MealPlanClient({ recipes }: { recipes: Recipe[] }) {
|
|||||||
Visa recept →
|
Visa recept →
|
||||||
</Link>
|
</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>}
|
{isSaving && <span style={{ fontSize: '0.8rem', color: '#888' }}>Sparar...</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export default function WriteRecipePage() {
|
|||||||
const [editedName, setEditedName] = useState('');
|
const [editedName, setEditedName] = useState('');
|
||||||
const [editedDescription, setEditedDescription] = useState('');
|
const [editedDescription, setEditedDescription] = useState('');
|
||||||
const [editedInstructions, setEditedInstructions] = useState('');
|
const [editedInstructions, setEditedInstructions] = useState('');
|
||||||
|
const [editedServings, setEditedServings] = useState<number | null>(null);
|
||||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||||
const [ingredients, setIngredients] = useState<ParsedIngredientRow[]>([]);
|
const [ingredients, setIngredients] = useState<ParsedIngredientRow[]>([]);
|
||||||
const [allProducts, setAllProducts] = useState<Product[]>([]);
|
const [allProducts, setAllProducts] = useState<Product[]>([]);
|
||||||
@@ -180,6 +181,7 @@ export default function WriteRecipePage() {
|
|||||||
description: editedDescription || undefined,
|
description: editedDescription || undefined,
|
||||||
instructions: editedInstructions || undefined,
|
instructions: editedInstructions || undefined,
|
||||||
imageUrl: imageUrl || undefined,
|
imageUrl: imageUrl || undefined,
|
||||||
|
servings: editedServings ?? undefined,
|
||||||
ingredients: validIngredients.map((ing) => ({
|
ingredients: validIngredients.map((ing) => ({
|
||||||
productId: ing.selectedProductId,
|
productId: ing.selectedProductId,
|
||||||
quantity: Number(ing.editedQuantity),
|
quantity: Number(ing.editedQuantity),
|
||||||
@@ -358,6 +360,20 @@ Stek löken i lite smör. Tillsätt köttfärsen...`}</pre>
|
|||||||
style={{ width: '100%', padding: '0.75rem', border: '1px solid #ddd', borderRadius: '4px', fontSize: '1rem', minHeight: '150px', fontFamily: 'monospace', boxSizing: 'border-box' }}
|
style={{ width: '100%', padding: '0.75rem', border: '1px solid #ddd', borderRadius: '4px', fontSize: '1rem', minHeight: '150px', fontFamily: 'monospace', boxSizing: 'border-box' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600 }}>Portioner</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={editedServings ?? ''}
|
||||||
|
onChange={(e) => setEditedServings(e.target.value ? Number(e.target.value) : null)}
|
||||||
|
placeholder="t.ex. 4"
|
||||||
|
style={{ width: '120px', padding: '0.5rem 0.75rem', border: '1px solid #ddd', borderRadius: '4px', fontSize: '1rem' }}
|
||||||
|
/>
|
||||||
|
<p style={{ fontSize: '0.82rem', color: '#888', margin: '0.3rem 0 0' }}>Anges portioner kan mängderna skalas på receptsidan.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ingredienser */}
|
{/* Ingredienser */}
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ export type Nutrition = {
|
|||||||
fiber: number | null;
|
fiber: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Category = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
parentId: number | null;
|
||||||
|
parent: Category | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type Product = {
|
export type Product = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -35,6 +42,8 @@ export type Product = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
tags?: ProductTag[];
|
tags?: ProductTag[];
|
||||||
nutrition?: Nutrition | null;
|
nutrition?: Nutrition | null;
|
||||||
|
categoryId?: number | null;
|
||||||
|
categoryRef?: Category | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type InventoryItem = {
|
export type InventoryItem = {
|
||||||
|
|||||||
Reference in New Issue
Block a user