feat(admin): refactor product management components for improved state handling and data fetching
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user