feat(customers): add pagination with 5 items per page
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import Link from 'next/link'
|
||||
import type { EndCustomerWithDevices, FlattenedDevice } from '@/lib/types'
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
AlertTriangle,
|
||||
Search,
|
||||
Package,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -188,6 +192,8 @@ export function CustomerAccordionList({ customers }: CustomerAccordionListProps)
|
||||
})
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const pageSize = 5
|
||||
|
||||
const toggleCustomer = (id: string) => {
|
||||
setOpenCustomerIds((prev) => ({
|
||||
@@ -196,16 +202,32 @@ export function CustomerAccordionList({ customers }: CustomerAccordionListProps)
|
||||
}))
|
||||
}
|
||||
|
||||
const filteredCustomers = customers.filter((c) => {
|
||||
const term = searchTerm.toLowerCase()
|
||||
return (
|
||||
c.company_name.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))
|
||||
)
|
||||
})
|
||||
const filteredCustomers = useMemo(() => {
|
||||
const term = searchTerm.toLowerCase().trim()
|
||||
if (!term) return customers
|
||||
|
||||
return customers.filter((c) => {
|
||||
return (
|
||||
c.company_name.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) {
|
||||
return (
|
||||
@@ -225,151 +247,223 @@ export function CustomerAccordionList({ customers }: CustomerAccordionListProps)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Suchfeld */}
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Kunden suchen (Firma, Name, Stadt)..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 bg-slate-900/50 border-white/10 text-white placeholder:text-slate-500 focus:border-primary"
|
||||
/>
|
||||
{/* Suchfeld & Info */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-between items-stretch sm:items-center">
|
||||
<div className="relative max-w-md flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Kunden suchen (Firma, Name, Stadt)..."
|
||||
value={searchTerm}
|
||||
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>
|
||||
|
||||
{/* 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 "{searchTerm}" gefunden.</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setSearchTerm('')} className="border-white/10 text-xs">
|
||||
Filter zurücksetzen
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Akkordeon-Liste */}
|
||||
<div className="space-y-4">
|
||||
{filteredCustomers.map((customer) => {
|
||||
const isOpen = !!openCustomerIds[customer.id]
|
||||
const devices = customer.devices || []
|
||||
{paginatedCustomers.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{paginatedCustomers.map((customer) => {
|
||||
const isOpen = !!openCustomerIds[customer.id]
|
||||
const devices = customer.devices || []
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={customer.id}
|
||||
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"
|
||||
return (
|
||||
<Card
|
||||
key={customer.id}
|
||||
className="glass-dark border-white/10 overflow-hidden transition-all duration-200"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
{/* 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]">
|
||||
<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>
|
||||
<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 className="flex items-center gap-3 ml-auto">
|
||||
{/* Kassen-Anzahl Badge */}
|
||||
<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" />
|
||||
{devices.length} {devices.length === 1 ? 'Kasse' : 'Kassen'}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-3 ml-auto">
|
||||
{/* Kassen-Anzahl Badge */}
|
||||
<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" />
|
||||
{devices.length} {devices.length === 1 ? 'Kasse' : 'Kassen'}
|
||||
</Badge>
|
||||
|
||||
{!customer.is_anonymized && (
|
||||
<>
|
||||
<Link
|
||||
href={`/order?customer_id=${customer.id}`}
|
||||
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)]"
|
||||
{!customer.is_anonymized && (
|
||||
<>
|
||||
<Link
|
||||
href={`/order?customer_id=${customer.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" /> Kunde updaten / bestellen
|
||||
</Button>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/my-customers/${customer.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-slate-400 hover:text-white hover:bg-white/10 gap-1 text-xs"
|
||||
<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>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/my-customers/${customer.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" /> Bearbeiten
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
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
|
||||
variant="ghost"
|
||||
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>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ausgeklappter Bereich mit Kassen */}
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-slate-400 hover:text-white p-1"
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronUp className="w-5 h-5" />
|
||||
) : (
|
||||
<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>
|
||||
<ChevronDown className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ausgeklappter Bereich mit Kassen */}
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user