feat: redigeringsformulär för produkter i admin med namn, canonical name, kategori och mjukradering
This commit is contained in:
@@ -6,4 +6,14 @@ export class UpdateProductDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@MaxLength(191)
|
@MaxLength(191)
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(191)
|
||||||
|
canonicalName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(191)
|
||||||
|
category?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export class ProductsService {
|
|||||||
name?: string;
|
name?: string;
|
||||||
normalizedName?: string;
|
normalizedName?: string;
|
||||||
canonicalName?: string;
|
canonicalName?: string;
|
||||||
|
category?: string | null;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
if (typeof data.name === 'string') {
|
if (typeof data.name === 'string') {
|
||||||
@@ -132,7 +133,14 @@ export class ProductsService {
|
|||||||
|
|
||||||
updateData.name = name;
|
updateData.name = name;
|
||||||
updateData.normalizedName = normalizedName;
|
updateData.normalizedName = normalizedName;
|
||||||
updateData.canonicalName = name;
|
}
|
||||||
|
|
||||||
|
if (typeof data.canonicalName === 'string') {
|
||||||
|
updateData.canonicalName = data.canonicalName.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.category === 'string') {
|
||||||
|
updateData.category = data.category.trim() || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.product.update({
|
return this.prisma.product.update({
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import type { Product } from '../../../features/inventory/types';
|
import type { Product } from '../../../features/inventory/types';
|
||||||
import CanonicalNameForm from './CanonicalNameForm';
|
import EditProductForm from './EditProductForm';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
products: Product[];
|
products: Product[];
|
||||||
@@ -105,23 +105,24 @@ export default function AdminProductList({ products }: Props) {
|
|||||||
gap: '0.5rem',
|
gap: '0.5rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
<strong>ID:</strong> {product.id}
|
<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>
|
||||||
|
<span style={{ color: '#aaa', fontSize: '0.8rem' }}>ID: {product.id}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div style={{ fontSize: '0.8rem', color: '#888' }}>
|
||||||
<strong>Namn:</strong> {product.name}
|
Normalized: {product.normalizedName}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<EditProductForm product={product} />
|
||||||
<strong>Canonical name:</strong> {product.canonicalName || 'Saknas'}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>Normalized:</strong> {product.normalizedName}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CanonicalNameForm
|
|
||||||
id={product.id}
|
|
||||||
currentCanonicalName={product.canonicalName}
|
|
||||||
/>
|
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react';
|
||||||
|
import type { Product } from '../../../features/inventory/types';
|
||||||
|
import { updateProduct, deleteProduct } from './actions';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
product: Product;
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSuccess(false);
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await updateProduct(formData);
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<input
|
||||||
|
name="category"
|
||||||
|
type="text"
|
||||||
|
defaultValue={product.category ?? ''}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="T.ex. Kött, Grönsaker, Mejeriprodukter"
|
||||||
|
/>
|
||||||
|
</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,43 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { API_BASE } from '../../../lib/api';
|
||||||
|
|
||||||
|
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 res = await fetch(`${API_BASE}/api/products/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name || undefined,
|
||||||
|
canonicalName: canonicalName || undefined,
|
||||||
|
category: category || null,
|
||||||
|
}),
|
||||||
|
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 deleteProduct(id: number) {
|
||||||
|
const res = await fetch(`${API_BASE}/api/products/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Kunde inte ta bort produkt: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/admin/products');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user