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