diff --git a/frontend/app/admin/products/AdminProductList.tsx b/frontend/app/admin/products/AdminProductList.tsx index 99ed91d3..6abec984 100644 --- a/frontend/app/admin/products/AdminProductList.tsx +++ b/frontend/app/admin/products/AdminProductList.tsx @@ -54,10 +54,18 @@ export default function AdminProductList() { const [aiApplying, setAiApplying] = useState(false); const refetchProducts = useCallback(() => { + console.log('[AdminProductList] refetchProducts: starting fetch /api/products'); fetch('/api/products') - .then((r) => r.json()) - .then((data) => { if (Array.isArray(data)) setProducts(data); }) - .catch(() => {}) + .then(async (r) => { + console.log('[AdminProductList] refetchProducts: HTTP', r.status); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); + }) + .then((data) => { + console.log('[AdminProductList] refetchProducts: got', Array.isArray(data) ? data.length : 'non-array', 'products'); + if (Array.isArray(data)) setProducts(data); + }) + .catch((e) => console.error('[AdminProductList] refetchProducts error:', e)) .finally(() => setProductsLoading(false)); }, []); diff --git a/frontend/app/admin/products/EditProductForm.tsx b/frontend/app/admin/products/EditProductForm.tsx index 728ce0eb..d57806f3 100644 --- a/frontend/app/admin/products/EditProductForm.tsx +++ b/frontend/app/admin/products/EditProductForm.tsx @@ -1,8 +1,8 @@ 'use client'; -import { useState, useTransition, useEffect } from 'react'; +import { useState, useEffect } from 'react'; import type { Product } from '../../../features/inventory/types'; -import { updateProductWithTags, deleteProduct, suggestProductCategory } from './actions'; +import { suggestProductCategory } from './actions'; type CategoryNode = { id: number; @@ -36,7 +36,7 @@ const inputStyle: React.CSSProperties = { export default function EditProductForm({ product, onSaved, onDeleted }: Props) { const [isOpen, setIsOpen] = useState(false); - const [isPending, startTransition] = useTransition(); + const [isPending, setIsPending] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); const [tagInput, setTagInput] = useState( @@ -93,41 +93,66 @@ export default function EditProductForm({ product, onSaved, onDeleted }: Props) } } - function handleSubmit(e: React.FormEvent) { + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); + const formData = new FormData(e.currentTarget); + const rawTags = tagInput.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean); + console.log('[EditProductForm] handleSubmit: PATCH /api/admin/product/', product.id); + setIsPending(true); setError(null); setSuccess(false); - const formData = new FormData(e.currentTarget); - if (selectedCategoryId !== '') { - formData.set('categoryId', String(selectedCategoryId)); - } else { - formData.set('categoryId', ''); - } - const rawTags = tagInput.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean); - startTransition(async () => { - try { - const updated = await updateProductWithTags(formData, rawTags); - setSuccess(true); - setIsOpen(false); - onSaved(updated as Product); - } catch (err) { - setError(err instanceof Error ? err.message : 'Okänt fel'); + try { + const res = await fetch(`/api/admin/product/${product.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: formData.get('name'), + canonicalName: formData.get('canonicalName') ?? '', + category: formData.get('category') ?? '', + subcategory: formData.get('subcategory') ?? '', + brand: formData.get('brand') ?? '', + categoryId: selectedCategoryId !== '' ? selectedCategoryId : null, + tags: rawTags, + }), + }); + const data = await res.json(); + console.log('[EditProductForm] handleSubmit: HTTP', res.status, data); + if (!res.ok) { + setError(data?.error ?? 'Okänt fel'); + return; } - }); + setSuccess(true); + setIsOpen(false); + onSaved(data as Product); + } catch (err) { + console.error('[EditProductForm] handleSubmit error:', err); + setError(err instanceof Error ? err.message : 'Okänt fel'); + } finally { + setIsPending(false); + } } - function handleDelete() { + async 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); - onDeleted(product.id); - } catch (err) { - setError(err instanceof Error ? err.message : 'Okänt fel'); + setIsPending(true); + console.log('[EditProductForm] handleDelete: DELETE /api/admin/product/', product.id); + try { + const res = await fetch(`/api/admin/product/${product.id}`, { method: 'DELETE' }); + console.log('[EditProductForm] handleDelete: HTTP', res.status); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data?.error ?? 'Kunde inte ta bort produkt'); + return; } - }); + onDeleted(product.id); + } catch (err) { + console.error('[EditProductForm] handleDelete error:', err); + setError(err instanceof Error ? err.message : 'Okänt fel'); + } finally { + setIsPending(false); + } } return ( diff --git a/frontend/app/admin/products/actions.ts b/frontend/app/admin/products/actions.ts index 8473a41d..d6ecb77d 100644 --- a/frontend/app/admin/products/actions.ts +++ b/frontend/app/admin/products/actions.ts @@ -61,9 +61,8 @@ export async function setProductTags(productId: number, tags: string[]) { export async function updateProductWithTags(formData: FormData, tags: string[]) { const id = Number(formData.get('id')); + console.log('[actions:updateProductWithTags] called for product id:', id, 'tags:', tags); 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(); const categoryIdRaw = formData.get('categoryId'); @@ -77,7 +76,7 @@ export async function updateProductWithTags(formData: FormData, tags: string[]) if (brand.length > 100) throw new Error('Varumärke får inte vara längre än 100 tecken.'); const authHeaders = await getAuthHeaders(); - + console.log('[actions:updateProductWithTags] auth headers present:', !!authHeaders.Authorization);\n const res = await fetch(`${API_BASE}/api/products/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', ...authHeaders }, @@ -98,6 +97,7 @@ export async function updateProductWithTags(formData: FormData, tags: string[]) } const updatedProduct = await res.json(); + console.log('[actions:updateProductWithTags] PATCH OK, product id:', (updatedProduct as any)?.id); const tagsRes = await fetch(`${API_BASE}/api/products/${id}/tags`, { method: 'PUT', @@ -111,13 +111,17 @@ export async function updateProductWithTags(formData: FormData, tags: string[]) throw new Error(`Kunde inte uppdatera taggar: ${text}`); } + console.log('[actions:updateProductWithTags] tags PUT OK, fetching full product...'); // Fetch the complete product (includes tags, categoryRef) after both updates const fullRes = await fetch(`${API_BASE}/api/products/${id}`, { headers: authHeaders, cache: 'no-store', }); + console.log('[actions:updateProductWithTags] full product fetch HTTP', fullRes.status); if (!fullRes.ok) return updatedProduct; - return fullRes.json(); + const result = await fullRes.json(); + console.log('[actions:updateProductWithTags] returning full product id:', (result as any)?.id); + return result; } export async function deleteProduct(id: number) { diff --git a/frontend/app/api/admin/product/[id]/route.ts b/frontend/app/api/admin/product/[id]/route.ts new file mode 100644 index 00000000..fb11c474 --- /dev/null +++ b/frontend/app/api/admin/product/[id]/route.ts @@ -0,0 +1,133 @@ +import { auth } from '../../../../../auth'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; + +async function getAuthHeaders(): Promise> { + const session = await auth(); + if (!session?.accessToken) { + return {}; + } + return { Authorization: `Bearer ${session.accessToken}` }; +} + +// PATCH /api/admin/product/[id] +// Body: { name, canonicalName, category, subcategory, brand, categoryId, tags } +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const productId = Number(id); + if (!productId) return Response.json({ error: 'Invalid id' }, { status: 400 }); + + const body = await req.json(); + const { name, canonicalName, category, subcategory, brand, categoryId, tags } = body; + + if (!name || typeof name !== 'string' || !name.trim()) { + return Response.json({ error: 'Namn får inte vara tomt.' }, { status: 400 }); + } + + const authHeaders = await getAuthHeaders(); + if (!authHeaders.Authorization) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + console.log('[api/admin/product] PATCH product', productId); + + // 1. Update product fields + const patchRes = await fetch(`${API_BASE}/api/products/${productId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', ...authHeaders }, + body: JSON.stringify({ + name: name.trim(), + canonicalName: canonicalName?.trim() || undefined, + category: category?.trim() || null, + subcategory: subcategory?.trim() || null, + brand: brand?.trim() || null, + categoryId: categoryId ?? null, + }), + }); + + if (!patchRes.ok) { + const text = await patchRes.text(); + console.error('[api/admin/product] PATCH failed:', patchRes.status, text); + return Response.json({ error: `Kunde inte uppdatera produkt: ${text}` }, { status: patchRes.status }); + } + + console.log('[api/admin/product] PATCH OK'); + + // 2. Update tags + const tagsRes = await fetch(`${API_BASE}/api/products/${productId}/tags`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...authHeaders }, + body: JSON.stringify({ tags: tags ?? [] }), + }); + + if (!tagsRes.ok) { + const text = await tagsRes.text(); + console.error('[api/admin/product] tags PUT failed:', tagsRes.status, text); + return Response.json({ error: `Kunde inte uppdatera taggar: ${text}` }, { status: tagsRes.status }); + } + + console.log('[api/admin/product] tags PUT OK'); + + // 3. Return the complete updated product + const fullRes = await fetch(`${API_BASE}/api/products/${productId}`, { + headers: authHeaders, + }); + + if (!fullRes.ok) { + return Response.json({ error: 'Produkt uppdaterad men kunde inte hämtas' }, { status: 500 }); + } + + const product = await fullRes.json(); + console.log('[api/admin/product] returning full product id:', product?.id); + return Response.json(product); + } catch (err) { + console.error('[api/admin/product] PATCH error:', err); + return Response.json( + { error: err instanceof Error ? err.message : 'Unknown error' }, + { status: 500 }, + ); + } +} + +// DELETE /api/admin/product/[id] +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const productId = Number(id); + if (!productId) return Response.json({ error: 'Invalid id' }, { status: 400 }); + + const authHeaders = await getAuthHeaders(); + if (!authHeaders.Authorization) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + console.log('[api/admin/product] DELETE product', productId); + + const res = await fetch(`${API_BASE}/api/products/${productId}`, { + method: 'DELETE', + headers: authHeaders, + }); + + if (!res.ok) { + const text = await res.text(); + console.error('[api/admin/product] DELETE failed:', res.status, text); + return Response.json({ error: `Kunde inte ta bort produkt: ${text}` }, { status: res.status }); + } + + console.log('[api/admin/product] DELETE OK'); + return new Response(null, { status: 204 }); + } catch (err) { + console.error('[api/admin/product] DELETE error:', err); + return Response.json( + { error: err instanceof Error ? err.message : 'Unknown error' }, + { status: 500 }, + ); + } +}