feat(admin): refactor product management components for improved state handling and data fetching

This commit is contained in:
Nils-Johan Gynther
2026-04-19 18:22:43 +02:00
parent f12d881395
commit e9b5de4407
5 changed files with 57 additions and 28 deletions
@@ -1,7 +1,6 @@
'use client'; 'use client';
import { useState, useMemo, useEffect, useTransition } from 'react'; import { useState, useMemo, useEffect, useTransition, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import type { Product, Category } from '../../../features/inventory/types'; import type { Product, Category } from '../../../features/inventory/types';
import EditProductForm from './EditProductForm'; import EditProductForm from './EditProductForm';
import { bulkSetCategory, suggestBulkCategories } from './actions'; import { bulkSetCategory, suggestBulkCategories } from './actions';
@@ -20,10 +19,6 @@ type AiSuggestion = {
}; };
}; };
type Props = {
products: Product[];
};
const sortOptions = [ const sortOptions = [
{ value: 'createdDesc', label: 'Senast tillagda' }, { value: 'createdDesc', label: 'Senast tillagda' },
{ value: 'nameAsc', label: 'Namn A–Ö' }, { value: 'nameAsc', label: 'Namn A–Ö' },
@@ -39,8 +34,9 @@ function flattenTree(nodes: CategoryNode[], depth = 0): { id: number; label: str
return result; return result;
} }
export default function AdminProductList({ products }: Props) { export default function AdminProductList() {
const router = useRouter(); const [products, setProducts] = useState<Product[]>([]);
const [productsLoading, setProductsLoading] = useState(true);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sort, setSort] = useState('createdDesc'); const [sort, setSort] = useState('createdDesc');
const [showUncategorizedOnly, setShowUncategorizedOnly] = useState(false); const [showUncategorizedOnly, setShowUncategorizedOnly] = useState(false);
@@ -57,6 +53,18 @@ export default function AdminProductList({ products }: Props) {
const [aiApproved, setAiApproved] = useState<Set<number>>(new Set()); const [aiApproved, setAiApproved] = useState<Set<number>>(new Set());
const [aiApplying, setAiApplying] = useState(false); const [aiApplying, setAiApplying] = useState(false);
const refetchProducts = useCallback(() => {
fetch('/api/products')
.then((r) => r.json())
.then((data) => { if (Array.isArray(data)) setProducts(data); })
.catch(() => {})
.finally(() => setProductsLoading(false));
}, []);
useEffect(() => {
refetchProducts();
}, [refetchProducts]);
useEffect(() => { useEffect(() => {
fetch('/api/categories') fetch('/api/categories')
.then((r) => r.json()) .then((r) => r.json())
@@ -129,7 +137,7 @@ export default function AdminProductList({ products }: Props) {
await bulkSetCategory(ids, categoryId); await bulkSetCategory(ids, categoryId);
setSelectedIds(new Set()); setSelectedIds(new Set());
setBulkCategoryId(''); setBulkCategoryId('');
router.refresh(); refetchProducts();
} catch (err) { } catch (err) {
setBulkError(err instanceof Error ? err.message : 'Fel vid uppdatering'); setBulkError(err instanceof Error ? err.message : 'Fel vid uppdatering');
} }
@@ -167,6 +175,7 @@ export default function AdminProductList({ products }: Props) {
} }
setAiSuggestions(null); setAiSuggestions(null);
setAiApproved(new Set()); setAiApproved(new Set());
refetchProducts();
} catch (err) { } catch (err) {
setAiError(err instanceof Error ? err.message : 'Fel vid tillämpning'); setAiError(err instanceof Error ? err.message : 'Fel vid tillämpning');
} finally { } finally {
@@ -174,6 +183,10 @@ export default function AdminProductList({ products }: Props) {
} }
}; };
if (productsLoading) {
return <p style={{ color: '#888', marginTop: '1rem' }}>Laddar produkter</p>;
}
return ( return (
<> <>
{/* Sök + sortering + filter */} {/* Sök + sortering + filter */}
@@ -337,7 +350,11 @@ export default function AdminProductList({ products }: Props) {
<div style={{ fontSize: '0.8rem', color: '#888' }}> <div style={{ fontSize: '0.8rem', color: '#888' }}>
Normalized: {product.normalizedName} Normalized: {product.normalizedName}
</div> </div>
<EditProductForm product={product} /> <EditProductForm
product={product}
onSaved={(updated) => setProducts((prev) => prev.map((p) => p.id === updated.id ? updated : p))}
onDeleted={(id) => setProducts((prev) => prev.filter((p) => p.id !== id))}
/>
</article> </article>
))} ))}
</div> </div>
@@ -1,7 +1,6 @@
'use client'; 'use client';
import { useState, useTransition, useEffect } from 'react'; import { useState, useTransition, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import type { Product } from '../../../features/inventory/types'; import type { Product } from '../../../features/inventory/types';
import { updateProductWithTags, deleteProduct, suggestProductCategory } from './actions'; import { updateProductWithTags, deleteProduct, suggestProductCategory } from './actions';
@@ -22,6 +21,8 @@ type AiSuggestion = {
type Props = { type Props = {
product: Product; product: Product;
onSaved: (updated: Product) => void;
onDeleted: (id: number) => void;
}; };
const inputStyle: React.CSSProperties = { const inputStyle: React.CSSProperties = {
@@ -33,8 +34,7 @@ const inputStyle: React.CSSProperties = {
boxSizing: 'border-box', boxSizing: 'border-box',
}; };
export default function EditProductForm({ product }: Props) { export default function EditProductForm({ product, onSaved, onDeleted }: Props) {
const router = useRouter();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -106,10 +106,10 @@ export default function EditProductForm({ product }: Props) {
const rawTags = tagInput.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean); const rawTags = tagInput.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean);
startTransition(async () => { startTransition(async () => {
try { try {
await updateProductWithTags(formData, rawTags); const updated = await updateProductWithTags(formData, rawTags);
setSuccess(true); setSuccess(true);
setIsOpen(false); setIsOpen(false);
router.refresh(); onSaved(updated as Product);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel'); setError(err instanceof Error ? err.message : 'Okänt fel');
} }
@@ -123,7 +123,7 @@ export default function EditProductForm({ product }: Props) {
startTransition(async () => { startTransition(async () => {
try { try {
await deleteProduct(product.id); await deleteProduct(product.id);
router.refresh(); onDeleted(product.id);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel'); setError(err instanceof Error ? err.message : 'Okänt fel');
} }
@@ -1,14 +1,11 @@
'use client'; 'use client';
import { useState, useTransition } from 'react'; import { useState, useTransition, useEffect } from 'react';
import type { MergePreview, Product } from '../../../features/inventory/types'; import type { MergePreview, Product } from '../../../features/inventory/types';
import { mergeProducts } from '../../inventory/actions'; import { mergeProducts } from '../../inventory/actions';
type Props = { export default function MergePreviewForm() {
products: Product[]; const [products, setProducts] = useState<Product[]>([]);
};
export default function MergePreviewForm({ products }: Props) {
const [sourceProductId, setSourceProductId] = useState(''); const [sourceProductId, setSourceProductId] = useState('');
const [targetProductId, setTargetProductId] = useState(''); const [targetProductId, setTargetProductId] = useState('');
const [preview, setPreview] = useState<MergePreview | null>(null); const [preview, setPreview] = useState<MergePreview | null>(null);
@@ -18,6 +15,15 @@ export default function MergePreviewForm({ products }: Props) {
const [isConfirming, setIsConfirming] = useState(false); const [isConfirming, setIsConfirming] = useState(false);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
useEffect(() => {
if (isExpanded && products.length === 0) {
fetch('/api/products')
.then((r) => r.json())
.then((data) => { if (Array.isArray(data)) setProducts(data); })
.catch(() => {});
}
}, [isExpanded]);
const fetchPreview = () => { const fetchPreview = () => {
setError(null); setError(null);
setSuccessMessage(null); setSuccessMessage(null);
+10
View File
@@ -97,6 +97,8 @@ export async function updateProductWithTags(formData: FormData, tags: string[])
throw new Error(`Kunde inte uppdatera produkt: ${text}`); throw new Error(`Kunde inte uppdatera produkt: ${text}`);
} }
const updatedProduct = await res.json();
const tagsRes = await fetch(`${API_BASE}/api/products/${id}/tags`, { const tagsRes = await fetch(`${API_BASE}/api/products/${id}/tags`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json', ...authHeaders }, headers: { 'Content-Type': 'application/json', ...authHeaders },
@@ -108,6 +110,14 @@ export async function updateProductWithTags(formData: FormData, tags: string[])
const text = await tagsRes.text(); const text = await tagsRes.text();
throw new Error(`Kunde inte uppdatera taggar: ${text}`); throw new Error(`Kunde inte uppdatera taggar: ${text}`);
} }
// Fetch the complete product (includes tags, categoryRef) after both updates
const fullRes = await fetch(`${API_BASE}/api/products/${id}`, {
headers: authHeaders,
cache: 'no-store',
});
if (!fullRes.ok) return updatedProduct;
return fullRes.json();
} }
export async function deleteProduct(id: number) { export async function deleteProduct(id: number) {
+2 -6
View File
@@ -1,5 +1,3 @@
import { fetchJson } from '../../../lib/api';
import type { Product } from '../../../features/inventory/types';
import MergePreviewForm from './MergePreviewForm'; import MergePreviewForm from './MergePreviewForm';
import AdminProductList from './AdminProductList'; import AdminProductList from './AdminProductList';
import Navigation from '../../Navigation'; import Navigation from '../../Navigation';
@@ -7,8 +5,6 @@ import ExpandableCreateProductSection from './ExpandableCreateProductSection';
import ResetProductsButton from './ResetProductsButton'; import ResetProductsButton from './ResetProductsButton';
export default async function AdminProductsPage() { export default async function AdminProductsPage() {
const products = await fetchJson<Product[]>('/api/products');
return ( return (
<main style={{ padding: '1rem', maxWidth: '1100px', margin: '0 auto' }}> <main style={{ padding: '1rem', maxWidth: '1100px', margin: '0 auto' }}>
<Navigation /> <Navigation />
@@ -19,9 +15,9 @@ export default async function AdminProductsPage() {
<ResetProductsButton /> <ResetProductsButton />
<MergePreviewForm products={products} /> <MergePreviewForm />
<AdminProductList products={products} /> <AdminProductList />
</main> </main>
); );
} }