From 65ec74ac7d9f285c54415bedaab7ebd3700952c1 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Wed, 15 Apr 2026 22:02:58 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20redigeringsformul=C3=A4r=20f=C3=B6r=20p?= =?UTF-8?q?rodukter=20i=20admin=20med=20namn,=20canonical=20name,=20katego?= =?UTF-8?q?ri=20och=20mjukradering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/products/dto/update-product.dto.ts | 10 ++ backend/src/products/products.service.ts | 10 +- .../app/admin/products/AdminProductList.tsx | 33 ++-- .../app/admin/products/EditProductForm.tsx | 169 ++++++++++++++++++ frontend/app/admin/products/actions.ts | 43 +++++ 5 files changed, 248 insertions(+), 17 deletions(-) create mode 100644 frontend/app/admin/products/EditProductForm.tsx create mode 100644 frontend/app/admin/products/actions.ts diff --git a/backend/src/products/dto/update-product.dto.ts b/backend/src/products/dto/update-product.dto.ts index 8c3fb82a..974732b4 100644 --- a/backend/src/products/dto/update-product.dto.ts +++ b/backend/src/products/dto/update-product.dto.ts @@ -6,4 +6,14 @@ export class UpdateProductDto { @IsNotEmpty() @MaxLength(191) name?: string; + + @IsOptional() + @IsString() + @MaxLength(191) + canonicalName?: string; + + @IsOptional() + @IsString() + @MaxLength(191) + category?: string; } diff --git a/backend/src/products/products.service.ts b/backend/src/products/products.service.ts index e7940fd1..3dd3244e 100644 --- a/backend/src/products/products.service.ts +++ b/backend/src/products/products.service.ts @@ -104,6 +104,7 @@ export class ProductsService { name?: string; normalizedName?: string; canonicalName?: string; + category?: string | null; } = {}; if (typeof data.name === 'string') { @@ -132,7 +133,14 @@ export class ProductsService { updateData.name = name; 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({ diff --git a/frontend/app/admin/products/AdminProductList.tsx b/frontend/app/admin/products/AdminProductList.tsx index b4d6b57a..a834bc18 100644 --- a/frontend/app/admin/products/AdminProductList.tsx +++ b/frontend/app/admin/products/AdminProductList.tsx @@ -2,7 +2,7 @@ import { useState, useMemo } from 'react'; import type { Product } from '../../../features/inventory/types'; -import CanonicalNameForm from './CanonicalNameForm'; +import EditProductForm from './EditProductForm'; type Props = { products: Product[]; @@ -105,23 +105,24 @@ export default function AdminProductList({ products }: Props) { gap: '0.5rem', }} > -
- ID: {product.id} +
+
+ {product.canonicalName || product.name} + {product.canonicalName && product.canonicalName !== product.name && ( + ({product.name}) + )} + {product.category && ( + + {product.category} + + )} +
+ ID: {product.id}
-
- Namn: {product.name} +
+ Normalized: {product.normalizedName}
-
- Canonical name: {product.canonicalName || 'Saknas'} -
-
- Normalized: {product.normalizedName} -
- - + ))}
diff --git a/frontend/app/admin/products/EditProductForm.tsx b/frontend/app/admin/products/EditProductForm.tsx new file mode 100644 index 00000000..354afa20 --- /dev/null +++ b/frontend/app/admin/products/EditProductForm.tsx @@ -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(null); + const [success, setSuccess] = useState(false); + + function handleSubmit(e: React.FormEvent) { + 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 ( +
+
+ + {success && ✓ Sparat!} +
+ + {error &&
{error}
} + + {isOpen && ( +
+ + + + + + + + +
+ Normaliserat namn: {product.normalizedName} + Aktiv: {product.isActive ? 'Ja' : 'Nej'} +
+ +
+ + + +
+
+ )} +
+ ); +} diff --git a/frontend/app/admin/products/actions.ts b/frontend/app/admin/products/actions.ts new file mode 100644 index 00000000..20a1e0a1 --- /dev/null +++ b/frontend/app/admin/products/actions.ts @@ -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'); +}