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.
783 lines
39 KiB
783 lines
39 KiB
import React, { useState, useEffect, useRef } from 'react'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Avatar } from '@/components/ui/avatar'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import { Ic } from '@/icons'
|
|
import type { FactorItem, FactorStatus } from '../types'
|
|
import { BillingServiceBadge } from './BillingServiceBadge'
|
|
import { fetchFactorDetail, reviewFactor } from '../services/billing-api'
|
|
import { ShimmerLine, ShimmerBox } from '@/components/shared/shimmer_frontend'
|
|
import { toast } from '@/stores/toast'
|
|
|
|
import { useTranslation } from '@/i18n'
|
|
import { formatDateByLanguage, formatDateTimeByLanguage } from '@/lib/date-utils'
|
|
import { cn, resolveMediaUrl, getMediaFallbackUrl } from '@/lib/utils'
|
|
|
|
interface Props {
|
|
factor: FactorItem | null
|
|
open: boolean
|
|
onClose: () => void
|
|
onFactorUpdated?: (updatedFactor: FactorItem) => void
|
|
onApprove?: (factorId: number) => Promise<void>
|
|
onOpenRejectDialog?: (factor: FactorItem) => void
|
|
onOpenReceiptLightbox: (factor: FactorItem) => void
|
|
onClearReceipt?: (factorId: number) => Promise<void>
|
|
isUpdating?: boolean
|
|
}
|
|
|
|
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-400',
|
|
},
|
|
]
|
|
|
|
export function BillingDetailModal({
|
|
factor,
|
|
open,
|
|
onClose,
|
|
onFactorUpdated,
|
|
onOpenReceiptLightbox,
|
|
onClearReceipt,
|
|
isUpdating,
|
|
}: Props) {
|
|
const { t, language } = useTranslation()
|
|
const [copiedCard, setCopiedCard] = useState(false)
|
|
const [currentFactor, setCurrentFactor] = useState<FactorItem | null>(factor)
|
|
const [selectedStatus, setSelectedStatus] = useState<FactorStatus>(factor?.status || 'awaiting_payment')
|
|
const [rejectReason, setRejectReason] = useState<string>(factor?.rejected_description || '')
|
|
const [isLoadingDetail, setIsLoadingDetail] = useState(false)
|
|
const [isSaving, setIsSaving] = useState(false)
|
|
const [hasChanges, setHasChanges] = useState(false)
|
|
const [receiptError, setReceiptError] = useState(false)
|
|
const [isClearingReceipt, setIsClearingReceipt] = useState(false)
|
|
const lastLoadedFactorIdRef = useRef<number | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!open || !factor) {
|
|
lastLoadedFactorIdRef.current = null
|
|
return
|
|
}
|
|
|
|
// مقداردهی اولیه وضعیت و فیلدها منحصراً در زمان باز شدن مودال یا تغییر شناسه صورتحساب
|
|
if (lastLoadedFactorIdRef.current !== factor.id) {
|
|
lastLoadedFactorIdRef.current = factor.id
|
|
setCurrentFactor(factor)
|
|
setSelectedStatus(factor.status)
|
|
setRejectReason(factor.rejected_description || '')
|
|
setHasChanges(false)
|
|
setReceiptError(false)
|
|
|
|
if (!factor.detail_service) {
|
|
setIsLoadingDetail(true)
|
|
fetchFactorDetail(factor.id)
|
|
.then((res) => {
|
|
// بروزرسانی جزئیات بدون بازنویسی وضعیت تغییریافته توسط کاربر (جلوگیری قطعی از برگشت به حالت رد شده)
|
|
setCurrentFactor((prev) => (prev ? { ...prev, ...res } : res))
|
|
})
|
|
.catch((err) => {
|
|
console.error('Failed to load factor details:', err)
|
|
})
|
|
.finally(() => {
|
|
setIsLoadingDetail(false)
|
|
})
|
|
}
|
|
}
|
|
}, [factor?.id, open])
|
|
|
|
const activeFactor = currentFactor || factor
|
|
if (!factor || !activeFactor) return null
|
|
|
|
const handleCopyCard = (cardNumber: string) => {
|
|
navigator.clipboard.writeText(cardNumber)
|
|
setCopiedCard(true)
|
|
setTimeout(() => setCopiedCard(false), 2000)
|
|
}
|
|
|
|
const handleSaveStatus = async () => {
|
|
if (!activeFactor) return
|
|
if (selectedStatus === 'rejected' && !rejectReason.trim()) {
|
|
toast.error('وارد کردن دلیل رد صورتحساب الزامی است.', 'دلیل رد الزامی است')
|
|
return
|
|
}
|
|
setIsSaving(true)
|
|
try {
|
|
const payload = {
|
|
status: selectedStatus,
|
|
rejected_description: selectedStatus === 'rejected' ? rejectReason.trim() : undefined,
|
|
}
|
|
const updated = await reviewFactor(activeFactor.id, payload)
|
|
setCurrentFactor(updated)
|
|
setSelectedStatus(updated.status)
|
|
setRejectReason(updated.rejected_description || '')
|
|
setHasChanges(false)
|
|
if (onFactorUpdated) {
|
|
onFactorUpdated(updated)
|
|
}
|
|
const statusLabel =
|
|
STATUS_OPTIONS.find((s) => s.value === selectedStatus)?.label || selectedStatus
|
|
toast.success(
|
|
`وضعیت صورتحساب #${activeFactor.id} با موفقیت به «${statusLabel}» تغییر یافت.`,
|
|
'بروزرسانی وضعیت'
|
|
)
|
|
onClose()
|
|
} catch (err: any) {
|
|
toast.error(err?.message || 'خطا در ذخیرهسازی وضعیت صورتحساب.', 'خطا در عملیات')
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}
|
|
|
|
const detail = activeFactor.detail_service
|
|
const user = activeFactor.user_info
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(val) => !val && onClose()}>
|
|
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col border border-border/60 bg-card p-0 overflow-hidden shadow-2xl rounded-2xl">
|
|
{/* هدر مودال ثابت با پسزمینه سالید بدون تداخل اسکرول */}
|
|
<DialogHeader className="p-4 px-5 border-b border-border/40 bg-card shrink-0 z-10">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="size-10 rounded-xl bg-primary/15 text-primary flex items-center justify-center font-bold shrink-0">
|
|
<Ic name="receipt" className="size-5" />
|
|
</div>
|
|
<div>
|
|
<DialogTitle className="text-lg font-bold flex items-center gap-2">
|
|
<span>صورتحساب شماره #{activeFactor.id}</span>
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
|
|
ثبت شده در: {formatDateTimeByLanguage(activeFactor.created_at, language as any)}
|
|
</DialogDescription>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2.5">
|
|
<BillingServiceBadge service={activeFactor.service} />
|
|
|
|
{/* دراپداون تعاملی انتخاب وضعیت صورتحساب و دکمه ذخیره سریع در هدر */}
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative">
|
|
<Select
|
|
value={selectedStatus}
|
|
onValueChange={(val) => {
|
|
const newStatus = val as FactorStatus
|
|
setSelectedStatus(newStatus)
|
|
setHasChanges(newStatus !== activeFactor.status)
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-8.5 px-3 min-w-[175px] text-xs font-bold rounded-xl border-border-soft bg-card shadow-2xs hover:bg-muted/40 transition-colors">
|
|
<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 rounded-full shrink-0', curOpt.dotClass)} />
|
|
<span className="truncate">{curOpt.label}</span>
|
|
</div>
|
|
)
|
|
})()}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent className="rounded-xl border-border-soft bg-card shadow-xl p-1 z-50">
|
|
{STATUS_OPTIONS.map((opt) => (
|
|
<SelectItem
|
|
key={opt.value}
|
|
value={opt.value}
|
|
className="rounded-lg text-xs font-medium py-2 gap-2 cursor-pointer"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className={cn('size-2 rounded-full shrink-0', opt.dotClass)} />
|
|
<span>{opt.label}</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* دکمه ذخیره وضعیت سریع در کنار منو برای جلوگیری از سردرگمی کاربر */}
|
|
{hasChanges && (
|
|
<Button
|
|
size="sm"
|
|
disabled={isSaving || isUpdating || (selectedStatus === 'rejected' && !rejectReason.trim())}
|
|
isLoading={isSaving || isUpdating}
|
|
onClick={handleSaveStatus}
|
|
className="h-8.5 px-3.5 text-xs font-bold gap-1 rounded-xl bg-primary text-primary-foreground shadow-xs hover:brightness-110 animate-in fade-in zoom-in-95 duration-150 shrink-0"
|
|
title="ذخیره وضعیت انتخابشده"
|
|
>
|
|
<Ic name="check" className="size-3.5" />
|
|
<span>ذخیره وضعیت</span>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
{/* بدنه محتوا با اسکرول داخلی مجزا */}
|
|
<div className="p-5 space-y-5 flex-1 overflow-y-auto">
|
|
{/* کادر ویرایش/مشاهده دلیل رد صورتحساب در صورت انتخاب وضعیت رد شده */}
|
|
{selectedStatus === 'rejected' && (
|
|
<div className="bg-rose-500/10 border border-rose-500/25 rounded-2xl p-4 space-y-2.5 text-foreground">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<div className="size-6 rounded-lg bg-rose-500/20 text-rose-600 dark:text-rose-400 flex items-center justify-center shrink-0">
|
|
<Ic name="xCircle" className="size-3.5" />
|
|
</div>
|
|
<h4 className="font-bold text-xs text-rose-700 dark:text-rose-300 flex items-center gap-1.5">
|
|
<span>دلیل رد صورتحساب:</span>
|
|
<span className="text-[11px] font-bold text-rose-600 dark:text-rose-400 bg-rose-500/15 px-2 py-0.5 rounded-md">
|
|
(الزامی)
|
|
</span>
|
|
</h4>
|
|
</div>
|
|
<span className="text-[10px] text-rose-600/80 font-medium">نمایش برای مسافر در اپلیکیشن</span>
|
|
</div>
|
|
<Textarea
|
|
value={rejectReason}
|
|
onChange={(e) => {
|
|
setRejectReason(e.target.value)
|
|
setHasChanges(true)
|
|
}}
|
|
placeholder="علت رد فاکتور را حتماً وارد نمایید (مثال: فیش ناخوانا است، مبلغ واریزی مغایرت دارد، تصویر جعلی است و...)"
|
|
className={cn(
|
|
'text-xs bg-background/90 rounded-xl resize-none min-h-[76px] transition-colors',
|
|
!rejectReason.trim()
|
|
? 'border-rose-500/50 focus:border-rose-500 focus:ring-1 focus:ring-rose-500/30'
|
|
: 'border-rose-500/25 focus:border-rose-500/50'
|
|
)}
|
|
/>
|
|
{!rejectReason.trim() && (
|
|
<p className="text-[11px] text-rose-600 dark:text-rose-400 font-medium flex items-center gap-1.5 pt-0.5">
|
|
<Ic name="alert" className="size-3.5 shrink-0" />
|
|
<span>برای ثبت وضعیت «رد شده»، وارد کردن توضیح و دلیل الزامی است.</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* کارت وضعیت انقضای قبلی صورتحساب */}
|
|
{activeFactor.status === 'expired' && selectedStatus === 'expired' && (
|
|
<div className="bg-slate-100/80 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700/60 rounded-2xl p-4 flex items-start gap-3.5">
|
|
<div className="size-8 rounded-xl bg-slate-200 dark:bg-slate-700 text-slate-800 dark:text-slate-100 flex items-center justify-center shrink-0 mt-0.5">
|
|
<Ic name="clock" className="size-4" />
|
|
</div>
|
|
<div className="space-y-1 flex-1">
|
|
<h4 className="font-bold text-xs text-slate-900 dark:text-slate-100">دلیل انقضای صورتحساب:</h4>
|
|
<p className="text-xs text-slate-600 dark:text-slate-300 leading-relaxed font-medium">
|
|
{activeFactor.rejected_description || 'انقضای مهلت پرداخت (سپری شدن مهلت ۷۲ ساعته)'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* اطلاعرسانی تمدید خودکار مهلت ۳ روزه در صورت فعالسازی مجدد فاکتور منقضی شده یا رد شده */}
|
|
{(activeFactor.status === 'expired' || activeFactor.status === 'rejected') && (selectedStatus === 'awaiting_payment' || selectedStatus === 'pending') && (
|
|
<div className="bg-slate-800 border border-amber-500/40 rounded-2xl p-4 flex items-start gap-3.5 shadow-md">
|
|
<div className="size-8 rounded-xl bg-amber-500/20 text-amber-400 flex items-center justify-center shrink-0 mt-0.5">
|
|
<Ic name="clock" className="size-4" />
|
|
</div>
|
|
<div className="space-y-1 flex-1 text-xs">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<h4 className="font-bold text-white">
|
|
تمدید مجدد مهلت پرداخت صورتحساب:
|
|
</h4>
|
|
<span className="text-[11px] font-bold text-amber-300 bg-amber-500/20 border border-amber-500/30 px-2 py-0.5 rounded-md">
|
|
+۳ روز مهلت جدید
|
|
</span>
|
|
</div>
|
|
<p className="text-slate-200 leading-relaxed font-normal">
|
|
با ثبت وضعیت <strong className="font-bold text-amber-300">«{selectedStatus === 'awaiting_payment' ? 'در انتظار پرداخت' : 'در انتظار بررسی فیش'}»</strong>، این صورتحساب مجدداً فعال شده و یک مهلت پرداخت ۳ روزهٔ جدید (۷۲ ساعت از تاریخ امروز) برای مسافر در نظر گرفته میشود.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeFactor.expiration_date && (
|
|
<div className="bg-muted/50 border border-border-soft rounded-xl p-3 flex items-center justify-between text-xs">
|
|
<span className="text-foreground/80 font-medium flex items-center gap-1.5">
|
|
<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(activeFactor.expiration_date, language as any)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* اطلاعات خریدار و اطلاعات مالی در ۲ ستون */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{/* کارت مشخصات خریدار */}
|
|
<Card className="border-border/50 shadow-xs">
|
|
<CardHeader className="py-3 px-4 border-b border-border/40">
|
|
<CardTitle className="text-xs font-bold text-foreground flex items-center gap-2">
|
|
<Ic name="users" className="size-4 text-primary" />
|
|
<span>اطلاعات مشتری</span>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-4 space-y-3">
|
|
<div className="flex items-center gap-3">
|
|
<Avatar className="size-11 shrink-0" iconClassName="size-5.5" />
|
|
<div className="min-w-0">
|
|
<p className="font-bold text-sm truncate text-foreground">
|
|
{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/40 space-y-1.5 text-xs">
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">شماره تماس:</span>
|
|
<span className="font-mono dir-ltr font-medium text-foreground">
|
|
{user?.phone_number || '—'}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">شناسه کاربری:</span>
|
|
<span className="font-mono font-medium">{user?.id ?? '—'}</span>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* کارت مشخصات مالی فاکتور */}
|
|
<Card className="border-border/50 shadow-xs">
|
|
<CardHeader className="py-3 px-4 border-b border-border/40">
|
|
<CardTitle className="text-xs font-bold text-foreground flex items-center gap-2">
|
|
<Ic name="creditCard" className="size-4 text-primary" />
|
|
<span>مشخصات مالی و واریز</span>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs text-muted-foreground">مبلغ کل فاکتور:</span>
|
|
<span className="text-lg font-black text-primary font-mono">
|
|
${Number(activeFactor.amount).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
|
|
{activeFactor.card_number && (
|
|
<div className="bg-muted/40 p-2.5 rounded-lg border border-border/40 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">
|
|
{activeFactor.card_number}
|
|
</span>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-7 text-muted-foreground hover:text-foreground"
|
|
onClick={() => handleCopyCard(activeFactor.card_number!)}
|
|
title="کپی شماره کارت"
|
|
>
|
|
<Ic name={copiedCard ? 'check' : 'clipboardCheck'} className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="pt-2 border-t border-border/40 space-y-1 text-xs">
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">شناسه سفارش مرجع:</span>
|
|
<span className="font-mono font-medium">
|
|
{activeFactor.order_id || activeFactor.object_id}
|
|
</span>
|
|
</div>
|
|
{activeFactor.description && (
|
|
<div className="flex justify-between gap-2">
|
|
<span className="text-muted-foreground shrink-0">توضیحات:</span>
|
|
<span className="text-foreground text-end truncate">{activeFactor.description}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* جزئیات سرویس چندشکلی (Polymorphic Details) */}
|
|
<Card className="border-border/50 shadow-xs">
|
|
<CardHeader className="py-3 px-4 border-b border-border/40 bg-muted/20">
|
|
<CardTitle className="text-xs font-bold flex items-center gap-2">
|
|
<Ic name="list" className="size-4 text-primary" />
|
|
<span>ریز اقلام و مشخصات سرویس ({activeFactor.service.toUpperCase()})</span>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-4 text-xs space-y-4">
|
|
{isLoadingDetail ? (
|
|
<div className="space-y-3 py-2">
|
|
<ShimmerLine width="50%" height={16} rounded="md" />
|
|
<ShimmerBox width="100%" height={44} rounded="xl" />
|
|
<ShimmerBox width="100%" height={44} rounded="xl" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* ۱. تور */}
|
|
{activeFactor.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-3">
|
|
<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-soft">
|
|
<span className="font-bold text-foreground">
|
|
بزرگسال ({detail.passenger_data.adults?.count} نفر):
|
|
</span>
|
|
<span className="ms-2 text-muted-foreground">
|
|
{detail.passenger_data.adults?.names.join('، ')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* کودکان */}
|
|
{(detail.passenger_data.children?.count ?? 0) > 0 && (
|
|
<div className="bg-muted/40 p-2.5 rounded-xl border border-border-soft">
|
|
<span className="font-bold text-foreground">
|
|
کودک ({detail.passenger_data.children?.count} نفر):
|
|
</span>
|
|
<span className="ms-2 text-muted-foreground">
|
|
{detail.passenger_data.children?.names.join('، ')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* نوزادان */}
|
|
{(detail.passenger_data.infants?.count ?? 0) > 0 && (
|
|
<div className="bg-muted/40 p-2.5 rounded-xl border border-border-soft">
|
|
<span className="font-bold text-foreground">
|
|
نوزاد ({detail.passenger_data.infants?.count} نفر):
|
|
</span>
|
|
<span className="ms-2 text-muted-foreground">
|
|
{detail.passenger_data.infants?.names.join('، ')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p className="text-muted-foreground">اطلاعات مسافران ثبت نشده است.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ۲. سیمکارت */}
|
|
{activeFactor.service === 'simcard' && (
|
|
<div className="space-y-3">
|
|
<div className="divide-y divide-border/40">
|
|
{detail?.items?.map((item, idx) => (
|
|
<div key={idx} className="py-2 flex items-center justify-between">
|
|
<div>
|
|
<span className="font-medium text-foreground">
|
|
{item.simcard_title || `سیمکارت کد ${item.simcard_id}`}
|
|
</span>
|
|
<span className="ms-2 text-muted-foreground">
|
|
(تعداد: {item.quantity})
|
|
</span>
|
|
</div>
|
|
<span className="font-bold font-mono">
|
|
${Number(item.cost).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{detail?.total_cost && (
|
|
<div className="flex justify-between pt-2 border-t border-border/40 font-bold">
|
|
<span>هزینه کل اقلام:</span>
|
|
<span className="text-primary font-mono">${detail.total_cost}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ۳. فروشگاه */}
|
|
{activeFactor.service === 'shop' && (
|
|
<div className="space-y-3">
|
|
<div className="divide-y divide-border/40">
|
|
{detail?.items?.map((item, idx) => (
|
|
<div key={idx} className="py-2 flex items-center justify-between">
|
|
<div>
|
|
<span className="font-medium text-foreground">
|
|
{item.product_slug || `محصول #${item.product_id}`}
|
|
</span>
|
|
<span className="ms-2 text-muted-foreground">
|
|
({item.quantity} عدد × ${item.price})
|
|
</span>
|
|
</div>
|
|
<span className="font-bold font-mono">
|
|
${Number(item.cost).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{detail?.total_cost && (
|
|
<div className="flex justify-between pt-2 border-t border-border/40 font-bold">
|
|
<span>جمع اقلام سبد خرید:</span>
|
|
<span className="text-primary font-mono">${detail.total_cost}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ۴. صرافی / تسریف */}
|
|
{activeFactor.service === 'tasrif' && (
|
|
<div className="space-y-2 bg-muted/30 p-3 rounded-lg border border-border/40">
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">تبدیل ارز:</span>
|
|
<span className="font-bold text-foreground">
|
|
از {detail?.from_currency} به {detail?.to_currency}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">مبلغ درخواستی:</span>
|
|
<span className="font-mono font-medium">{detail?.amount}</span>
|
|
</div>
|
|
<div className="flex justify-between pt-2 border-border/40 font-bold">
|
|
<span>مبلغ نهایی معادل:</span>
|
|
<span className="text-emerald-600 dark:text-emerald-400 font-mono text-sm">
|
|
${detail?.total_price}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ۵. سفر اختصاصی (Custom Trip) */}
|
|
{activeFactor.service === 'custom_trip' && (
|
|
<div className="space-y-3">
|
|
{detail?.destinations && detail.destinations.length > 0 && (
|
|
<div>
|
|
<span className="text-muted-foreground block mb-1">مسیر سفر:</span>
|
|
<div className="flex flex-wrap gap-2">
|
|
{detail.destinations.map((d, i) => (
|
|
<span
|
|
key={i}
|
|
className="bg-primary/10 text-primary px-2.5 py-1 rounded-md text-xs font-semibold"
|
|
>
|
|
{d}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{detail?.detail && (
|
|
<div className="bg-muted/30 p-3 rounded-lg border border-border/40">
|
|
<span className="text-muted-foreground block mb-1">خلاصه برنامه سفر:</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>
|
|
)}
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* کارت تصویر فیش پرداختی آپلود شده توسط کاربر */}
|
|
<Card className="border-border/50 shadow-xs">
|
|
<CardHeader className="py-3 px-4 border-b border-border/40">
|
|
<CardTitle className="text-xs font-bold flex items-center gap-2">
|
|
<Ic name="receipt" className="size-4 text-primary" />
|
|
<span>فیش واریزی و رسید بانکی</span>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-4">
|
|
{activeFactor.transaction_receipt ? (
|
|
<div className="flex flex-col sm:flex-row items-center gap-4 bg-muted/20 p-3 rounded-xl border border-border/40">
|
|
<div
|
|
onClick={() => onOpenReceiptLightbox(activeFactor)}
|
|
className="relative cursor-pointer group overflow-hidden rounded-xl border border-border/60 size-28 sm:size-32 shrink-0 bg-muted/40 shadow-2xs flex items-center justify-center hover:border-primary/50 transition-colors"
|
|
title="مشاهده در اندازه بزرگ"
|
|
>
|
|
<img
|
|
src={resolveMediaUrl(activeFactor.transaction_receipt)}
|
|
alt={`فیش صورتحساب #${activeFactor.id}`}
|
|
className="size-full object-cover group-hover:scale-105 transition-transform"
|
|
onError={(e) => {
|
|
const el = e.currentTarget
|
|
if (!el.dataset.fallbackApplied && activeFactor.transaction_receipt) {
|
|
el.dataset.fallbackApplied = 'true'
|
|
const fallback = getMediaFallbackUrl(activeFactor.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 shadow-2xs border-border-soft hover:bg-muted/60"
|
|
onClick={() => onOpenReceiptLightbox(activeFactor)}
|
|
>
|
|
<Ic name="eye" className="size-3.5 text-primary" />
|
|
<span>مشاهده در اندازه بزرگ</span>
|
|
</Button>
|
|
|
|
{onClearReceipt && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={isClearingReceipt}
|
|
className="h-8 gap-1.5 text-xs rounded-xl shadow-2xs border-destructive/30 text-destructive hover:bg-destructive/10"
|
|
onClick={async () => {
|
|
setIsClearingReceipt(true)
|
|
try {
|
|
await onClearReceipt(activeFactor.id)
|
|
setCurrentFactor((prev) => (prev ? { ...prev, transaction_receipt: null } : null))
|
|
setReceiptError(false)
|
|
} finally {
|
|
setIsClearingReceipt(false)
|
|
}
|
|
}}
|
|
>
|
|
<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>
|
|
|
|
{/* فوتر مودال و دکمههای عملیاتی: ثابت و بدون تداخل با اسکرول */}
|
|
<DialogFooter className="p-4 px-5 border-t border-border-soft bg-card shrink-0 flex flex-row items-center justify-between w-full z-10">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onClose}
|
|
className="rounded-xl h-9 px-4 text-xs font-bold border-border-soft hover:bg-muted/50"
|
|
>
|
|
{t('common.close')}
|
|
</Button>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
disabled={isSaving || isUpdating || (selectedStatus === 'rejected' && !rejectReason.trim())}
|
|
className={cn(
|
|
'rounded-xl h-9 px-5 text-xs font-bold gap-1.5 shadow-xs transition-all',
|
|
hasChanges && !(selectedStatus === 'rejected' && !rejectReason.trim())
|
|
? 'bg-primary text-primary-foreground hover:brightness-110 ring-2 ring-primary/30'
|
|
: 'bg-primary text-primary-foreground hover:brightness-105'
|
|
)}
|
|
isLoading={isSaving || isUpdating}
|
|
loadingText="در حال ذخیره..."
|
|
onClick={handleSaveStatus}
|
|
>
|
|
<Ic name="check" className="size-4" />
|
|
<span>{hasChanges ? 'ذخیره تغییرات' : 'ثبت وضعیت'}</span>
|
|
</Button>
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|