feat: change products view sorting to category grouping with subheaders
All checks were successful
Staging Build / build (push) Successful in 2m34s
All checks were successful
Staging Build / build (push) Successful in 2m34s
This commit is contained in:
@@ -14,12 +14,12 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { Edit2, Trash2, Layers, ArrowUpDown } from 'lucide-react'
|
import { Edit2, Trash2, Layers, ArrowUpDown } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { deleteProduct } from '@/lib/actions/products'
|
import { deleteProduct } from '@/lib/actions/products'
|
||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo, Fragment } from 'react'
|
||||||
import { CreateProductDialog } from './create-product-dialog'
|
import { CreateProductDialog } from './create-product-dialog'
|
||||||
|
|
||||||
export function ProductList({ initialProducts, categories }: { initialProducts: Product[], categories: Category[] }) {
|
export function ProductList({ initialProducts, categories }: { initialProducts: Product[], categories: Category[] }) {
|
||||||
const [products, setProducts] = useState(initialProducts)
|
const [products, setProducts] = useState(initialProducts)
|
||||||
const [isSortedByCategory, setIsSortedByCategory] = useState(false)
|
const [isGroupedByCategory, setIsGroupedByCategory] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setProducts(initialProducts)
|
setProducts(initialProducts)
|
||||||
@@ -32,25 +32,81 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleSortByCategory = () => {
|
const toggleGroupByCategory = () => {
|
||||||
setIsSortedByCategory(!isSortedByCategory)
|
setIsGroupedByCategory(!isGroupedByCategory)
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayedProducts = useMemo(() => {
|
const groupedProducts = useMemo(() => {
|
||||||
if (!isSortedByCategory) return products
|
const groups: Record<string, { category: Category | null; products: Product[] }> = {}
|
||||||
|
products.forEach(p => {
|
||||||
return [...products].sort((a, b) => {
|
const catId = p.category_id || 'no-category'
|
||||||
|
if (!groups[catId]) {
|
||||||
|
groups[catId] = {
|
||||||
|
category: p.category || null,
|
||||||
|
products: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groups[catId].products.push(p)
|
||||||
|
})
|
||||||
|
return Object.values(groups).sort((a, b) => {
|
||||||
const orderA = a.category?.sort_order ?? 9999
|
const orderA = a.category?.sort_order ?? 9999
|
||||||
const orderB = b.category?.sort_order ?? 9999
|
const orderB = b.category?.sort_order ?? 9999
|
||||||
if (orderA !== orderB) return orderA - orderB
|
return orderA - orderB
|
||||||
|
|
||||||
const catA = a.category?.name || ''
|
|
||||||
const catB = b.category?.name || ''
|
|
||||||
if (catA !== catB) return catA.localeCompare(catB)
|
|
||||||
|
|
||||||
return a.name.localeCompare(b.name)
|
|
||||||
})
|
})
|
||||||
}, [products, isSortedByCategory])
|
}, [products])
|
||||||
|
|
||||||
|
const renderProductRow = (product: Product) => (
|
||||||
|
<TableRow key={product.id} className="border-slate-100 dark:border-white/5 hover:bg-slate-50 dark:hover:bg-white/5 transition-colors">
|
||||||
|
<TableCell className="font-medium text-slate-900 dark:text-white">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{product.name}
|
||||||
|
{product.category && (
|
||||||
|
<Badge variant="outline" className="text-[10px] uppercase border-primary/30 text-primary py-0 h-4">
|
||||||
|
{product.category.name}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{product.billing_interval === 'monthly' && (
|
||||||
|
<Badge className="text-[10px] bg-blue-500/20 text-blue-600 dark:text-blue-400 border border-blue-500/30 py-0 h-4">Abo/Monat</Badge>
|
||||||
|
)}
|
||||||
|
{product.billing_interval === 'one_time' && (
|
||||||
|
<Badge className="text-[10px] bg-slate-500/20 text-slate-600 dark:text-slate-300 border border-slate-500/30 py-0 h-4">Einmalig</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 font-normal">{product.description}</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-slate-900 dark:text-white font-semibold">{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Layers className="w-3 h-3 text-primary" />
|
||||||
|
<span className="text-slate-700 dark:text-slate-200">{product.modules?.length || 0} Module</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={product.billing_interval === 'monthly' ? 'default' : 'secondary'}>
|
||||||
|
{product.billing_interval === 'monthly' ? 'Abo/Monat' : 'Einmalig'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<CreateProductDialog categories={categories} product={product}>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">
|
||||||
|
<Edit2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CreateProductDialog>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => handleDelete(product.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-white dark:bg-slate-900/50 border border-slate-200 dark:border-white/10 shadow-sm overflow-hidden text-slate-900 dark:text-white">
|
<Card className="bg-white dark:bg-slate-900/50 border border-slate-200 dark:border-white/10 shadow-sm overflow-hidden text-slate-900 dark:text-white">
|
||||||
@@ -64,13 +120,13 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={toggleSortByCategory}
|
onClick={toggleGroupByCategory}
|
||||||
className={`border-slate-200 dark:border-white/10 hover:bg-slate-100 dark:hover:bg-white/10 text-slate-700 dark:text-white ${
|
className={`border-slate-200 dark:border-white/10 hover:bg-slate-100 dark:hover:bg-white/10 text-slate-700 dark:text-white ${
|
||||||
isSortedByCategory ? 'bg-primary/10 border-primary text-primary hover:bg-primary/20 dark:bg-primary/20 dark:border-primary dark:text-primary-foreground' : ''
|
isGroupedByCategory ? 'bg-primary/10 border-primary text-primary hover:bg-primary/20 dark:bg-primary/20 dark:border-primary dark:text-primary-foreground' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ArrowUpDown className="w-4 h-4 mr-2" />
|
<ArrowUpDown className="w-4 h-4 mr-2" />
|
||||||
{isSortedByCategory ? 'Kategoriesortierung aktiv' : 'Nach Kategorien sortieren'}
|
{isGroupedByCategory ? 'Kategoriengruppierung aktiv' : 'Nach Kategorien gruppieren'}
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -85,59 +141,21 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{displayedProducts.map((product) => (
|
{isGroupedByCategory ? (
|
||||||
<TableRow key={product.id} className="border-slate-100 dark:border-white/5 hover:bg-slate-50 dark:hover:bg-white/5 transition-colors">
|
groupedProducts.map((group) => (
|
||||||
<TableCell className="font-medium text-slate-900 dark:text-white">
|
<Fragment key={group.category?.id || 'no-category'}>
|
||||||
<div className="flex flex-col gap-1">
|
<TableRow className="bg-slate-100 dark:bg-slate-800/40 hover:bg-slate-100 dark:hover:bg-slate-800/40 border-b border-slate-200 dark:border-white/10">
|
||||||
<div className="flex items-center gap-2">
|
<TableCell colSpan={5} className="text-primary font-bold py-2.5 text-xs uppercase tracking-wider">
|
||||||
{product.name}
|
{group.category ? group.category.name : 'Keine Kategorie'}
|
||||||
{product.category && (
|
</TableCell>
|
||||||
<Badge variant="outline" className="text-[10px] uppercase border-primary/30 text-primary py-0 h-4">
|
</TableRow>
|
||||||
{product.category.name}
|
{group.products.map((product) => renderProductRow(product))}
|
||||||
</Badge>
|
</Fragment>
|
||||||
)}
|
))
|
||||||
{product.billing_interval === 'monthly' && (
|
) : (
|
||||||
<Badge className="text-[10px] bg-blue-500/20 text-blue-600 dark:text-blue-400 border border-blue-500/30 py-0 h-4">Abo/Monat</Badge>
|
products.map((product) => renderProductRow(product))
|
||||||
)}
|
)}
|
||||||
{product.billing_interval === 'one_time' && (
|
{products.length === 0 && (
|
||||||
<Badge className="text-[10px] bg-slate-500/20 text-slate-600 dark:text-slate-300 border border-slate-500/30 py-0 h-4">Einmalig</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400 font-normal">{product.description}</p>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-slate-900 dark:text-white font-semibold">{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Layers className="w-3 h-3 text-primary" />
|
|
||||||
<span className="text-slate-700 dark:text-slate-200">{product.modules?.length || 0} Module</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={product.billing_interval === 'monthly' ? 'default' : 'secondary'}>
|
|
||||||
{product.billing_interval === 'monthly' ? 'Abo/Monat' : 'Einmalig'}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<CreateProductDialog categories={categories} product={product}>
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">
|
|
||||||
<Edit2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</CreateProductDialog>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
||||||
onClick={() => handleDelete(product.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
{displayedProducts.length === 0 && (
|
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} className="text-center py-10 text-slate-500 dark:text-slate-400">
|
<TableCell colSpan={5} className="text-center py-10 text-slate-500 dark:text-slate-400">
|
||||||
Keine Produkte gefunden. Erstellen Sie Ihr erstes Produkt!
|
Keine Produkte gefunden. Erstellen Sie Ihr erstes Produkt!
|
||||||
|
|||||||
Reference in New Issue
Block a user