feat(customers): add pagination with 5 items per page

This commit is contained in:
DanielS
2026-09-07 23:34:41 +02:00
parent facf481fdb
commit 40b63b3fa9

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import React, { useState } from 'react' import React, { useState, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import Link from 'next/link' import Link from 'next/link'
import type { EndCustomerWithDevices, FlattenedDevice } from '@/lib/types' import type { EndCustomerWithDevices, FlattenedDevice } from '@/lib/types'
@@ -17,6 +17,10 @@ import {
AlertTriangle, AlertTriangle,
Search, Search,
Package, Package,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from 'lucide-react' } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -188,6 +192,8 @@ export function CustomerAccordionList({ customers }: CustomerAccordionListProps)
}) })
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [currentPage, setCurrentPage] = useState(1)
const pageSize = 5
const toggleCustomer = (id: string) => { const toggleCustomer = (id: string) => {
setOpenCustomerIds((prev) => ({ setOpenCustomerIds((prev) => ({
@@ -196,16 +202,32 @@ export function CustomerAccordionList({ customers }: CustomerAccordionListProps)
})) }))
} }
const filteredCustomers = customers.filter((c) => { const filteredCustomers = useMemo(() => {
const term = searchTerm.toLowerCase() const term = searchTerm.toLowerCase().trim()
return ( if (!term) return customers
c.company_name.toLowerCase().includes(term) ||
(c.first_name && c.first_name.toLowerCase().includes(term)) || return customers.filter((c) => {
(c.last_name && c.last_name.toLowerCase().includes(term)) || return (
(c.city && c.city.toLowerCase().includes(term)) || c.company_name.toLowerCase().includes(term) ||
(c.email && c.email.toLowerCase().includes(term)) (c.first_name && c.first_name.toLowerCase().includes(term)) ||
) (c.last_name && c.last_name.toLowerCase().includes(term)) ||
}) (c.city && c.city.toLowerCase().includes(term)) ||
(c.email && c.email.toLowerCase().includes(term))
)
})
}, [customers, searchTerm])
const totalPages = Math.ceil(filteredCustomers.length / pageSize) || 1
const paginatedCustomers = useMemo(() => {
const from = (currentPage - 1) * pageSize
return filteredCustomers.slice(from, from + pageSize)
}, [filteredCustomers, currentPage, pageSize])
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value)
setCurrentPage(1)
}
if (customers.length === 0) { if (customers.length === 0) {
return ( return (
@@ -225,151 +247,223 @@ export function CustomerAccordionList({ customers }: CustomerAccordionListProps)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Suchfeld */} {/* Suchfeld & Info */}
<div className="relative max-w-md"> <div className="flex flex-col sm:flex-row gap-4 justify-between items-stretch sm:items-center">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" /> <div className="relative max-w-md flex-1">
<Input <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
type="text" <Input
placeholder="Kunden suchen (Firma, Name, Stadt)..." type="text"
value={searchTerm} placeholder="Kunden suchen (Firma, Name, Stadt)..."
onChange={(e) => setSearchTerm(e.target.value)} value={searchTerm}
className="pl-9 bg-slate-900/50 border-white/10 text-white placeholder:text-slate-500 focus:border-primary" onChange={handleSearchChange}
/> className="pl-9 bg-slate-900/50 border-white/10 text-white placeholder:text-slate-500 focus:border-primary"
/>
</div>
<div className="text-xs text-slate-400 bg-slate-900/50 px-3.5 py-2 rounded-xl border border-white/10 shrink-0 text-center flex items-center justify-center gap-2">
<span>Kunden:</span>
<span className="text-white font-bold">{filteredCustomers.length}</span>
{searchTerm && <span className="text-slate-500">(gefiltert aus {customers.length})</span>}
</div>
</div> </div>
{/* Keine Suchergebnisse */}
{filteredCustomers.length === 0 && (
<Card className="glass-dark border-white/10">
<CardContent className="flex flex-col items-center justify-center py-12 gap-3 text-center">
<Search className="w-10 h-10 text-slate-600" />
<p className="text-slate-300 font-medium">Keine Kunden für &quot;{searchTerm}&quot; gefunden.</p>
<Button variant="outline" size="sm" onClick={() => setSearchTerm('')} className="border-white/10 text-xs">
Filter zurücksetzen
</Button>
</CardContent>
</Card>
)}
{/* Akkordeon-Liste */} {/* Akkordeon-Liste */}
<div className="space-y-4"> {paginatedCustomers.length > 0 && (
{filteredCustomers.map((customer) => { <div className="space-y-4">
const isOpen = !!openCustomerIds[customer.id] {paginatedCustomers.map((customer) => {
const devices = customer.devices || [] const isOpen = !!openCustomerIds[customer.id]
const devices = customer.devices || []
return ( return (
<Card <Card
key={customer.id} key={customer.id}
className="glass-dark border-white/10 overflow-hidden transition-all duration-200" className="glass-dark border-white/10 overflow-hidden transition-all duration-200"
>
{/* Akkordeon-Header */}
<div
onClick={() => toggleCustomer(customer.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
toggleCustomer(customer.id)
}
}}
tabIndex={0}
role="button"
aria-expanded={isOpen}
className="p-5 flex items-center justify-between cursor-pointer hover:bg-white/[0.02] transition-colors select-none gap-4 flex-wrap focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
> >
<div className="flex items-center gap-4 min-w-[240px]"> {/* Akkordeon-Header */}
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary font-bold"> <div
<Building2 className="w-5 h-5" /> onClick={() => toggleCustomer(customer.id)}
</div> onKeyDown={(e) => {
<div> if (e.key === 'Enter' || e.key === ' ') {
<div className="flex items-center gap-2"> e.preventDefault()
<h3 className="font-bold text-lg text-white">{customer.company_name}</h3> toggleCustomer(customer.id)
{customer.is_anonymized && ( }
<Badge className="bg-red-500/10 text-red-400 border-red-500/20 text-xs gap-1"> }}
<AlertTriangle className="w-3 h-3" /> Anonymisiert tabIndex={0}
</Badge> role="button"
)} aria-expanded={isOpen}
className="p-5 flex items-center justify-between cursor-pointer hover:bg-white/[0.02] transition-colors select-none gap-4 flex-wrap focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
>
<div className="flex items-center gap-4 min-w-[240px]">
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary font-bold">
<Building2 className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-bold text-lg text-white">{customer.company_name}</h3>
{customer.is_anonymized && (
<Badge className="bg-red-500/10 text-red-400 border-red-500/20 text-xs gap-1">
<AlertTriangle className="w-3 h-3" /> Anonymisiert
</Badge>
)}
</div>
<p className="text-sm text-slate-400">
{[customer.first_name, customer.last_name].filter(Boolean).join(' ') ||
'Kein Ansprechpartner'}
{customer.city ? `${customer.city}` : ''}
{customer.email ? `${customer.email}` : ''}
</p>
</div> </div>
<p className="text-sm text-slate-400">
{[customer.first_name, customer.last_name].filter(Boolean).join(' ') ||
'Kein Ansprechpartner'}
{customer.city ? `${customer.city}` : ''}
{customer.email ? `${customer.email}` : ''}
</p>
</div> </div>
</div>
<div className="flex items-center gap-3 ml-auto"> <div className="flex items-center gap-3 ml-auto">
{/* Kassen-Anzahl Badge */} {/* Kassen-Anzahl Badge */}
<Badge variant="outline" className="border-white/10 text-slate-300 bg-white/5"> <Badge variant="outline" className="border-white/10 text-slate-300 bg-white/5">
<Package className="w-3.5 h-3.5 mr-1 text-primary" /> <Package className="w-3.5 h-3.5 mr-1 text-primary" />
{devices.length} {devices.length === 1 ? 'Kasse' : 'Kassen'} {devices.length} {devices.length === 1 ? 'Kasse' : 'Kassen'}
</Badge> </Badge>
{!customer.is_anonymized && ( {!customer.is_anonymized && (
<> <>
<Link <Link
href={`/order?customer_id=${customer.id}`} href={`/order?customer_id=${customer.id}`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
>
<Button
size="sm"
className="bg-primary/20 hover:bg-primary/30 text-primary border border-primary/40 gap-1.5 text-xs font-bold shadow-[0_0_10px_rgba(59,130,246,0.2)]"
> >
<Sparkles className="w-3.5 h-3.5" /> Kunde updaten / bestellen <Button
</Button> size="sm"
</Link> className="bg-primary/20 hover:bg-primary/30 text-primary border border-primary/40 gap-1.5 text-xs font-bold shadow-[0_0_10px_rgba(59,130,246,0.2)]"
<Link >
href={`/my-customers/${customer.id}`} <Sparkles className="w-3.5 h-3.5" /> Kunde updaten / bestellen
onClick={(e) => e.stopPropagation()} </Button>
> </Link>
<Button <Link
variant="ghost" href={`/my-customers/${customer.id}`}
size="sm" onClick={(e) => e.stopPropagation()}
className="text-slate-400 hover:text-white hover:bg-white/10 gap-1 text-xs"
> >
<Edit2 className="w-3.5 h-3.5" /> Bearbeiten <Button
</Button> variant="ghost"
</Link> size="sm"
</> className="text-slate-400 hover:text-white hover:bg-white/10 gap-1 text-xs"
)} >
<Edit2 className="w-3.5 h-3.5" /> Bearbeiten
<Button </Button>
variant="ghost" </Link>
size="sm" </>
className="text-slate-400 hover:text-white p-1"
>
{isOpen ? (
<ChevronUp className="w-5 h-5" />
) : (
<ChevronDown className="w-5 h-5" />
)} )}
</Button>
</div>
</div>
{/* Ausgeklappter Bereich mit Kassen */} <Button
<AnimatePresence initial={false}> variant="ghost"
{isOpen && ( size="sm"
<motion.div className="text-slate-400 hover:text-white p-1"
initial={{ height: 0, opacity: 0 }} >
animate={{ height: 'auto', opacity: 1 }} {isOpen ? (
exit={{ height: 0, opacity: 0 }} <ChevronUp className="w-5 h-5" />
transition={{ duration: 0.2, ease: 'easeInOut' }}
className="overflow-hidden border-t border-white/5 bg-slate-950/40"
>
<div className="p-5 space-y-3">
{devices.length === 0 ? (
<div className="text-slate-500 text-sm py-4 text-center">
Keine Kassen oder Bestellungen für diesen Kunden vorhanden.
</div>
) : ( ) : (
<div className="space-y-3"> <ChevronDown className="w-5 h-5" />
<p className="text-xs uppercase tracking-wider text-slate-400 font-semibold mb-2">
Zugeordnete Kassen ({devices.length})
</p>
{devices.map((device, idx) => (
<DeviceCard
key={`${device.orderId}-${device.deviceId}-${idx}`}
device={device}
customerId={customer.id}
/>
))}
</div>
)} )}
</div> </Button>
</motion.div> </div>
)} </div>
</AnimatePresence>
</Card> {/* Ausgeklappter Bereich mit Kassen */}
) <AnimatePresence initial={false}>
})} {isOpen && (
</div> <motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
className="overflow-hidden border-t border-white/5 bg-slate-950/40"
>
<div className="p-5 space-y-3">
{devices.length === 0 ? (
<div className="text-slate-500 text-sm py-4 text-center">
Keine Kassen oder Bestellungen für diesen Kunden vorhanden.
</div>
) : (
<div className="space-y-3">
<p className="text-xs uppercase tracking-wider text-slate-400 font-semibold mb-2">
Zugeordnete Kassen ({devices.length})
</p>
{devices.map((device, idx) => (
<DeviceCard
key={`${device.orderId}-${device.deviceId}-${idx}`}
device={device}
customerId={customer.id}
/>
))}
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</Card>
)
})}
</div>
)}
{/* Paginierung (5 Kunden pro Seite) */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-4 border-t border-white/10">
<span className="text-xs text-slate-400">
Seite <span className="text-white font-bold">{currentPage}</span> von{' '}
<span className="text-white font-bold">{totalPages}</span>
</span>
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className="h-8 px-2 text-slate-400 hover:text-white"
title="Erste Seite"
>
<ChevronsLeft className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="border-white/10 text-xs gap-1 rounded-xl h-8 px-3"
>
<ChevronLeft className="w-3.5 h-3.5" /> Vorherige
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="border-white/10 text-xs gap-1 rounded-xl h-8 px-3"
>
Nächste <ChevronRight className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages}
className="h-8 px-2 text-slate-400 hover:text-white"
title="Letzte Seite"
>
<ChevronsRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</div> </div>
) )
} }