feat: enhance product model with subcategory, brand, tags, and nutrition; update related DTOs and services
This commit is contained in:
@@ -2,12 +2,238 @@
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import type { Product } from '../../../features/inventory/types';
|
||||
import { updateProduct, deleteProduct } from './actions';
|
||||
import { updateProduct, deleteProduct, setProductTags } from './actions';
|
||||
|
||||
type Props = {
|
||||
product: Product;
|
||||
};
|
||||
|
||||
const CATEGORIES: Record<string, string[]> = {
|
||||
'Bröd & Kakor': ['Bröd', 'Kakor & bullar', 'Bageriprodukter'],
|
||||
'Dryck': ['Kaffe & te', 'Juice & läsk', 'Vatten', 'Alkohol'],
|
||||
'Fisk & Skaldjur': ['Fisk', 'Skaldjur', 'Bläckfisk & kalmar', 'Rökt fisk'],
|
||||
'Frukt & Grönt': ['Frukt', 'Grönsaker', 'Bär', 'Rotfrukter', 'Kål'],
|
||||
'Fryst': ['Fryst frukt & grönt', 'Frysta färdigrätter', 'Fryst kött & fisk', 'Glass'],
|
||||
'Färdigmat': ['Färdigrätter', 'Snabbmat', 'Sallader & wrap'],
|
||||
'Glass, godis & snacks': ['Glass', 'Godis', 'Snacks'],
|
||||
'Kött, chark & fågel': ['Nötkött', 'Fläsk', 'Fågel', 'Charkuteri', 'Vilt'],
|
||||
'Mejeri, ost & ägg': ['Mjölk', 'Grädde', 'Ost', 'Yoghurt & fil', 'Smör & margarin', 'Ägg'],
|
||||
'Skafferi': ['Mjöl & bakning', 'Pasta & ris', 'Baljväxter', 'Nötter & frön', 'Socker & sötningsmedel', 'Kryddor & örter', 'Konserver & burkar'],
|
||||
'Vegetariskt': ['Vegetariska proteinkällor', 'Vegetariska färdigrätter', 'Vegetariska korvar & burgare'],
|
||||
'Övrigt': [],
|
||||
};
|
||||
|
||||
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 }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [selectedCategory, setSelectedCategory] = useState(product.category ?? '');
|
||||
const [tagInput, setTagInput] = useState(
|
||||
product.tags?.map((pt) => pt.tag.name).join(', ') ?? ''
|
||||
);
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const rawTags = tagInput.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await updateProduct(formData);
|
||||
await setProductTags(product.id, rawTags);
|
||||
setSuccess(true);
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Okänt fel');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (!confirm(`Ta bort "${product.name}"? Detta är en mjukradering och kan återställas.`)) return;
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await deleteProduct(product.id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Okänt fel');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const subcategories = CATEGORIES[selectedCategory] ?? [];
|
||||
|
||||
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</span>
|
||||
<select
|
||||
name="category"
|
||||
value={selectedCategory}
|
||||
onChange={(e) => { setSelectedCategory(e.target.value); }}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">— Ingen kategori —</option>
|
||||
{Object.keys(CATEGORIES).map((cat) => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{subcategories.length > 0 && (
|
||||
<label style={{ display: 'grid', gap: '0.25rem', fontSize: '0.9rem' }}>
|
||||
<span style={{ fontWeight: 600 }}>Underkategori</span>
|
||||
<select
|
||||
name="subcategory"
|
||||
defaultValue={product.subcategory ?? ''}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">— Ingen underkategori —</option>
|
||||
{subcategories.map((sub) => (
|
||||
<option key={sub} value={sub}>{sub}</option>
|
||||
))}
|
||||
</select>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: '0.5rem 0.75rem',
|
||||
border: '1px solid #ddd',
|
||||
|
||||
@@ -8,11 +8,15 @@ export async function updateProduct(formData: FormData) {
|
||||
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();
|
||||
|
||||
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',
|
||||
@@ -21,6 +25,8 @@ export async function updateProduct(formData: FormData) {
|
||||
name: name || undefined,
|
||||
canonicalName: canonicalName || undefined,
|
||||
category: category || null,
|
||||
subcategory: subcategory || null,
|
||||
brand: brand || null,
|
||||
}),
|
||||
cache: 'no-store',
|
||||
});
|
||||
@@ -33,6 +39,22 @@ export async function updateProduct(formData: FormData) {
|
||||
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' },
|
||||
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 deleteProduct(id: number) {
|
||||
const res = await fetch(`${API_BASE}/api/products/${id}`, {
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -1,13 +1,40 @@
|
||||
export type Tag = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type ProductTag = {
|
||||
productId: number;
|
||||
tagId: number;
|
||||
tag: Tag;
|
||||
};
|
||||
|
||||
export type Nutrition = {
|
||||
id: number;
|
||||
productId: number;
|
||||
calories: number | null;
|
||||
protein: number | null;
|
||||
fat: number | null;
|
||||
carbohydrates: number | null;
|
||||
salt: number | null;
|
||||
sugar: number | null;
|
||||
fiber: number | null;
|
||||
};
|
||||
|
||||
export type Product = {
|
||||
id: number;
|
||||
name: string;
|
||||
normalizedName: string;
|
||||
category: string | null;
|
||||
subcategory: string | null;
|
||||
brand: string | null;
|
||||
canonicalName: string | null;
|
||||
isActive: boolean;
|
||||
deletedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tags?: ProductTag[];
|
||||
nutrition?: Nutrition | null;
|
||||
};
|
||||
|
||||
export type InventoryItem = {
|
||||
|
||||
Reference in New Issue
Block a user