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

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>
)
}