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.
 
 
 

1131 lines
55 KiB

import React, { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Avatar } from '@/components/ui/avatar'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Ic } from '@/icons'
import { ShimmerBox, ShimmerLine } from '@/components/shared/shimmer_frontend'
import { toast } from '@/stores/toast'
import { useTranslation } from '@/i18n'
import { useBillingBadgeStore } from '@/stores/billing-badge'
import { formatDateByLanguage, formatDateTimeByLanguage, formatDualDate } from '@/lib/date-utils'
import { cn, resolveMediaUrl, getMediaFallbackUrl, toFa } from '@/lib/utils'
import type { FactorItem, FactorStatus } from './types'
import {
fetchFactorDetail,
reviewFactor,
cancelFactor,
uploadFactorTicket,
deleteFactorTicket,
clearFactorReceipt,
} from './services/billing-api'
import { BillingStatusBadge } from './components/BillingStatusBadge'
import { BillingServiceBadge } from './components/BillingServiceBadge'
import { ReceiptLightboxDialog } from './components/ReceiptLightboxDialog'
const STATUS_OPTIONS: {
value: FactorStatus
label: string
dotClass: string
}[] = [
{ value: 'awaiting_payment', label: 'در انتظار پرداخت', dotClass: 'bg-amber-500' },
{ value: 'pending', label: 'در انتظار بررسی فیش', dotClass: 'bg-blue-500' },
{ value: 'approved', label: 'پرداخت شده و تایید نهایی', dotClass: 'bg-emerald-500' },
{ value: 'rejected', label: 'رد شده توسط ادمین', dotClass: 'bg-rose-500' },
{ value: 'expired', label: 'منقضی شده (مهلت پرداخت)', dotClass: 'bg-slate-500' },
{ value: 'cancelled', label: 'کنسل‌شده', dotClass: 'bg-red-500' },
]
export default function BillingDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { t, language } = useTranslation()
const [factor, setFactor] = useState<FactorItem | null>(null)
const [isLoading, setIsLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null)
const [selectedStatus, setSelectedStatus] = useState<FactorStatus>('awaiting_payment')
const [rejectReason, setRejectReason] = useState<string>('')
const [hasChanges, setHasChanges] = useState<boolean>(false)
const [isSaving, setIsSaving] = useState<boolean>(false)
const [isUploadingTicket, setIsUploadingTicket] = useState<boolean>(false)
const [isDeletingTicket, setIsDeletingTicket] = useState<boolean>(false)
const [isDraggingTicket, setIsDraggingTicket] = useState<boolean>(false)
const ticketFileInputRef = useRef<HTMLInputElement>(null)
const [receiptLightboxOpen, setReceiptLightboxOpen] = useState<boolean>(false)
const [isClearingReceipt, setIsClearingReceipt] = useState<boolean>(false)
const [receiptError, setReceiptError] = useState<boolean>(false)
const [copiedCard, setCopiedCard] = useState<boolean>(false)
const [copiedFactorNumber, setCopiedFactorNumber] = useState<boolean>(false)
const loadFactor = async (targetId: string | number) => {
setIsLoading(true)
setError(null)
try {
const data = await fetchFactorDetail(targetId)
setFactor(data)
setSelectedStatus(data.status)
setRejectReason(data.rejected_description || '')
setHasChanges(false)
setReceiptError(false)
} catch (err: any) {
console.error('Failed to load factor details:', err)
setError(err?.message || 'صورت‌حساب مالی مورد نظر یافت نشد یا دسترسی به آن امکان‌پذیر نیست.')
setFactor(null)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
if (id) {
loadFactor(id)
}
}, [id])
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="h-9 w-48 rounded-xl bg-muted/60 animate-pulse" />
<div className="h-9 w-32 rounded-xl bg-muted/60 animate-pulse" />
</div>
<div className="h-28 rounded-2xl bg-card border border-border/60 animate-pulse" />
<div className="grid grid-cols-1 xl:grid-cols-12 gap-6">
<div className="xl:col-span-8 space-y-4">
<div className="h-64 rounded-2xl bg-card border border-border/60 animate-pulse" />
<div className="h-44 rounded-2xl bg-card border border-border/60 animate-pulse" />
</div>
<div className="xl:col-span-4 space-y-4">
<div className="h-48 rounded-2xl bg-card border border-border/60 animate-pulse" />
<div className="h-48 rounded-2xl bg-card border border-border/60 animate-pulse" />
</div>
</div>
</div>
)
}
if (error || !factor) {
return (
<div className="p-8 text-center bg-card border border-border/70 rounded-2xl max-w-xl mx-auto my-12 space-y-4 shadow-sm">
<div className="size-14 rounded-2xl bg-rose-500/10 text-rose-600 dark:text-rose-400 flex items-center justify-center mx-auto">
<Ic name="alertCircle" className="size-7" />
</div>
<h2 className="text-base font-bold text-foreground">خطا در بارگذاری صورت‌حساب مالی</h2>
<p className="text-xs text-muted-foreground leading-relaxed">{error}</p>
<div className="flex items-center justify-center gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={() => navigate('/billing')}
className="rounded-xl text-xs font-bold h-9 px-4 gap-1.5"
>
<Ic name="receipt" className="size-4" />
<span>مشاهده همه صورت‌حساب‌ها</span>
</Button>
<Button
variant="default"
size="sm"
onClick={() => id && loadFactor(id)}
className="rounded-xl text-xs font-bold h-9 px-4 gap-1.5"
>
<Ic name="refreshCw" className="size-4" />
<span>تلاش مجدد</span>
</Button>
</div>
</div>
)
}
const detail = factor.detail_service
const user = factor.user_info
const isTourOrCustomTrip = factor.service === 'tour' || factor.service === 'custom_trip'
const isApproved = factor.status === 'approved'
const isApprovedSelectedWithoutTicket =
isTourOrCustomTrip && selectedStatus === 'approved' && !factor.ticket_file
const handleCopyCard = (cardNumber: string) => {
navigator.clipboard.writeText(cardNumber)
setCopiedCard(true)
setTimeout(() => setCopiedCard(false), 2000)
}
const handleCopyFactorNumber = (num: string | number) => {
navigator.clipboard.writeText(String(num))
setCopiedFactorNumber(true)
setTimeout(() => setCopiedFactorNumber(false), 2000)
}
const handleSaveStatus = async () => {
if (!factor) return
if (selectedStatus === 'rejected' && !rejectReason.trim()) {
toast.error('وارد کردن دلیل رد صورت‌حساب الزامی است.', 'دلیل رد الزامی است')
return
}
if (isApprovedSelectedWithoutTicket) {
toast.error(
t('billing.ticketRequiredForApprovedDesc'),
t('billing.ticketRequiredForApprovedTitle')
)
return
}
setIsSaving(true)
try {
const payload = {
status: selectedStatus,
rejected_description: selectedStatus === 'rejected' ? rejectReason.trim() : undefined,
}
const updated = await reviewFactor(factor.id, payload)
setFactor(updated)
setSelectedStatus(updated.status)
setRejectReason(updated.rejected_description || '')
setHasChanges(false)
useBillingBadgeStore.setState({ lastFetched: null })
useBillingBadgeStore.getState().fetchCounts()
const statusLabel =
STATUS_OPTIONS.find((s) => s.value === selectedStatus)?.label || selectedStatus
toast.success(
`وضعیت صورت‌حساب #${factor.factor_number || factor.id} با موفقیت به «${statusLabel}» تغییر یافت.`,
'بروزرسانی وضعیت'
)
if (selectedStatus === 'approved') {
navigate('/billing')
}
} catch (err: any) {
toast.error(err?.message || 'خطا در ذخیره‌سازی وضعیت صورت‌حساب.', 'خطا در عملیات')
} finally {
setIsSaving(false)
}
}
const handleCancelFactor = async () => {
if (!factor) return
if (!window.confirm(t('billing.cancelInvoiceConfirm'))) return
setIsSaving(true)
try {
const updated = await cancelFactor(factor.id)
setFactor(updated)
setSelectedStatus(updated.status)
setHasChanges(false)
useBillingBadgeStore.setState({ lastFetched: null })
useBillingBadgeStore.getState().fetchCounts()
toast.success(t('billing.invoiceCancelledSuccess'), 'کنسل کردن فاکتور')
navigate('/billing')
} catch (err: any) {
toast.error(err?.message || 'خطا در کنسل کردن فاکتور.', 'خطا در لغو')
} finally {
setIsSaving(false)
}
}
const handleUploadTicketFile = async (file: File) => {
if (!factor) return
setIsUploadingTicket(true)
try {
const updated = await uploadFactorTicket(factor.id, file)
setFactor(updated)
if (updated.status !== 'approved') {
setSelectedStatus('approved')
setHasChanges(true)
} else {
setSelectedStatus(updated.status)
setHasChanges(false)
}
useBillingBadgeStore.setState({ lastFetched: null })
useBillingBadgeStore.getState().fetchCounts()
toast.success(
updated.status !== 'approved'
? t('billing.ticketUploadSuccessPendingSave')
: t('billing.ticketUploadSuccess')
)
} catch (err: any) {
toast.error(err?.message || 'خطا در بارگذاری فایل بلیت.')
} finally {
setIsUploadingTicket(false)
if (ticketFileInputRef.current) ticketFileInputRef.current.value = ''
}
}
const handleDeleteTicket = async () => {
if (!factor) return
if (factor.status === 'approved') {
toast.warning(
t('billing.cannotDeleteTicketWhenApproved'),
t('billing.ticketRequiredTitle')
)
return
}
if (!window.confirm(t('billing.deleteTicketConfirm'))) return
setIsDeletingTicket(true)
try {
const updated = await deleteFactorTicket(factor.id)
setFactor(updated)
if (selectedStatus === 'approved') {
setSelectedStatus(updated.status)
setHasChanges(false)
}
toast.success(t('billing.ticketDeleteSuccess'))
} catch (err: any) {
toast.error(err?.message || 'خطا در حذف بلیت.')
} finally {
setIsDeletingTicket(false)
}
}
const handleClearReceipt = async () => {
if (!factor) return
if (!window.confirm(t('billing.confirmClearReceipt'))) return
setIsClearingReceipt(true)
try {
await clearFactorReceipt(factor.id)
setFactor((prev) => (prev ? { ...prev, transaction_receipt: null } : null))
setReceiptError(false)
toast.success(t('billing.receiptClearedSuccess'))
} catch (err: any) {
toast.error(err?.message || 'خطا در حذف تصویر رسید.')
} finally {
setIsClearingReceipt(false)
}
}
return (
<div className="space-y-6 pb-12">
{/* Top Header & Breadcrumbs */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-4 border-b border-border/70">
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Link
to="/billing"
className="hover:text-primary transition-colors flex items-center gap-1 font-medium"
>
<Ic name="receipt" className="size-3.5" />
<span>صورت‌حساب‌های مالی</span>
</Link>
<span>/</span>
<span className="font-mono text-foreground font-semibold">
#{toFa(factor.factor_number || factor.id)}
</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-xl sm:text-2xl font-black text-foreground flex items-center gap-2">
<span>صورت‌حساب مالی</span>
<span className="font-mono text-primary">#{toFa(factor.factor_number || factor.id)}</span>
</h1>
<BillingServiceBadge service={factor.service} />
<BillingStatusBadge status={factor.status} />
</div>
</div>
<div className="flex flex-wrap items-center gap-2.5 shrink-0">
{/* Back button */}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => navigate(-1)}
className="rounded-xl text-xs font-semibold h-9 px-3.5 gap-1.5 border-border/70 hover:bg-muted cursor-pointer"
>
<Ic name="arrowRight" className="size-3.5 rtl:rotate-0 ltr:rotate-180" />
<span>بازگشت</span>
</Button>
{/* Quick link to reference order */}
{factor.service === 'custom_trip' && (factor.object_id || factor.order_id) && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => navigate(`/custom-trip/orders/${factor.object_id || factor.order_id}`)}
className="rounded-xl text-xs font-bold h-9 px-3.5 border-primary/40 text-primary hover:bg-primary/10 gap-1.5 cursor-pointer"
>
<Ic name="map" className="size-3.5" />
<span>مشاهده برنامه سفر در بخش اختصاصی</span>
</Button>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => navigate('/billing')}
className="rounded-xl text-xs font-bold h-9 px-3.5 border-border/70 hover:bg-muted gap-1.5 cursor-pointer"
>
<Ic name="receipt" className="size-3.5" />
<span>فهرست کل صورت‌حساب‌ها</span>
</Button>
</div>
</div>
{/* Expiration date banner if exists */}
{factor.expiration_date && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-2xl p-4 flex items-center justify-between text-xs">
<span className="text-foreground/90 font-medium flex items-center gap-2">
<Ic name="clock" className="size-4 text-amber-600 dark:text-amber-400 shrink-0" />
<span>مهلت پرداخت فاکتور (تاریخ انقضای اعتبار):</span>
</span>
<span className="font-bold text-foreground font-mono">
{formatDateByLanguage(factor.expiration_date, language as any)}
</span>
</div>
)}
{/* 2-Column Responsive Layout */}
<div className="grid grid-cols-1 xl:grid-cols-12 gap-6 items-start">
{/* Main Column (8 cols): Service Details, Travel Ticket, Bank Receipt */}
<div className="xl:col-span-8 space-y-6">
{/* Polymorphic Service Details Card */}
<Card className="border-border/70 rounded-2xl shadow-xs overflow-hidden">
<CardHeader className="py-3.5 px-5 border-b border-border/60 bg-muted/20 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-bold flex items-center gap-2 text-foreground">
<Ic name="list" className="size-4.5 text-primary" />
<span>ریز اقلام و مشخصات سفارش ({factor.service?.toUpperCase() || ''})</span>
</CardTitle>
<span className="text-xs text-muted-foreground font-mono">
{formatDateTimeByLanguage(factor.created_at, language as any)}
</span>
</CardHeader>
<CardContent className="p-5 text-xs sm:text-sm space-y-4">
{/* 1. Custom Trip Service */}
{factor.service === 'custom_trip' && (
<div className="space-y-4">
{detail?.origin_city && (
<div className="flex items-center gap-2 pb-2.5 border-b border-border/50 text-xs">
<span className="text-muted-foreground font-medium">مبدأ حرکت زائر:</span>
<span className="font-bold text-foreground text-sm">{detail.origin_city}</span>
</div>
)}
{detail?.legs && Array.isArray(detail.legs) && detail.legs.length > 0 && (
<div className="space-y-2.5">
<div className="flex items-center justify-between">
<span className="font-bold text-xs text-foreground flex items-center gap-1.5">
<Ic name="navigation" className="size-4 text-primary" />
<span>مسیرها و هتل‌های تأییدشده در فاکتور:</span>
</span>
<span className="text-xs text-muted-foreground">
{toFa(detail.legs.length)} ایستگاه سفر
</span>
</div>
<div className="space-y-2.5">
{detail.legs.map((leg: any, idx: number) => {
const isReturnLeg = Boolean(leg.is_return)
const stay = detail.stays?.find((s: any) => s.city === leg.to_city)
return (
<div
key={idx}
className="p-3.5 rounded-xl border border-border/60 bg-muted/20 space-y-2"
>
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="size-6 rounded-lg bg-primary/10 text-primary font-bold flex items-center justify-center text-[11px] shrink-0">
{toFa(idx + 1)}
</span>
<span className="font-bold text-foreground">{leg.from_city}</span>
<span className="text-muted-foreground">←</span>
<span className="font-bold text-primary">{leg.to_city}</span>
{isReturnLeg ? (
<span className="text-primary font-semibold text-[11px] bg-primary/10 px-2 py-0.5 rounded-md">
(بازگشت به شهر مبدأ — بدون هتل)
</span>
) : (
stay?.nights && (
<span className="text-muted-foreground text-[11px]">
({toFa(stay.nights)} شب اقامت)
</span>
)
)}
</div>
<div className="flex flex-wrap items-center gap-2 text-[11px]">
<span className="bg-card px-2 py-0.5 rounded-md border border-border/60 font-medium">
✈️ {leg.transport_method_display || (leg.transport_method === 1 ? 'هوایی' : leg.transport_method === 2 ? 'ریلی' : 'زمینی')}
</span>
{leg.departure_date && (
<span className="bg-card px-2 py-0.5 rounded-md border border-border/60 text-muted-foreground">
📅 {formatDualDate(leg.departure_date, (language as any) || 'fa', { withTime: true, time: leg.departure_time })}
</span>
)}
{!isReturnLeg && stay?.hotel_name && (
<span className="bg-card px-2 py-0.5 rounded-md border border-border/60 font-semibold text-foreground">
🏨 {stay.hotel_name}
</span>
)}
</div>
</div>
)
})}
</div>
</div>
)}
{detail?.admin_notes && (
<div className="bg-emerald-500/10 p-3 rounded-xl border border-emerald-500/25">
<span className="font-bold text-emerald-700 dark:text-emerald-300 block mb-0.5 text-xs">
توضیحات تأیید نهایی و صدور فاکتور:
</span>
<p className="text-xs text-foreground leading-relaxed">{detail.admin_notes}</p>
</div>
)}
{detail?.detail && (
<div className="bg-muted/30 p-3 rounded-xl border border-border/40">
<span className="text-muted-foreground block mb-1 text-xs">درخواست اولیه زائر:</span>
<p className="text-xs leading-relaxed text-foreground whitespace-pre-wrap">
{typeof detail.detail === 'string' ? detail.detail : JSON.stringify(detail.detail)}
</p>
</div>
)}
</div>
)}
{/* 2. Tour Service */}
{factor.service === 'tour' && (
<div className="space-y-3">
<div className="flex items-center justify-between pb-2 border-b border-border/40">
<span className="text-muted-foreground">عنوان تور انتخابی:</span>
<span className="font-bold text-sm text-foreground">{detail?.tour || '—'}</span>
</div>
{detail?.passenger_data && (
<div className="space-y-2">
<p className="font-bold text-xs text-muted-foreground">اسامی و رده سنی مسافران:</p>
{(detail.passenger_data.adults?.count ?? 0) > 0 && (
<div className="bg-muted/40 p-2.5 rounded-xl border border-border/50 text-xs">
<span className="font-bold text-foreground">
بزرگسال ({toFa(detail.passenger_data.adults?.count)} نفر):
</span>
<span className="ms-2 text-muted-foreground">
{Array.isArray(detail.passenger_data.adults?.names)
? detail.passenger_data.adults?.names.join('، ')
: String(detail.passenger_data.adults?.names || '')}
</span>
</div>
)}
{(detail.passenger_data.children?.count ?? 0) > 0 && (
<div className="bg-muted/40 p-2.5 rounded-xl border border-border/50 text-xs">
<span className="font-bold text-foreground">
کودک ({toFa(detail.passenger_data.children?.count)} نفر):
</span>
<span className="ms-2 text-muted-foreground">
{Array.isArray(detail.passenger_data.children?.names)
? detail.passenger_data.children?.names.join('، ')
: String(detail.passenger_data.children?.names || '')}
</span>
</div>
)}
{(detail.passenger_data.infants?.count ?? 0) > 0 && (
<div className="bg-muted/40 p-2.5 rounded-xl border border-border/50 text-xs">
<span className="font-bold text-foreground">
نوزاد ({toFa(detail.passenger_data.infants?.count)} نفر):
</span>
<span className="ms-2 text-muted-foreground">
{Array.isArray(detail.passenger_data.infants?.names)
? detail.passenger_data.infants?.names.join('، ')
: String(detail.passenger_data.infants?.names || '')}
</span>
</div>
)}
</div>
)}
</div>
)}
{/* 3. Simcard Service */}
{factor.service === 'simcard' && (
<div className="space-y-3">
<div className="flex justify-between pb-2 border-b border-border/40">
<span className="text-muted-foreground">اپراتور و بسته:</span>
<span className="font-bold text-foreground">{detail?.title || 'سیم‌کارت بین‌المللی'}</span>
</div>
<div className="flex justify-between pb-2 border-b border-border/40">
<span className="text-muted-foreground">تعداد:</span>
<span className="font-mono font-bold text-foreground">{detail?.count || 1}</span>
</div>
</div>
)}
{/* 4. Exchange (Tasrif) Service */}
{factor.service === 'tasrif' && (
<div className="space-y-3">
<div className="flex justify-between pb-2 border-b border-border/40">
<span className="text-muted-foreground">نوع تبدیل ارز:</span>
<span className="font-bold text-foreground">{detail?.exchange_type || 'صرافی و تبدیل ارز'}</span>
</div>
</div>
)}
{/* 5. Shop Service */}
{factor.service === 'shop' && (
<div className="space-y-3">
<div className="flex justify-between pb-2 border-b border-border/40">
<span className="text-muted-foreground">محصول فروشگاه:</span>
<span className="font-bold text-foreground">{detail?.product_title || 'خرید محصول'}</span>
</div>
</div>
)}
</CardContent>
</Card>
{/* Travel Ticket Management Card (Tour & Custom Trip) */}
{isTourOrCustomTrip && (
<Card className="border-border/70 rounded-2xl shadow-xs overflow-hidden">
<CardHeader className="py-3.5 px-5 border-b border-border/60 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-bold flex items-center gap-2 text-foreground">
<div className="size-6 rounded-lg bg-primary/10 text-primary flex items-center justify-center shrink-0">
<Ic name="ticket" className="size-3.5" />
</div>
<span>{t('billing.ticketCardTitle')}</span>
</CardTitle>
{factor.ticket_file ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<span className="size-1.5 rounded-full bg-emerald-500" />
{t('billing.ticketIssuedBadge')}
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20">
<span className="size-1.5 rounded-full bg-amber-500" />
{t('billing.ticketPendingBadge')}
</span>
)}
</CardHeader>
<CardContent className="p-5">
<input
ref={ticketFileInputRef}
type="file"
accept=".pdf,image/*,.doc,.docx"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleUploadTicketFile(file)
}}
/>
{factor.ticket_file ? (
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 p-4 rounded-xl border border-emerald-500/30 bg-emerald-500/5">
<div className="flex items-center gap-3">
<div className="size-10 rounded-xl bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center justify-center shrink-0">
<Ic name="file" className="size-5" />
</div>
<div className="space-y-0.5">
<p className="text-xs font-bold text-foreground">
{t('billing.ticketIssuedTitle')}
</p>
<p
className="text-[11px] text-muted-foreground font-mono truncate max-w-[280px] sm:max-w-md"
dir="ltr"
>
{factor.ticket_file.split('/').pop() || factor.ticket_file}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 w-full sm:w-auto">
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs rounded-xl border-border-soft hover:bg-muted"
onClick={() => window.open(resolveMediaUrl(factor.ticket_file!), '_blank')}
>
<Ic name="download" className="size-3.5 text-primary" />
<span>{t('billing.viewTicketBtn')}</span>
</Button>
<Button
variant="outline"
size="sm"
disabled={isUploadingTicket}
className="h-8 gap-1.5 text-xs rounded-xl border-border-soft hover:bg-muted"
onClick={() => ticketFileInputRef.current?.click()}
>
<Ic name="upload" className="size-3.5" />
<span>
{isUploadingTicket ? t('billing.ticketUploading') : t('billing.replaceTicketBtn')}
</span>
</Button>
<Button
variant="outline"
size="sm"
disabled={isDeletingTicket || factor.status === 'approved'}
title={
factor.status === 'approved'
? t('billing.cannotDeleteTicketWhenApproved')
: undefined
}
className={cn(
'h-8 gap-1.5 text-xs rounded-xl border-destructive/30 text-destructive hover:bg-destructive/10',
factor.status === 'approved' && 'opacity-50 cursor-not-allowed hover:bg-transparent'
)}
onClick={handleDeleteTicket}
>
<Ic name="trash" className="size-3.5" />
<span>
{isDeletingTicket ? t('billing.ticketDeleting') : t('billing.deleteTicketBtn')}
</span>
</Button>
</div>
</div>
) : (
<div
onDragOver={(e) => {
e.preventDefault()
setIsDraggingTicket(true)
}}
onDragLeave={() => setIsDraggingTicket(false)}
onDrop={(e) => {
e.preventDefault()
setIsDraggingTicket(false)
const file = e.dataTransfer.files?.[0]
if (file) handleUploadTicketFile(file)
}}
className={`flex flex-col items-center justify-center p-6 rounded-xl border border-dashed transition-all duration-200 text-center space-y-3 ${
isDraggingTicket
? 'border-primary bg-primary/10 ring-2 ring-primary/20'
: 'border-border/80 bg-muted/20 hover:border-primary/50 hover:bg-muted/30'
}`}
>
<div className="size-12 rounded-2xl bg-primary/10 text-primary flex items-center justify-center shadow-xs">
<Ic name="ticket" className="size-6" />
</div>
<div className="space-y-1 max-w-md">
<p className="text-xs font-bold text-foreground">
{t('billing.ticketUploadPrompt')}
</p>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t('billing.ticketDragDropPrompt')}
</p>
<p className="text-[10px] text-muted-foreground/70 font-medium">
{t('billing.ticketSupportedFormats')}
</p>
</div>
<Button
variant="default"
size="sm"
disabled={isUploadingTicket}
isLoading={isUploadingTicket}
loadingText={t('billing.ticketUploading')}
className="rounded-xl h-8 px-4 text-xs font-bold gap-1.5 bg-primary text-primary-foreground hover:brightness-105 shadow-xs"
onClick={() => ticketFileInputRef.current?.click()}
>
<Ic name="upload" className="size-3.5" />
<span>{t('billing.uploadTicketBtn')}</span>
</Button>
{!isApproved && (
<div className="flex items-center gap-2 p-2.5 rounded-xl border border-amber-500/20 bg-amber-500/10 text-[11px] text-amber-700 dark:text-amber-400 font-medium max-w-md text-start">
<Ic name="ticket" className="size-4 text-amber-500 shrink-0" />
<span>{t('billing.ticketAutoApproveNotice')}</span>
</div>
)}
</div>
)}
</CardContent>
</Card>
)}
{/* Bank Transaction Receipt Card */}
<Card className="border-border/70 rounded-2xl shadow-xs overflow-hidden">
<CardHeader className="py-3.5 px-5 border-b border-border/60">
<CardTitle className="text-sm font-bold flex items-center gap-2 text-foreground">
<Ic name="receipt" className="size-4.5 text-primary" />
<span>فیش واریزی و رسید پرداخت بانکی</span>
</CardTitle>
</CardHeader>
<CardContent className="p-5">
{factor.transaction_receipt ? (
<div className="flex flex-col sm:flex-row items-center gap-4 bg-muted/20 p-4 rounded-xl border border-border/50">
<div
onClick={() => setReceiptLightboxOpen(true)}
className="relative cursor-pointer group overflow-hidden rounded-xl border border-border/60 size-28 sm:size-32 shrink-0 bg-muted/40 shadow-xs flex items-center justify-center hover:border-primary/50 transition-colors"
title="مشاهده در اندازه بزرگ"
>
<img
src={resolveMediaUrl(factor.transaction_receipt)}
alt={`فیش صورت‌حساب #${factor.id}`}
className="size-full object-cover group-hover:scale-105 transition-transform"
onError={(e) => {
const el = e.currentTarget
if (!el.dataset.fallbackApplied && factor.transaction_receipt) {
el.dataset.fallbackApplied = 'true'
const fallback = getMediaFallbackUrl(factor.transaction_receipt)
if (fallback && el.src !== fallback) {
el.src = fallback
return
}
}
el.style.display = 'none'
setReceiptError(true)
const fallback = el.nextElementSibling as HTMLElement
if (fallback) fallback.style.display = 'flex'
}}
/>
<div
style={{ display: 'none' }}
className="size-full flex flex-col items-center justify-center text-muted-foreground p-2 text-center"
>
<Ic name="alert" className="size-6 text-amber-500 mb-1" />
<span className="text-[10px] font-semibold text-amber-600 dark:text-amber-400">
عدم لود تصویر
</span>
</div>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 flex items-center justify-center text-white transition-opacity text-xs font-medium">
<Ic name="eye" className="size-5" />
</div>
</div>
<div className="space-y-2 text-xs flex-1">
{receiptError ? (
<>
<p className="font-bold text-destructive flex items-center gap-1.5">
<Ic name="alert" className="size-3.5" />
<span>فایل تصویر رسید در سرور یافت نشد یا آسیب دیده است</span>
</p>
<p className="text-muted-foreground text-[11px] leading-relaxed">
آدرس فیش در پایگاه داده ثبت شده، اما فایل فیزیکی در سرور در دسترس نیست. جهت
جلوگیری از سردرگمی می‌توانید فیش نامعتبر را پاکسازی کنید.
</p>
</>
) : (
<>
<p className="font-bold text-foreground">فیش بانکی توسط مشتری در اپلیکیشن آپلود شده است.</p>
<p className="text-muted-foreground text-[11px] leading-relaxed">
جهت بررسی صحت اطلاعات و انطباق با واریز حساب، می‌توانید تصویر فیش را در سایز
کامل باز کرده و جزئیات آن را بررسی نمایید.
</p>
</>
)}
<div className="flex flex-wrap items-center gap-2 pt-1">
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs rounded-xl border-border-soft hover:bg-muted"
onClick={() => setReceiptLightboxOpen(true)}
>
<Ic name="eye" className="size-3.5 text-primary" />
<span>مشاهده در اندازه بزرگ</span>
</Button>
<Button
variant="outline"
size="sm"
disabled={isClearingReceipt}
className="h-8 gap-1.5 text-xs rounded-xl border-destructive/30 text-destructive hover:bg-destructive/10"
onClick={handleClearReceipt}
>
<Ic name="trash" className="size-3.5" />
<span>
{isClearingReceipt
? 'در حال پاکسازی...'
: receiptError
? 'پاکسازی فیش نامعتبر'
: 'حذف فیش'}
</span>
</Button>
</div>
</div>
</div>
) : (
<div className="flex items-center gap-3.5 p-4 rounded-xl border border-amber-500/30 bg-amber-500/10 text-foreground">
<div className="size-9 rounded-xl bg-amber-500/20 text-amber-700 dark:text-amber-400 flex items-center justify-center shrink-0">
<Ic name="alert" className="size-5" />
</div>
<div className="space-y-0.5">
<p className="text-xs font-bold text-amber-950 dark:text-amber-200">
عدم بارگذاری فیش واریزی
</p>
<p className="text-xs text-foreground/80 font-medium leading-relaxed">
هنوز تصویر فیش واریزی توسط مشتری در سامانه بارگذاری نشده است (صورت‌حساب در انتظار
پرداخت می‌باشد).
</p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
{/* Sidebar Column (4 cols): User Dossier, Financial Pricing, Status Management */}
<div className="xl:col-span-4 space-y-6">
{/* Customer Dossier Card */}
<Card className="border-border/70 rounded-2xl shadow-xs overflow-hidden">
<CardHeader className="py-3.5 px-5 border-b border-border/60">
<CardTitle className="text-sm font-bold flex items-center gap-2 text-foreground">
<Ic name="user" className="size-4.5 text-primary" />
<span>مشخصات خریدار / مسافر</span>
</CardTitle>
</CardHeader>
<CardContent className="p-5 space-y-4">
<div className="flex items-center gap-3">
<Avatar className="size-11 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center font-bold text-primary text-sm">
{user?.fullname ? user.fullname.slice(0, 1) : <Ic name="user" className="size-5" />}
</Avatar>
<div className="min-w-0 flex-1">
<p className="font-bold text-sm text-foreground truncate">
{user?.fullname || 'نام ثبت نشده'}
</p>
<p className="text-xs text-muted-foreground truncate font-mono dir-ltr text-start">
{user?.email || '—'}
</p>
</div>
</div>
<div className="pt-2 border-t border-border/50 space-y-2 text-xs">
<div className="flex justify-between items-center">
<span className="text-muted-foreground">شماره تماس:</span>
<span className="font-mono dir-ltr font-semibold text-foreground">
{user?.phone_number || '—'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">شناسه کاربری:</span>
<span className="font-mono font-medium">{user?.id ?? '—'}</span>
</div>
</div>
{user?.id && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => navigate(`/users/${user.id}`)}
className="w-full rounded-xl text-xs font-semibold h-8.5 gap-1.5 border-border/70 hover:bg-muted cursor-pointer"
>
<Ic name="externalLink" className="size-3.5" />
<span>مشاهده پرونده کامل کاربر</span>
</Button>
)}
</CardContent>
</Card>
{/* Financial Dossier Card */}
<Card className="border-border/70 rounded-2xl shadow-xs overflow-hidden">
<CardHeader className="py-3.5 px-5 border-b border-border/60">
<CardTitle className="text-sm font-bold flex items-center gap-2 text-foreground">
<Ic name="creditCard" className="size-4.5 text-primary" />
<span>مشخصات مالی و پرداخت</span>
</CardTitle>
</CardHeader>
<CardContent className="p-5 space-y-3.5">
<div className="flex items-center justify-between pb-3 border-b border-border/50">
<span className="text-xs text-muted-foreground font-medium">مبلغ کل فاکتور:</span>
<span className="text-xl font-black text-primary font-mono">
${Number(factor.amount).toLocaleString()}
</span>
</div>
{factor.card_number && (
<div className="bg-muted/30 p-2.5 rounded-xl border border-border/50 flex items-center justify-between text-xs">
<div className="flex flex-col">
<span className="text-[10px] text-muted-foreground">شماره کارت بانکی مقصد:</span>
<span className="font-mono font-bold tracking-wider dir-ltr text-foreground">
{factor.card_number}
</span>
</div>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-foreground"
onClick={() => handleCopyCard(factor.card_number!)}
title="کپی شماره کارت"
>
<Ic name={copiedCard ? 'check' : 'clipboardCheck'} className="size-3.5" />
</Button>
</div>
)}
<div className="space-y-2 text-xs">
{factor.factor_number && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">{t('billing.factorNumber')}:</span>
<div className="flex items-center gap-1.5">
<span className="font-mono font-bold text-primary bg-primary/10 px-2 py-0.5 rounded-md">
#{factor.factor_number}
</span>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => handleCopyFactorNumber(factor.factor_number!)}
title="کپی شماره فاکتور"
>
<Ic name={copiedFactorNumber ? 'check' : 'clipboardCheck'} className="size-3" />
</Button>
</div>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">{t('billing.id')}:</span>
<span className="font-mono text-muted-foreground">#{factor.id}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">شناسه سفارش مرجع:</span>
<span className="font-mono font-medium">
{factor.order_id || factor.object_id}
</span>
</div>
{factor.description && (
<div className="flex justify-between gap-2 pt-1 border-t border-border/40">
<span className="text-muted-foreground shrink-0">توضیحات:</span>
<span className="text-foreground text-end">{factor.description}</span>
</div>
)}
</div>
</CardContent>
</Card>
{/* Status & Review Controller Card */}
<Card className="border-border/70 rounded-2xl shadow-xs overflow-hidden">
<CardHeader className="py-3.5 px-5 border-b border-border/60">
<CardTitle className="text-sm font-bold flex items-center gap-2 text-foreground">
<Ic name="sliders" className="size-4.5 text-primary" />
<span>مدیریت وضعیت فاکتور</span>
</CardTitle>
</CardHeader>
<CardContent className="p-5 space-y-4">
<div>
<label className="text-xs font-bold text-foreground block mb-1.5">
تغییر وضعیت فاکتور:
</label>
<Select
value={selectedStatus}
onValueChange={(val) => {
const newStatus = val as FactorStatus
setSelectedStatus(newStatus)
setHasChanges(newStatus !== factor.status)
}}
>
<SelectTrigger className="h-10 px-3.5 text-xs font-black rounded-xl border border-border/80 bg-background shadow-2xs hover:border-primary/50 transition-all">
<SelectValue placeholder="انتخاب وضعیت...">
{(() => {
const curOpt = STATUS_OPTIONS.find((o) => o.value === selectedStatus)
if (!curOpt) return selectedStatus
return (
<div className="flex items-center gap-2">
<span className={cn('size-2.5 rounded-full shrink-0', curOpt.dotClass)} />
<span className="truncate font-black">{curOpt.label}</span>
</div>
)
})()}
</SelectValue>
</SelectTrigger>
<SelectContent className="rounded-xl border-border-soft bg-card shadow-2xl p-1 z-50">
{STATUS_OPTIONS.map((opt) => (
<SelectItem
key={opt.value}
value={opt.value}
className="rounded-lg text-xs font-bold py-2.5 px-3 gap-2 cursor-pointer hover:bg-muted/70"
>
<div className="flex items-center gap-2.5">
<span className={cn('size-2.5 rounded-full shrink-0', opt.dotClass)} />
<span className="font-bold">{opt.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Warning if approved without ticket */}
{isApprovedSelectedWithoutTicket && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-xl p-3 flex items-start gap-2.5 text-xs text-amber-700 dark:text-amber-400">
<Ic name="alert" className="size-4 shrink-0 mt-0.5 text-amber-500" />
<p className="leading-relaxed">
{t('billing.ticketRequiredForApprovedDesc')}
</p>
</div>
)}
{/* Rejection reason textarea if rejected */}
{selectedStatus === 'rejected' && (
<div className="bg-rose-500/10 border border-rose-500/25 rounded-xl p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="font-bold text-xs text-rose-700 dark:text-rose-300">
دلیل رد صورت‌حساب (الزامی):
</span>
<span className="text-[10px] text-rose-600/80">نمایش به زائر</span>
</div>
<Textarea
rows={3}
value={rejectReason}
onChange={(e) => {
setRejectReason(e.target.value)
setHasChanges(true)
}}
placeholder="علت رد فاکتور را حتماً وارد نمایید (مثال: فیش ناخوانا است، مبلغ مغایرت دارد و...)"
className={cn(
'text-xs bg-background/90 rounded-xl resize-none min-h-[70px]',
!rejectReason.trim()
? 'border-rose-500/60 focus:ring-rose-500'
: 'border-border/60'
)}
/>
</div>
)}
{/* Save changes button */}
<Button
type="button"
variant="default"
disabled={
isSaving ||
(selectedStatus === 'rejected' && !rejectReason.trim()) ||
isApprovedSelectedWithoutTicket
}
isLoading={isSaving}
loadingText="در حال ذخیره..."
onClick={handleSaveStatus}
className={cn(
'w-full rounded-xl text-xs font-bold h-10 shadow-xs gap-2 transition-all',
hasChanges
? 'bg-primary text-primary-foreground hover:brightness-110 ring-2 ring-primary/30'
: 'bg-primary text-primary-foreground hover:brightness-105'
)}
>
<Ic name="check" className="size-4" />
<span>
{hasChanges
? selectedStatus === 'approved'
? t('billing.confirmAndSave')
: t('billing.saveStatusChanges')
: factor.status === 'approved'
? t('billing.reSubmitStatus')
: t('billing.submitStatus')}
</span>
</Button>
{/* Cancel Invoice button */}
{factor.status === 'awaiting_payment' && !factor.transaction_receipt && (
<Button
type="button"
variant="outline"
disabled={isSaving}
onClick={handleCancelFactor}
className="w-full rounded-xl text-xs font-bold h-9 border-rose-500/40 text-rose-600 dark:text-rose-400 hover:bg-rose-500/10 gap-1.5 cursor-pointer"
>
<Ic name="x" className="size-3.5" />
<span>{t('billing.cancelInvoice')}</span>
</Button>
)}
</CardContent>
</Card>
</div>
</div>
{/* Lightbox Dialog */}
<ReceiptLightboxDialog
factor={factor}
open={receiptLightboxOpen}
onClose={() => setReceiptLightboxOpen(false)}
onClearReceipt={handleClearReceipt}
/>
</div>
)
}