Add sorting by name functionality and implement AdminProductList component for product management

This commit is contained in:
Nils-Johan Gynther
2026-04-10 19:10:50 +02:00
parent 33cb4e5328
commit 556a0fdc30
5 changed files with 172 additions and 40 deletions
@@ -26,6 +26,8 @@ export class InventoryService {
orderBy.push({ bestBeforeDate: 'asc' }); orderBy.push({ bestBeforeDate: 'asc' });
} else if (query?.sort === 'bestBeforeDesc') { } else if (query?.sort === 'bestBeforeDesc') {
orderBy.push({ bestBeforeDate: 'desc' }); orderBy.push({ bestBeforeDate: 'desc' });
} else if (query?.sort === 'nameAsc') {
orderBy.push({ product: { name: 'asc' } } as any);
} else if (query?.sort === 'purchaseDateAsc') { } else if (query?.sort === 'purchaseDateAsc') {
orderBy.push({ purchaseDate: 'asc' }); orderBy.push({ purchaseDate: 'asc' });
} else if (query?.sort === 'purchaseDateDesc') { } else if (query?.sort === 'purchaseDateDesc') {
@@ -0,0 +1,130 @@
'use client';
import { useState, useMemo } from 'react';
import type { Product } from '../../../features/inventory/types';
import CanonicalNameForm from './CanonicalNameForm';
type Props = {
products: Product[];
};
const sortOptions = [
{ value: 'createdDesc', label: 'Senast tillagda' },
{ value: 'nameAsc', label: 'Namn A–Ö' },
];
export default function AdminProductList({ products }: Props) {
const [search, setSearch] = useState('');
const [sort, setSort] = useState('createdDesc');
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
let result = q
? products.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
(p.canonicalName ?? '').toLowerCase().includes(q) ||
(p.normalizedName ?? '').toLowerCase().includes(q),
)
: [...products];
if (sort === 'nameAsc') {
result.sort((a, b) =>
(a.canonicalName || a.name).localeCompare(b.canonicalName || b.name, 'sv'),
);
} else {
result.sort((a, b) => b.id - a.id);
}
return result;
}, [products, search, sort]);
return (
<>
<div
style={{
display: 'flex',
gap: '1rem',
alignItems: 'center',
marginBottom: '1rem',
flexWrap: 'wrap',
}}
>
<input
type="search"
placeholder="Sök produkt…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{
flex: '1 1 200px',
padding: '0.5rem 0.75rem',
border: '1px solid #ddd',
borderRadius: '6px',
fontSize: '1rem',
}}
/>
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
{sortOptions.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setSort(opt.value)}
style={{
padding: '0.45rem 0.75rem',
borderRadius: '999px',
border: '1px solid #ddd',
background: sort === opt.value ? '#efefef' : '#fff',
fontWeight: sort === opt.value ? 600 : 400,
cursor: 'pointer',
fontSize: '0.9rem',
}}
>
{opt.label}
</button>
))}
</div>
{search && (
<span style={{ color: '#666', fontSize: '0.9rem', whiteSpace: 'nowrap' }}>
{filtered.length} av {products.length} produkter
</span>
)}
</div>
<div style={{ display: 'grid', gap: '1rem' }}>
{filtered.map((product) => (
<article
key={product.id}
style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '1rem',
display: 'grid',
gap: '0.5rem',
}}
>
<div>
<strong>ID:</strong> {product.id}
</div>
<div>
<strong>Namn:</strong> {product.name}
</div>
<div>
<strong>Canonical name:</strong> {product.canonicalName || 'Saknas'}
</div>
<div>
<strong>Normalized:</strong> {product.normalizedName}
</div>
<CanonicalNameForm
id={product.id}
currentCanonicalName={product.canonicalName}
/>
</article>
))}
</div>
</>
);
}
+2 -33
View File
@@ -1,7 +1,7 @@
import { fetchJson } from '../../../lib/api'; import { fetchJson } from '../../../lib/api';
import type { Product } from '../../../features/inventory/types'; import type { Product } from '../../../features/inventory/types';
import CanonicalNameForm from './CanonicalNameForm';
import MergePreviewForm from './MergePreviewForm'; import MergePreviewForm from './MergePreviewForm';
import AdminProductList from './AdminProductList';
export default async function AdminProductsPage() { export default async function AdminProductsPage() {
const products = await fetchJson<Product[]>('/api/products'); const products = await fetchJson<Product[]>('/api/products');
@@ -13,38 +13,7 @@ export default async function AdminProductsPage() {
<MergePreviewForm products={products} /> <MergePreviewForm products={products} />
<div style={{ display: 'grid', gap: '1rem' }}> <AdminProductList products={products} />
{products.map((product) => (
<article
key={product.id}
style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '1rem',
display: 'grid',
gap: '0.5rem',
}}
>
<div>
<strong>ID:</strong> {product.id}
</div>
<div>
<strong>Namn:</strong> {product.name}
</div>
<div>
<strong>Canonical name:</strong> {product.canonicalName || 'Saknas'}
</div>
<div>
<strong>Normalized:</strong> {product.normalizedName}
</div>
<CanonicalNameForm
id={product.id}
currentCanonicalName={product.canonicalName}
/>
</article>
))}
</div>
</main> </main>
); );
} }
+31 -3
View File
@@ -11,6 +11,7 @@ type Props = {
export default function InventoryForm({ products }: Props) { export default function InventoryForm({ products }: Props) {
const [isPending, setIsPending] = useState(false); const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false);
const UNIT_OPTIONS = [ const UNIT_OPTIONS = [
{ value: '', label: 'Välj enhet' }, { value: '', label: 'Välj enhet' },
@@ -56,6 +57,30 @@ export default function InventoryForm({ products }: Props) {
} }
return ( return (
<div style={{ marginBottom: '1.5rem' }}>
<button
type="button"
onClick={() => setIsOpen((v) => !v)}
style={{
padding: '0.6rem 1rem',
border: '1px solid #ddd',
borderRadius: '6px',
background: '#fff',
cursor: 'pointer',
fontWeight: 500,
fontSize: '1rem',
width: '100%',
textAlign: 'left',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span>Lägg till hemmavara</span>
<span>{isOpen ? '▲' : '▼'}</span>
</button>
{isOpen && (
<form <form
onSubmit={async (e) => { onSubmit={async (e) => {
e.preventDefault(); e.preventDefault();
@@ -82,11 +107,12 @@ export default function InventoryForm({ products }: Props) {
gap: '0.75rem', gap: '0.75rem',
padding: '1rem', padding: '1rem',
border: '1px solid #ddd', border: '1px solid #ddd',
borderRadius: '8px', borderTop: 'none',
marginBottom: '1.5rem', borderRadius: '0 0 8px 8px',
marginBottom: '0',
}} }}
> >
<h2 style={{ margin: 0 }}>Lägg till hemmavara</h2> <h2 style={{ margin: 0, display: 'none' }}>Lägg till hemmavara</h2>
<label> <label>
Produkt Produkt
@@ -187,5 +213,7 @@ export default function InventoryForm({ products }: Props) {
{error ? <p style={{ color: 'crimson', margin: 0 }}>{error}</p> : null} {error ? <p style={{ color: 'crimson', margin: 0 }}>{error}</p> : null}
</form> </form>
)}
</div>
); );
} }
+5 -2
View File
@@ -98,8 +98,9 @@ export default async function InventoryPage({ searchParams }: InventoryPageProps
const locationOptions = ['', 'Kyl', 'Frys', 'Skafferi']; const locationOptions = ['', 'Kyl', 'Frys', 'Skafferi'];
const sortOptions = [ const sortOptions = [
{ value: '', label: 'Senast tillagda' }, { value: '', label: 'Senast tillagda' },
{ value: 'bestBeforeAsc', label: 'Bäst före Stigande' }, { value: 'nameAsc', label: 'Namn A\u2013\u00d6' },
{ value: 'bestBeforeDesc', label: 'Bäst före Fallande' }, { value: 'bestBeforeAsc', label: 'B\u00e4st f\u00f6re Stigande' },
{ value: 'bestBeforeDesc', label: 'B\u00e4st f\u00f6re Fallande' },
]; ];
return ( return (
@@ -131,6 +132,7 @@ export default async function InventoryPage({ searchParams }: InventoryPageProps
<Link <Link
key={option || 'alla'} key={option || 'alla'}
href={buildInventoryUrl(option || undefined, sort || undefined)} href={buildInventoryUrl(option || undefined, sort || undefined)}
scroll={false}
style={{ style={{
padding: '0.45rem 0.75rem', padding: '0.45rem 0.75rem',
borderRadius: '999px', borderRadius: '999px',
@@ -158,6 +160,7 @@ export default async function InventoryPage({ searchParams }: InventoryPageProps
<Link <Link
key={option.value || 'default'} key={option.value || 'default'}
href={buildInventoryUrl(location || undefined, option.value || undefined)} href={buildInventoryUrl(location || undefined, option.value || undefined)}
scroll={false}
style={{ style={{
padding: '0.45rem 0.75rem', padding: '0.45rem 0.75rem',
borderRadius: '999px', borderRadius: '999px',