250 lines
8.7 KiB
TypeScript
250 lines
8.7 KiB
TypeScript
import React, { useCallback } from 'react'
|
|
import type { OrderCardActionProps } from '@packages/shared'
|
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
|
import { ChevronLeft, ChevronRight, CircleAlert, User as UserIcon, Clock, ShoppingBag } from 'lucide-react'
|
|
import { AppContainer, p as MyText, Checkbox } from 'web-components'
|
|
import { trpc } from '@/lib/trpc-client'
|
|
import { formatDistanceToNow } from 'date-fns'
|
|
import dayjs from 'dayjs'
|
|
import { UserIncidentsView } from '../components/UserIncidentsView'
|
|
|
|
export const Route = createFileRoute('/dashboard/users/$id')({
|
|
component: UserDetails,
|
|
})
|
|
|
|
interface Order {
|
|
id: number
|
|
readableId: number
|
|
totalAmount: string
|
|
createdAt: string
|
|
status: string
|
|
isFlashDelivery: boolean
|
|
itemCount: number
|
|
}
|
|
|
|
interface OrderItemProps extends OrderCardActionProps<Order> {
|
|
}
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case 'delivered':
|
|
return 'text-green-600 bg-green-50 border-green-100'
|
|
case 'cancelled':
|
|
return 'text-red-600 bg-red-50 border-red-100'
|
|
default:
|
|
return 'text-yellow-600 bg-yellow-50 border-yellow-100'
|
|
}
|
|
}
|
|
|
|
const OrderItem: React.FC<OrderItemProps> = ({ order, onPress }) => {
|
|
const statusStyle = getStatusColor(order.status)
|
|
|
|
return (
|
|
<div
|
|
onClick={onPress}
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') onPress()
|
|
}}
|
|
className="mb-3 cursor-pointer rounded-xl border border-gray-100 bg-white p-4 text-left shadow-sm transition-colors hover:bg-gray-50"
|
|
>
|
|
<div className="mb-3 flex-row flex items-center justify-between">
|
|
<div className="flex-row flex items-center">
|
|
<MyText className="text-lg font-bold text-gray-900">
|
|
#{order.readableId}
|
|
</MyText>
|
|
{order.isFlashDelivery && (
|
|
<span className="ml-2 rounded-full border border-amber-200 bg-amber-100 px-2 py-0.5">
|
|
<MyText className="text-[10px] font-black uppercase text-amber-700">⚡</MyText>
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className={`rounded-full border px-3 py-1 ${statusStyle}`}>
|
|
<MyText className={`text-xs font-bold uppercase tracking-wider ${statusStyle.split(' ')[0]}`}>
|
|
{order.status}
|
|
</MyText>
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex-row flex items-center justify-between">
|
|
<div>
|
|
<MyText className="mb-1 text-sm text-gray-500">
|
|
{dayjs(order.createdAt).format('MMM DD, YYYY • h:mm A')}
|
|
</MyText>
|
|
<MyText className="text-xs text-gray-400">
|
|
{order.itemCount} {order.itemCount === 1 ? 'item' : 'items'}
|
|
</MyText>
|
|
</div>
|
|
<MyText className="text-xl font-bold text-blue-600">
|
|
₹{order.totalAmount}
|
|
</MyText>
|
|
</div>
|
|
|
|
<div className="mt-3 flex-row flex items-center justify-center border-t border-gray-100 pt-3">
|
|
<MyText className="text-sm font-medium text-blue-600">
|
|
View Order Details
|
|
</MyText>
|
|
<ChevronRight size={18} color="#3b82f6" />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function UserDetails() {
|
|
const navigate = useNavigate()
|
|
const { id } = Route.useParams() as { id: string }
|
|
const userId = id ? parseInt(id) : 0
|
|
|
|
const { data, isLoading, error, refetch } = trpc.admin.user.getUserDetails.useQuery(
|
|
{ userId },
|
|
{ enabled: !!userId }
|
|
)
|
|
|
|
const updateSuspension = trpc.admin.user.updateUserSuspension.useMutation({
|
|
onSuccess: () => {
|
|
refetch()
|
|
},
|
|
})
|
|
|
|
const handleOrderPress = useCallback((orderId: number) => {
|
|
navigate({ to: '/dashboard/orders/$id', params: { id: String(orderId) } })
|
|
}, [navigate])
|
|
|
|
const handleSuspensionToggle = useCallback((isSuspended: boolean) => {
|
|
updateSuspension.mutate({ userId, isSuspended })
|
|
}, [userId, updateSuspension])
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<AppContainer>
|
|
<div className="flex flex-1 items-center justify-center">
|
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
|
|
<MyText className="mt-4 text-gray-500">Loading user details...</MyText>
|
|
</div>
|
|
</AppContainer>
|
|
)
|
|
}
|
|
|
|
if (error || !data) {
|
|
return (
|
|
<AppContainer>
|
|
<div className="flex flex-1 items-center justify-center p-8">
|
|
<CircleAlert size={64} color="#ef4444" />
|
|
<MyText className="mt-4 text-lg font-bold text-gray-900">Error</MyText>
|
|
<MyText className="mt-2 text-center text-gray-500">
|
|
{error?.message || 'Failed to load user details'}
|
|
</MyText>
|
|
<button
|
|
onClick={() => refetch()}
|
|
className="mt-6 rounded-full bg-blue-600 px-6 py-3"
|
|
>
|
|
<MyText className="font-semibold text-white">Retry</MyText>
|
|
</button>
|
|
</div>
|
|
</AppContainer>
|
|
)
|
|
}
|
|
|
|
const { user, orders } = data
|
|
const displayName = user.name || 'Unnamed User'
|
|
|
|
return (
|
|
<AppContainer>
|
|
<div className="flex-1 bg-gray-50">
|
|
<div className="flex-row flex items-center border-b border-gray-200 bg-white px-4 py-4">
|
|
<button
|
|
onClick={() => window.history.back()}
|
|
className="-ml-4 p-2"
|
|
aria-label="Go back"
|
|
>
|
|
<ChevronLeft size={24} color="#374151" />
|
|
</button>
|
|
<MyText className="ml-2 text-xl font-bold text-gray-900">User Details</MyText>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="m-4 rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
|
|
<div className="mb-4 flex-row flex items-center">
|
|
<div className="mr-4 flex h-12 w-12 items-center justify-center rounded-full bg-blue-50">
|
|
<UserIcon size={24} color="#3B82F6" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="flex-row flex items-center">
|
|
<MyText className="mb-0.5 text-lg font-bold text-gray-900">
|
|
{user.mobile || 'No Mobile'}
|
|
</MyText>
|
|
{user.isSuspended && (
|
|
<span className="ml-2 rounded-full border border-red-200 bg-red-100 px-2 py-0.5">
|
|
<MyText className="text-[10px] font-bold uppercase text-red-700">Suspended</MyText>
|
|
</span>
|
|
)}
|
|
</div>
|
|
<MyText className="text-gray-500">
|
|
{displayName}
|
|
</MyText>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mb-4 rounded-xl bg-gray-50 p-3">
|
|
<div className="flex-row flex items-center">
|
|
<Clock size={18} color="#6B7280" />
|
|
<MyText className="ml-2 text-gray-600">
|
|
Registered {formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })}
|
|
</MyText>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-row flex items-center justify-between border-t border-gray-100 pt-4">
|
|
<div className="flex-1">
|
|
<MyText className="mb-1 text-base font-bold text-gray-900">
|
|
Suspend User
|
|
</MyText>
|
|
<MyText className="text-sm text-gray-500">
|
|
Prevent user from placing orders
|
|
</MyText>
|
|
</div>
|
|
{updateSuspension.isPending ? (
|
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
|
|
) : (
|
|
<Checkbox
|
|
checked={user.isSuspended}
|
|
onPress={() => handleSuspensionToggle(!user.isSuspended)}
|
|
size={28}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<UserIncidentsView userId={userId} orderId={null} />
|
|
|
|
<div className="px-4 pb-8">
|
|
<div className="mb-4 flex-row flex items-center justify-between">
|
|
<MyText className="text-lg font-bold text-gray-900">Order History</MyText>
|
|
<MyText className="text-sm text-gray-500">
|
|
{orders.length} {orders.length === 1 ? 'order' : 'orders'}
|
|
</MyText>
|
|
</div>
|
|
|
|
{orders.length === 0 ? (
|
|
<div className="items-center rounded-xl border border-gray-100 bg-white p-8">
|
|
<ShoppingBag size={48} color="#e5e7eb" />
|
|
<MyText className="mt-4 text-center text-gray-500">
|
|
No orders yet
|
|
</MyText>
|
|
</div>
|
|
) : (
|
|
orders.map((order) => (
|
|
<OrderItem
|
|
key={order.id}
|
|
order={order}
|
|
onPress={() => handleOrderPress(order.id)}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</AppContainer>
|
|
)
|
|
}
|