You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
423 lines
20 KiB
423 lines
20 KiB
import React, { useState } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Card } from '@/components/ui/card'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import {
|
|
Table,
|
|
TableHeader,
|
|
TableBody,
|
|
TableRow,
|
|
TableHead,
|
|
TableCell,
|
|
} from '@/components/ui/table'
|
|
import { Ic } from '@/icons'
|
|
import { toFa } from '@/lib/utils'
|
|
import { useTranslation } from '@/i18n'
|
|
import { formatDateByLanguage } from '@/lib/date-utils'
|
|
import type {
|
|
CustomTripOrderItem,
|
|
CustomTripOrderStatus,
|
|
CustomTripOriginItem,
|
|
CustomTripDestinationItem,
|
|
CustomTripHotelItem,
|
|
} from '../types'
|
|
|
|
interface CustomTripOrdersTabProps {
|
|
orders: CustomTripOrderItem[]
|
|
onOrderUpdated: (updated: CustomTripOrderItem) => void
|
|
origins?: CustomTripOriginItem[]
|
|
destinations?: CustomTripDestinationItem[]
|
|
hotels?: CustomTripHotelItem[]
|
|
}
|
|
|
|
export function CustomTripOrdersTab({
|
|
orders,
|
|
onOrderUpdated,
|
|
origins = [],
|
|
destinations = [],
|
|
hotels = [],
|
|
}: CustomTripOrdersTabProps) {
|
|
const navigate = useNavigate()
|
|
const { t, language } = useTranslation()
|
|
const [search, setSearch] = useState('')
|
|
const [statusFilter, setStatusFilter] = useState<string>('all')
|
|
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
|
|
|
const handleOpenReview = (order: CustomTripOrderItem) => {
|
|
navigate(`/custom-trip/orders/${order.id}`)
|
|
}
|
|
|
|
const getStatusBadge = (status: CustomTripOrderStatus) => {
|
|
switch (status) {
|
|
case 'APPROVED':
|
|
return (
|
|
<Badge variant="success" className="gap-1.5 py-1 text-xs font-semibold">
|
|
<Ic name="checkCircle" className="size-3.5" />
|
|
{t('customTrip.statusApproved')}
|
|
</Badge>
|
|
)
|
|
case 'AWAITING_PAYMENT':
|
|
return (
|
|
<Badge className="gap-1.5 py-1 text-xs font-semibold bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20">
|
|
<Ic name="calculator" className="size-3.5" />
|
|
{t('customTrip.statusAwaitingPayment')}
|
|
</Badge>
|
|
)
|
|
case 'PENDING':
|
|
return (
|
|
<Badge className="gap-1.5 py-1 text-xs font-semibold bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20">
|
|
<Ic name="history" className="size-3.5" />
|
|
{t('customTrip.statusPending')}
|
|
</Badge>
|
|
)
|
|
case 'REJECTED':
|
|
return (
|
|
<Badge variant="destructive" className="gap-1.5 py-1 text-xs font-semibold">
|
|
<Ic name="xCircle" className="size-3.5" />
|
|
{t('customTrip.statusRejected')}
|
|
</Badge>
|
|
)
|
|
case 'EXPIRED':
|
|
return (
|
|
<Badge variant="secondary" className="gap-1.5 py-1 text-xs font-semibold">
|
|
<Ic name="history" className="size-3.5" />
|
|
{t('customTrip.statusExpired')}
|
|
</Badge>
|
|
)
|
|
case 'CANCELLED':
|
|
return (
|
|
<Badge variant="destructive" className="gap-1.5 py-1 text-xs font-semibold">
|
|
<Ic name="xCircle" className="size-3.5" />
|
|
{t('customTrip.statusCancelled')}
|
|
</Badge>
|
|
)
|
|
default:
|
|
return (
|
|
<Badge variant="secondary" className="gap-1.5 py-1 text-xs font-semibold">
|
|
{status}
|
|
</Badge>
|
|
)
|
|
}
|
|
}
|
|
|
|
const filteredOrders = orders.filter((order) => {
|
|
const matchesStatus = statusFilter === 'all' || order.status === statusFilter
|
|
const term = search.trim().toLowerCase()
|
|
const matchesSearch =
|
|
!term ||
|
|
order.id.toString().includes(term) ||
|
|
(order.user.fullName && order.user.fullName.toLowerCase().includes(term)) ||
|
|
(order.originCity && order.originCity.toLowerCase().includes(term)) ||
|
|
order.destinations.some((d) => d.toLowerCase().includes(term))
|
|
return matchesStatus && matchesSearch
|
|
})
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Toast notification */}
|
|
{toastMessage && (
|
|
<div className="p-3 bg-emerald-500/10 border border-emerald-500/20 text-emerald-600 dark:text-emerald-400 text-sm font-medium rounded-xl flex items-center gap-2">
|
|
<Ic name="checkCircle" className="size-4" />
|
|
<span>{toastMessage}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* KPI Stats */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
<Card className="p-3.5 border-border/70 shadow-xs">
|
|
<span className="text-[11px] text-muted-foreground font-medium block">
|
|
{t('customTrip.allOrdersCount')}
|
|
</span>
|
|
<div className="mt-1 flex items-baseline gap-1.5">
|
|
<span className="text-xl font-black font-mono text-foreground">{toFa(orders.length)}</span>
|
|
<span className="text-xs text-muted-foreground">{t('customTrip.ordersUnit')}</span>
|
|
</div>
|
|
</Card>
|
|
<Card className="p-3.5 border-border/70 shadow-xs">
|
|
<span className="text-[11px] text-muted-foreground font-medium block">
|
|
{t('customTrip.pendingOrdersCount')}
|
|
</span>
|
|
<div className="mt-1 flex items-baseline gap-1.5">
|
|
<span className="text-xl font-black font-mono text-blue-600 dark:text-blue-400">
|
|
{toFa(orders.filter((o) => o.status === 'PENDING').length)}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">{t('customTrip.ordersUnit')}</span>
|
|
</div>
|
|
</Card>
|
|
<Card className="p-3.5 border-border/70 shadow-xs">
|
|
<span className="text-[11px] text-muted-foreground font-medium block">
|
|
{t('customTrip.approvedOrdersCount')}
|
|
</span>
|
|
<div className="mt-1 flex items-baseline gap-1.5">
|
|
<span className="text-xl font-black font-mono text-emerald-600 dark:text-emerald-400">
|
|
{toFa(orders.filter((o) => o.status === 'APPROVED').length)}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">{t('customTrip.ordersUnit')}</span>
|
|
</div>
|
|
</Card>
|
|
<Card className="p-3.5 border-border/70 shadow-xs">
|
|
<span className="text-[11px] text-muted-foreground font-medium block">
|
|
{t('customTrip.awaitingPaymentOrdersCount')}
|
|
</span>
|
|
<div className="mt-1 flex items-baseline gap-1.5">
|
|
<span className="text-xl font-black font-mono text-amber-600 dark:text-amber-400">
|
|
{toFa(orders.filter((o) => o.status === 'AWAITING_PAYMENT').length)}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">{t('customTrip.ordersUnit')}</span>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Filter and Action Bar */}
|
|
<Card className="flex flex-col gap-3 p-3 border-border/70 shadow-xs">
|
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
|
<div className="relative flex-1">
|
|
<Ic name="search" className="absolute top-1/2 -translate-y-1/2 start-3 size-4 text-muted-foreground" />
|
|
<Input
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder={t('customTrip.searchOrdersPlaceholder')}
|
|
className="ps-9 h-10 rounded-xl bg-background border-border/60 text-xs sm:text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status filter chips */}
|
|
<div className="flex items-center gap-1.5 overflow-x-auto pt-1 pb-0.5">
|
|
<span className="text-xs text-muted-foreground font-medium shrink-0 ps-1">
|
|
{t('customTrip.filterByStatus')}:
|
|
</span>
|
|
<button
|
|
onClick={() => setStatusFilter('all')}
|
|
className={`px-3 py-1 rounded-xl text-xs font-semibold whitespace-nowrap transition-colors cursor-pointer ${
|
|
statusFilter === 'all'
|
|
? 'bg-primary text-white shadow-xs'
|
|
: 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
}`}
|
|
>
|
|
{t('customTrip.allOrders')} ({toFa(orders.length)})
|
|
</button>
|
|
<button
|
|
onClick={() => setStatusFilter('PENDING')}
|
|
className={`flex items-center gap-1.5 px-3 py-1 rounded-xl text-xs font-semibold whitespace-nowrap transition-colors cursor-pointer ${
|
|
statusFilter === 'PENDING'
|
|
? 'bg-primary text-white shadow-xs'
|
|
: 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
}`}
|
|
>
|
|
<span className="size-2 rounded-full bg-blue-500" />
|
|
<span>{t('customTrip.statusPending')}</span>
|
|
<span className="opacity-75 font-mono text-[10px]">
|
|
({toFa(orders.filter((o) => o.status === 'PENDING').length)})
|
|
</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setStatusFilter('APPROVED')}
|
|
className={`flex items-center gap-1.5 px-3 py-1 rounded-xl text-xs font-semibold whitespace-nowrap transition-colors cursor-pointer ${
|
|
statusFilter === 'APPROVED'
|
|
? 'bg-primary text-white shadow-xs'
|
|
: 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
}`}
|
|
>
|
|
<span className="size-2 rounded-full bg-emerald-500" />
|
|
<span>{t('customTrip.statusApproved')}</span>
|
|
<span className="opacity-75 font-mono text-[10px]">
|
|
({toFa(orders.filter((o) => o.status === 'APPROVED').length)})
|
|
</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setStatusFilter('AWAITING_PAYMENT')}
|
|
className={`flex items-center gap-1.5 px-3 py-1 rounded-xl text-xs font-semibold whitespace-nowrap transition-colors cursor-pointer ${
|
|
statusFilter === 'AWAITING_PAYMENT'
|
|
? 'bg-primary text-white shadow-xs'
|
|
: 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
}`}
|
|
>
|
|
<span className="size-2 rounded-full bg-amber-500" />
|
|
<span>{t('customTrip.statusAwaitingPayment')}</span>
|
|
<span className="opacity-75 font-mono text-[10px]">
|
|
({toFa(orders.filter((o) => o.status === 'AWAITING_PAYMENT').length)})
|
|
</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setStatusFilter('REJECTED')}
|
|
className={`flex items-center gap-1.5 px-3 py-1 rounded-xl text-xs font-semibold whitespace-nowrap transition-colors cursor-pointer ${
|
|
statusFilter === 'REJECTED'
|
|
? 'bg-primary text-white shadow-xs'
|
|
: 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
}`}
|
|
>
|
|
<span className="size-2 rounded-full bg-rose-500" />
|
|
<span>{t('customTrip.statusRejected')}</span>
|
|
<span className="opacity-75 font-mono text-[10px]">
|
|
({toFa(orders.filter((o) => o.status === 'REJECTED').length)})
|
|
</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setStatusFilter('CANCELLED')}
|
|
className={`flex items-center gap-1.5 px-3 py-1 rounded-xl text-xs font-semibold whitespace-nowrap transition-colors cursor-pointer ${
|
|
statusFilter === 'CANCELLED'
|
|
? 'bg-primary text-white shadow-xs'
|
|
: 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
}`}
|
|
>
|
|
<span className="size-2 rounded-full bg-slate-500" />
|
|
<span>{t('customTrip.statusCancelled')}</span>
|
|
<span className="opacity-75 font-mono text-[10px]">
|
|
({toFa(orders.filter((o) => o.status === 'CANCELLED').length)})
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Orders Table */}
|
|
<Card className="overflow-hidden border-border/70 shadow-xs">
|
|
<div className="overflow-x-auto">
|
|
<Table className="w-full text-xs sm:text-sm text-start">
|
|
<TableHeader className="bg-muted/40 text-muted-foreground border-b border-border/60 font-medium">
|
|
<TableRow className="hover:bg-transparent">
|
|
<TableHead className="px-4 py-3.5 text-start font-medium">{t('customTrip.rowNumber')}</TableHead>
|
|
<TableHead className="px-4 py-3.5 text-start font-medium">{t('customTrip.orderId')}</TableHead>
|
|
<TableHead className="px-4 py-3.5 text-start font-medium">{t('customTrip.customer')}</TableHead>
|
|
<TableHead className="px-4 py-3.5 text-start font-medium">{t('customTrip.travelRoute')}</TableHead>
|
|
<TableHead className="px-4 py-3.5 text-center font-medium">{t('customTrip.passengers')}</TableHead>
|
|
<TableHead className="px-4 py-3.5 text-center font-medium">{t('customTrip.invoiceAmount')}</TableHead>
|
|
<TableHead className="px-4 py-3.5 text-center font-medium">{t('customTrip.status')}</TableHead>
|
|
<TableHead className="px-4 py-3.5 text-center font-medium">{t('customTrip.createdDate')}</TableHead>
|
|
<TableHead className="px-4 py-3.5 text-center font-medium">{t('customTrip.actions')}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody className="divide-y divide-border/50">
|
|
{filteredOrders.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={9} className="py-12 text-center text-muted-foreground">
|
|
<Ic name="inbox" className="size-8 mx-auto mb-2 text-muted-foreground/60" />
|
|
{t('customTrip.noOrdersFound')}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
filteredOrders.map((order, idx) => (
|
|
<TableRow
|
|
key={order.id}
|
|
onClick={() => handleOpenReview(order)}
|
|
className="hover:bg-muted/40 transition-colors cursor-pointer group"
|
|
title={t('customTrip.reviewOrder')}
|
|
>
|
|
<TableCell className="px-4 py-3.5 font-mono text-muted-foreground">
|
|
{toFa(idx + 1)}
|
|
</TableCell>
|
|
<TableCell className="px-4 py-3.5 font-mono font-bold text-foreground group-hover:text-primary transition-colors">
|
|
#{toFa(order.id)}
|
|
</TableCell>
|
|
<TableCell className="px-4 py-3.5">
|
|
<div className="font-semibold text-foreground group-hover:text-primary transition-colors">
|
|
{order.user.fullName || order.user.username}
|
|
</div>
|
|
<div className="font-mono text-xs text-muted-foreground mt-0.5 dir-ltr text-start">
|
|
{order.user.phoneNumber || order.user.email || '—'}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="px-4 py-3.5">
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
<span className="font-medium text-foreground px-2.5 py-1 rounded-lg bg-muted/70 text-xs">
|
|
{order.originCity}
|
|
</span>
|
|
{order.destinations.map((dest, i) => (
|
|
<React.Fragment key={i}>
|
|
<span className="text-muted-foreground/60 text-xs">←</span>
|
|
<span className="px-2.5 py-1 rounded-lg bg-primary/10 text-primary font-medium text-xs">
|
|
{dest}
|
|
</span>
|
|
</React.Fragment>
|
|
))}
|
|
{order.originCity && (
|
|
<>
|
|
<span className="text-muted-foreground/60 text-xs">←</span>
|
|
<span className="font-medium text-muted-foreground px-2 py-0.5 rounded-lg bg-muted/50 border border-border/60 text-[11px]">
|
|
{order.originCity}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
{order.legs && order.legs.length > 0 && (
|
|
<div className="text-[11px] text-muted-foreground mt-1 truncate max-w-xs">
|
|
{order.legs
|
|
.filter((l) => !l.isReturn)
|
|
.map((l) => `${l.toCity}: ${l.hotelName || '—'} (${toFa(l.stayNights || 1)} شب)`)
|
|
.join(' | ')}
|
|
</div>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="px-4 py-3.5 text-center">
|
|
<span className="inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-xl bg-card border border-border/70 text-foreground">
|
|
<Ic name="users" className="size-3 text-muted-foreground" />
|
|
<span className="font-mono">{toFa(order.adultsCount)}</span>
|
|
<span className="text-muted-foreground text-[11px]">ب</span>
|
|
{order.childrenCount > 0 && (
|
|
<>
|
|
<span className="text-muted-foreground/50">+</span>
|
|
<span className="font-mono">{toFa(order.childrenCount)}</span>
|
|
<span className="text-muted-foreground text-[11px]">ک</span>
|
|
</>
|
|
)}
|
|
{order.infantsCount > 0 && (
|
|
<>
|
|
<span className="text-muted-foreground/50">+</span>
|
|
<span className="font-mono">{toFa(order.infantsCount)}</span>
|
|
<span className="text-muted-foreground text-[11px]">ن</span>
|
|
</>
|
|
)}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="px-4 py-3.5 text-center">
|
|
{order.finalPrice && Number(order.finalPrice) > 0 ? (
|
|
<span className="font-mono font-bold text-foreground">
|
|
${toFa(Number(order.finalPrice).toLocaleString())}
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20 whitespace-nowrap">
|
|
{t('customTrip.pendingInvoiceBadge')}
|
|
</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="px-4 py-3.5 text-center">
|
|
<div className="flex flex-col items-center gap-1">
|
|
{getStatusBadge(order.status)}
|
|
{order.factorId && order.status !== 'PENDING' ? (
|
|
<span className="inline-flex items-center gap-1 text-[10px] font-mono font-bold text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-md">
|
|
<Ic name="receipt" className="size-3" />
|
|
<span>فاکتور #{toFa(order.factorNumber || order.factorId)}</span>
|
|
</span>
|
|
) : (
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{t('customTrip.notInvoicedYetBadge')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="px-4 py-3.5 text-center font-mono text-xs text-muted-foreground">
|
|
{formatDateByLanguage(order.created, language)}
|
|
</TableCell>
|
|
<TableCell className="px-4 py-3.5 text-center" onClick={(e) => e.stopPropagation()}>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleOpenReview(order)}
|
|
className="rounded-xl gap-1.5 h-8 px-2.5 text-xs font-semibold hover:border-primary hover:text-primary shadow-2xs"
|
|
>
|
|
<Ic name="edit" className="size-3.5" />
|
|
<span>{t('customTrip.reviewOrder')}</span>
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|