feat(admin): enhance operations with search and sales dashboard
This commit is contained in:
1 parent
d9bba4bacc
commit
36ea126993
3 files changed
+86
-27
No files matched your search
@@ -17,6 +17,7 @@ interface Customer {
|
|||||||
export default function CustomersPage() {
|
export default function CustomersPage() {
|
||||||
const [customers, setCustomers] = useState<Customer[]>([])
|
const [customers, setCustomers] = useState<Customer[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCustomers()
|
fetchCustomers()
|
||||||
@@ -34,12 +35,30 @@ export default function CustomersPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filteredCustomers = customers.filter(c => {
|
||||||
|
const term = searchTerm.toLowerCase()
|
||||||
|
return (
|
||||||
|
(c.displayName || '').toLowerCase().includes(term) ||
|
||||||
|
(c.phone || '').includes(term) ||
|
||||||
|
(c.email || '').toLowerCase().includes(term)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
if (loading) return <div className="p-8 animate-pulse text-slate-400">Loading Customers...</div>
|
if (loading) return <div className="p-8 animate-pulse text-slate-400">Loading Customers...</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">LINE Customers</h1>
|
<h1 className="text-3xl font-bold tracking-tight">LINE Customers</h1>
|
||||||
|
<div className="relative w-full md:w-64">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search name, phone, email..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full bg-white border border-slate-200 rounded-xl px-4 py-2 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
|
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
|
||||||
@@ -54,14 +73,14 @@ export default function CustomersPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-100">
|
<tbody className="divide-y divide-slate-100">
|
||||||
{customers.length === 0 ? (
|
{filteredCustomers.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} className="px-6 py-8 text-center text-slate-500">
|
<td colSpan={4} className="px-6 py-8 text-center text-slate-500">
|
||||||
No customers found yet.
|
No customers found matching your search.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
customers.map(customer => (
|
filteredCustomers.map(customer => (
|
||||||
<tr key={customer.id} className="hover:bg-slate-50/80 transition-colors">
|
<tr key={customer.id} className="hover:bg-slate-50/80 transition-colors">
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -7,29 +7,45 @@ export default function DashboardPage() {
|
|||||||
products: 0,
|
products: 0,
|
||||||
categories: 0,
|
categories: 0,
|
||||||
scents: 0,
|
scents: 0,
|
||||||
avgPrice: 0
|
avgPrice: 0,
|
||||||
|
orders: 0,
|
||||||
|
revenue: 0,
|
||||||
|
customers: 0
|
||||||
})
|
})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const [catRes, prodRes] = await Promise.all([
|
const [catRes, prodRes, orderRes, customerRes] = await Promise.all([
|
||||||
fetch(`${API_BASE}/categories`),
|
fetch(`${API_BASE}/categories`),
|
||||||
fetch(`${API_BASE}/products`)
|
fetch(`${API_BASE}/products`),
|
||||||
|
fetch(`${API_BASE}/orders`),
|
||||||
|
fetch(`${API_BASE}/customers`)
|
||||||
])
|
])
|
||||||
const cats: Category[] = await catRes.json()
|
const cats: Category[] = await catRes.json()
|
||||||
const prods: Product[] = await prodRes.json()
|
const prods: Product[] = await prodRes.json()
|
||||||
|
const orders = await orderRes.json()
|
||||||
|
const customers = await customerRes.json()
|
||||||
|
|
||||||
const scents = new Set(prods.map(p => p.scent.id)).size
|
const scents = new Set(prods.map(p => p.scent.id)).size
|
||||||
const totalProducts = prods.length
|
const totalProducts = prods.length
|
||||||
const avgPrice = prods.reduce((acc, curr) => acc + (curr.price || 0), 0) / (totalProducts || 1)
|
const avgPrice = prods.reduce((acc, curr) => acc + (curr.price || 0), 0) / (totalProducts || 1)
|
||||||
|
|
||||||
|
const validOrders = Array.isArray(orders) ? orders : []
|
||||||
|
const totalOrders = validOrders.length
|
||||||
|
const revenue = validOrders.reduce((acc: number, curr: any) => acc + (curr.status === 'PAID' || curr.status === 'SHIPPED' ? curr.totalAmount : 0), 0)
|
||||||
|
|
||||||
|
const totalCustomers = Array.isArray(customers) ? customers.length : 0
|
||||||
|
|
||||||
setStats({
|
setStats({
|
||||||
products: totalProducts,
|
products: totalProducts,
|
||||||
categories: cats.length,
|
categories: cats.length,
|
||||||
scents,
|
scents,
|
||||||
avgPrice
|
avgPrice,
|
||||||
|
orders: totalOrders,
|
||||||
|
revenue,
|
||||||
|
customers: totalCustomers
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Dashboard data error:', err)
|
console.error('Dashboard data error:', err)
|
||||||
@@ -41,10 +57,10 @@ export default function DashboardPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const cards = [
|
const cards = [
|
||||||
{ label: 'Total Products', value: stats.products, icon: Package, color: 'text-blue-400' },
|
{ label: 'Total Revenue', value: `฿${stats.revenue.toLocaleString()}`, icon: TrendingUp, color: 'text-emerald-400' },
|
||||||
{ label: 'Active Categories', value: stats.categories, icon: Layers, color: 'text-cyan-glow' },
|
{ label: 'Total Orders', value: stats.orders, icon: Package, color: 'text-blue-400' },
|
||||||
{ label: 'Unique Scents', value: stats.scents, icon: Tag, color: 'text-purple-400' },
|
{ label: 'Total Customers', value: stats.customers, icon: Tag, color: 'text-purple-400' },
|
||||||
{ label: 'Avg Unit Price', value: `฿${Math.round(stats.avgPrice).toLocaleString()}`, icon: TrendingUp, color: 'text-emerald-400' },
|
{ label: 'Total Products', value: stats.products, icon: Layers, color: 'text-cyan-glow' },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { API_BASE, formatPrice, type Order } from '@homaroma/types'
|
import { API_BASE, formatPrice, type Order } from '@homaroma/types'
|
||||||
import { ChevronRight, Clock, CheckCircle2, Truck, XCircle } from 'lucide-react'
|
import { Search, ChevronRight, Clock, CheckCircle2, Truck, XCircle } from 'lucide-react'
|
||||||
|
|
||||||
export default function OrdersPage() {
|
export default function OrdersPage() {
|
||||||
const [orders, setOrders] = useState<Order[]>([])
|
const [orders, setOrders] = useState<Order[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [filter, setFilter] = useState<string>('ALL')
|
const [filter, setFilter] = useState<string>('ALL')
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -25,7 +26,17 @@ export default function OrdersPage() {
|
|||||||
fetchData()
|
fetchData()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const filteredOrders = orders.filter(o => filter === 'ALL' || o.status === filter)
|
const filteredOrders = orders.filter(o => {
|
||||||
|
const matchesFilter = filter === 'ALL' || o.status === filter
|
||||||
|
const searchLower = searchTerm.toLowerCase()
|
||||||
|
const matchesSearch =
|
||||||
|
o.id.toString().includes(searchLower) ||
|
||||||
|
o.customerName.toLowerCase().includes(searchLower) ||
|
||||||
|
o.customerPhone.includes(searchLower) ||
|
||||||
|
(o.customer?.displayName?.toLowerCase() || '').includes(searchLower)
|
||||||
|
|
||||||
|
return matchesFilter && matchesSearch
|
||||||
|
})
|
||||||
|
|
||||||
const StatusIcon = ({ status }: { status: string }) => {
|
const StatusIcon = ({ status }: { status: string }) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -47,19 +58,32 @@ export default function OrdersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters & Search */}
|
||||||
<div className="flex bg-white/5 border border-white/5 rounded-xl p-1 w-max">
|
<div className="flex flex-col md:flex-row gap-4 justify-between items-start md:items-center">
|
||||||
{['ALL', 'PENDING_PAYMENT', 'PAID', 'SHIPPED', 'CANCELLED'].map((f) => (
|
<div className="flex flex-wrap bg-white/5 border border-white/5 rounded-xl p-1 w-max">
|
||||||
<button
|
{['ALL', 'PENDING_PAYMENT', 'PAID', 'SHIPPED', 'CANCELLED'].map((f) => (
|
||||||
key={f}
|
<button
|
||||||
onClick={() => setFilter(f)}
|
key={f}
|
||||||
className={`px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-widest transition-all ${
|
onClick={() => setFilter(f)}
|
||||||
filter === f ? 'bg-white/10 text-cyan-glow' : 'text-slate-500 hover:text-white'
|
className={`px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-widest transition-all ${
|
||||||
}`}
|
filter === f ? 'bg-white/10 text-cyan-glow' : 'text-slate-500 hover:text-white'
|
||||||
>
|
}`}
|
||||||
{f.replace('_', ' ')}
|
>
|
||||||
</button>
|
{f.replace('_', ' ')}
|
||||||
))}
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full md:w-64">
|
||||||
|
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search order ID, name, phone..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl pl-10 pr-4 py-2 text-sm text-white focus:outline-none focus:border-cyan-glow/50 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Orders Table */}
|
{/* Orders Table */}
|
||||||
|
|||||||
Reference in new issue
Block a user