Add sorting by name functionality and implement AdminProductList component for product management
This commit is contained in:
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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,8 +57,32 @@ export default function InventoryForm({ products }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
onSubmit={async (e) => {
|
<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
|
||||||
|
onSubmit={async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsPending(true);
|
setIsPending(true);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user