@ -17,7 +17,7 @@ import {
} from '@/components/ui/table'
} from '@/components/ui/table'
import { Ic , IconName } from '@/icons'
import { Ic , IconName } from '@/icons'
import { Shimmer , ShimmerBox , ShimmerCircle , ShimmerLine } from '@/components/shared/shimmer_frontend'
import { Shimmer , ShimmerBox , ShimmerCircle , ShimmerLine } from '@/components/shared/shimmer_frontend'
import { fetchFactors , reviewFactor , clearFactorReceipt } from './services/billing-api'
import { fetchFactors , fetchFactorDetail , reviewFactor , clearFactorReceipt } from './services/billing-api'
import type { FactorItem , FactorStatus , FactorService , BillingStats } from './types'
import type { FactorItem , FactorStatus , FactorService , BillingStats } from './types'
import { BillingStatusBadge } from './components/BillingStatusBadge'
import { BillingStatusBadge } from './components/BillingStatusBadge'
import { BillingServiceBadge } from './components/BillingServiceBadge'
import { BillingServiceBadge } from './components/BillingServiceBadge'
@ -111,6 +111,15 @@ export default function BillingPage() {
setSelectedStatuses ( ( prev ) = > ( prev . join ( ',' ) !== urlStatuses . join ( ',' ) ? urlStatuses : prev ) )
setSelectedStatuses ( ( prev ) = > ( prev . join ( ',' ) !== urlStatuses . join ( ',' ) ? urlStatuses : prev ) )
setSearchQuery ( ( prev ) = > ( prev !== urlSearch ? urlSearch : prev ) )
setSearchQuery ( ( prev ) = > ( prev !== urlSearch ? urlSearch : prev ) )
setCurrentPage ( ( prev ) = > ( prev !== urlPage ? urlPage : prev ) )
setCurrentPage ( ( prev ) = > ( prev !== urlPage ? urlPage : prev ) )
const directFactorId = searchParams . get ( 'factorId' ) || searchParams . get ( 'id' )
if ( directFactorId ) {
fetchFactorDetail ( directFactorId )
. then ( ( f ) = > {
if ( f && f . id ) setSelectedFactorForDetail ( f )
} )
. catch ( ( e ) = > console . warn ( 'Could not auto-open factor from URL:' , e ) )
}
} , [ searchParams ] )
} , [ searchParams ] )
// Load factors from API with race condition protection
// Load factors from API with race condition protection
@ -334,13 +343,13 @@ export default function BillingPage() {
) }
) }
{ /* سربرگ اصلی صفحه صورتحسابها */ }
{ /* سربرگ اصلی صفحه صورتحسابها */ }
< div className = "flex flex-col gap-4 rounded-2xl border border-border-soft bg-card/40 p-6 shadow-xs sm:flex-row sm:items-center sm:justify-between backdrop-blur-xs" >
< div className = "flex flex-col gap-4 rounded-2xl border border-border-soft bg-card/40 p-4 sm:p- 6 shadow-xs sm:flex-row sm:items-center sm:justify-between backdrop-blur-xs" >
< div className = "space-y-1" >
< div className = "space-y-1" >
< div className = "flex items-center gap-2.5" >
< div className = "flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary" >
< div className = "flex items-center gap-2.5 flex-wrap " >
< div className = "flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary shrink-0 " >
< Ic name = "receipt" className = "size-5" / >
< Ic name = "receipt" className = "size-5" / >
< / div >
< / div >
< h1 className = "text-xl font-black text-foreground" >
< h1 className = "text-lg sm:text- xl font-black text-foreground" >
{ t ( 'billing.title' ) }
{ t ( 'billing.title' ) }
< / h1 >
< / h1 >
< Badge
< Badge
@ -359,7 +368,7 @@ export default function BillingPage() {
< / p >
< / p >
< / div >
< / div >
< div className = "flex items-center gap-2.5" >
< div className = "flex items-center gap-2.5 self-start sm:self-auto shrink-0 " >
< Button
< Button
variant = "outline"
variant = "outline"
size = "sm"
size = "sm"
@ -375,19 +384,19 @@ export default function BillingPage() {
{ /* کارتهای KPI شاخصهای کلیدی مالی */ }
{ /* کارتهای KPI شاخصهای کلیدی مالی */ }
< div className = "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3.5" >
< div className = "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-3 xl:grid-cols- 5 gap-3 sm: gap-3.5" >
{ /* کل فاکتورها */ }
{ /* کل فاکتورها */ }
< Card className = "border-border-soft bg-card/40 shadow-xs" >
< Card className = "border-border-soft bg-card/40 shadow-xs" >
< CardContent className = "p-4 flex items-center justify-between" >
< div >
< p className = "text-[11px] text-grey-3 font-medium" > { t ( 'billing.totalInvoices' ) } < / p >
< CardContent className = "p-3.5 sm:p- 4 flex items-center justify-between gap-2.5 " >
< div className = "min-w-0 flex-1" >
< p className = "text-[11px] text-grey-3 font-medium truncate " > { t ( 'billing.totalInvoices' ) } < / p >
{ isLoading ? (
{ isLoading ? (
< ShimmerBox width = { 48 } height = { 24 } rounded = "md" className = "mt-1" / >
< ShimmerBox width = { 48 } height = { 24 } rounded = "md" className = "mt-1" / >
) : (
) : (
< p className = "text-xl font-black mt-1 font-mono text-foreground" > { stats . total } < / p >
< p className = "text-lg sm:text- xl font-black mt-1 font-mono text-foreground tabular-nums " > { stats . total } < / p >
) }
) }
< / div >
< / div >
< div className = "size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground" >
< div className = "size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground shrink-0 " >
< Ic name = "receipt" className = "size-4" / >
< Ic name = "receipt" className = "size-4" / >
< / div >
< / div >
< / CardContent >
< / CardContent >
@ -395,20 +404,20 @@ export default function BillingPage() {
{ /* در انتظار بررسی فیش */ }
{ /* در انتظار بررسی فیش */ }
< Card className = "border-blue-500/20 bg-blue-500/5 shadow-xs" >
< Card className = "border-blue-500/20 bg-blue-500/5 shadow-xs" >
< CardContent className = "p-4 flex items-center justify-between" >
< div >
< p className = "text-[11px] text-blue-600 dark:text-blue-400 font-medium" >
< CardContent className = "p-3.5 sm:p- 4 flex items-center justify-between gap-2.5 " >
< div className = "min-w-0 flex-1" >
< p className = "text-[11px] text-blue-600 dark:text-blue-400 font-medium truncate " >
{ t ( 'billing.pendingReview' ) }
{ t ( 'billing.pendingReview' ) }
< / p >
< / p >
{ isLoading ? (
{ isLoading ? (
< ShimmerBox width = { 48 } height = { 24 } rounded = "md" className = "mt-1" / >
< ShimmerBox width = { 48 } height = { 24 } rounded = "md" className = "mt-1" / >
) : (
) : (
< p className = "text-xl font-black mt-1 font-mono text-blue-600 dark:text-blue-400" >
< p className = "text-lg sm:text- xl font-black mt-1 font-mono text-blue-600 dark:text-blue-400 tabular-nums " >
{ stats . pendingReview }
{ stats . pendingReview }
< / p >
< / p >
) }
) }
< / div >
< / div >
< div className = "size-9 rounded-xl bg-blue-500/15 text-blue-600 dark:text-blue-400 flex items-center justify-center" >
< div className = "size-9 rounded-xl bg-blue-500/15 text-blue-600 dark:text-blue-400 flex items-center justify-center shrink-0 " >
< Ic name = "clock" className = "size-4" / >
< Ic name = "clock" className = "size-4" / >
< / div >
< / div >
< / CardContent >
< / CardContent >
@ -416,20 +425,20 @@ export default function BillingPage() {
{ /* در انتظار پرداخت */ }
{ /* در انتظار پرداخت */ }
< Card className = "border-amber-500/20 bg-amber-500/5 shadow-xs" >
< Card className = "border-amber-500/20 bg-amber-500/5 shadow-xs" >
< CardContent className = "p-4 flex items-center justify-between" >
< div >
< p className = "text-[11px] text-amber-600 dark:text-amber-400 font-medium" >
< CardContent className = "p-3.5 sm:p- 4 flex items-center justify-between gap-2.5 " >
< div className = "min-w-0 flex-1" >
< p className = "text-[11px] text-amber-600 dark:text-amber-400 font-medium truncate " >
{ t ( 'billing.awaitingPayment' ) }
{ t ( 'billing.awaitingPayment' ) }
< / p >
< / p >
{ isLoading ? (
{ isLoading ? (
< ShimmerBox width = { 48 } height = { 24 } rounded = "md" className = "mt-1" / >
< ShimmerBox width = { 48 } height = { 24 } rounded = "md" className = "mt-1" / >
) : (
) : (
< p className = "text-xl font-black mt-1 font-mono text-amber-600 dark:text-amber-400" >
< p className = "text-lg sm:text- xl font-black mt-1 font-mono text-amber-600 dark:text-amber-400 tabular-nums " >
{ stats . awaitingPayment }
{ stats . awaitingPayment }
< / p >
< / p >
) }
) }
< / div >
< / div >
< div className = "size-9 rounded-xl bg-amber-500/15 text-amber-600 dark:text-amber-400 flex items-center justify-center" >
< div className = "size-9 rounded-xl bg-amber-500/15 text-amber-600 dark:text-amber-400 flex items-center justify-center shrink-0 " >
< Ic name = "creditCard" className = "size-4" / >
< Ic name = "creditCard" className = "size-4" / >
< / div >
< / div >
< / CardContent >
< / CardContent >
@ -437,41 +446,41 @@ export default function BillingPage() {
{ /* تایید شده */ }
{ /* تایید شده */ }
< Card className = "border-emerald-500/20 bg-emerald-500/5 shadow-xs" >
< Card className = "border-emerald-500/20 bg-emerald-500/5 shadow-xs" >
< CardContent className = "p-4 flex items-center justify-between" >
< div >
< p className = "text-[11px] text-emerald-600 dark:text-emerald-400 font-medium" >
< CardContent className = "p-3.5 sm:p- 4 flex items-center justify-between gap-2.5 " >
< div className = "min-w-0 flex-1" >
< p className = "text-[11px] text-emerald-600 dark:text-emerald-400 font-medium truncate " >
{ t ( 'billing.approvedPayments' ) }
{ t ( 'billing.approvedPayments' ) }
< / p >
< / p >
{ isLoading ? (
{ isLoading ? (
< ShimmerBox width = { 48 } height = { 24 } rounded = "md" className = "mt-1" / >
< ShimmerBox width = { 48 } height = { 24 } rounded = "md" className = "mt-1" / >
) : (
) : (
< p className = "text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400" >
< p className = "text-lg sm:text- xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400 tabular-nums " >
{ stats . approved }
{ stats . approved }
< / p >
< / p >
) }
) }
< / div >
< / div >
< div className = "size-9 rounded-xl bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center justify-center" >
< div className = "size-9 rounded-xl bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center justify-center shrink-0 " >
< Ic name = "checkCircle" className = "size-4" / >
< Ic name = "checkCircle" className = "size-4" / >
< / div >
< / div >
< / CardContent >
< / CardContent >
< / Card >
< / Card >
{ /* رد شده یا منقضی */ }
{ /* رد شده یا منقضی */ }
< Card className = "border-rose-500/20 bg-rose-500/5 shadow-xs col-span-2 sm:col-span-1" >
< CardContent className = "p-4 flex items-center justify-between" >
< div >
< p className = "text-[11px] text-rose-600 dark:text-rose-400 font-medium" >
< Card className = "border-rose-500/20 bg-rose-500/5 shadow-xs col-span-2 sm:col-span-1 lg:col-span-2 xl:col-span-1 " >
< CardContent className = "p-3.5 sm:p- 4 flex items-center justify-between gap-2.5 " >
< div className = "min-w-0 flex-1" >
< p className = "text-[11px] text-rose-600 dark:text-rose-400 font-medium truncate " >
{ t ( 'billing.rejectedOrExpired' ) }
{ t ( 'billing.rejectedOrExpired' ) }
< / p >
< / p >
{ isLoading ? (
{ isLoading ? (
< ShimmerBox width = { 48 } height = { 24 } rounded = "md" className = "mt-1" / >
< ShimmerBox width = { 48 } height = { 24 } rounded = "md" className = "mt-1" / >
) : (
) : (
< p className = "text-xl font-black mt-1 font-mono text-rose-600 dark:text-rose-400" >
< p className = "text-lg sm:text- xl font-black mt-1 font-mono text-rose-600 dark:text-rose-400 tabular-nums " >
{ stats . rejectedOrExpired }
{ stats . rejectedOrExpired }
< / p >
< / p >
) }
) }
< / div >
< / div >
< div className = "size-9 rounded-xl bg-rose-500/15 text-rose-600 dark:text-rose-400 flex items-center justify-center" >
< div className = "size-9 rounded-xl bg-rose-500/15 text-rose-600 dark:text-rose-400 flex items-center justify-center shrink-0 " >
< Ic name = "xCircle" className = "size-4" / >
< Ic name = "xCircle" className = "size-4" / >
< / div >
< / div >
< / CardContent >
< / CardContent >
@ -480,8 +489,8 @@ export default function BillingPage() {
{ /* نوار جستجو و فیلترهای دوگانه: ۱. نوع سرویس ۲. وضعیت صورتحساب */ }
{ /* نوار جستجو و فیلترهای دوگانه: ۱. نوع سرویس ۲. وضعیت صورتحساب */ }
< Card className = "border-border-soft bg-card/40 shadow-xs" >
< Card className = "border-border-soft bg-card/40 shadow-xs" >
< CardContent className = "p-4" >
< div className = "flex flex-col lg :flex-row items-stretch lg :items-center justify-between gap-3" >
< CardContent className = "p-3.5 sm:p- 4" >
< div className = "flex flex-col x l:flex-row items-stretch x l:items-center justify-between gap-3" >
{ /* جستجوی متنی */ }
{ /* جستجوی متنی */ }
< div className = "relative flex-1 min-w-[200px]" >
< div className = "relative flex-1 min-w-[200px]" >
< Ic
< Ic
@ -505,9 +514,9 @@ export default function BillingPage() {
< / div >
< / div >
{ /* فیلترهای دوگانه چندانتخابی مجزا: نوع سرویس و وضعیت صورتحساب */ }
{ /* فیلترهای دوگانه چندانتخابی مجزا: نوع سرویس و وضعیت صورتحساب */ }
< div className = "flex flex-wrap sm:flex-nowrap items-center gap-2.5 w-full lg :w-auto" >
< div className = "flex flex-col sm:flex-row items-stretch sm:items-center gap-2.5 w-full xl :w-auto" >
{ /* فیلتر چندگزینهای نوع سرویس */ }
{ /* فیلتر چندگزینهای نوع سرویس */ }
< div className = "w-full sm:w-52" >
< div className = "w-full sm:w-48 lg:w- 52" >
< MultiFilterSelect
< MultiFilterSelect
values = { selectedServices }
values = { selectedServices }
onChange = { handleSelectServices }
onChange = { handleSelectServices }
@ -516,12 +525,12 @@ export default function BillingPage() {
placeholder = { t ( 'billing.filterService' ) }
placeholder = { t ( 'billing.filterService' ) }
selectedCountLabel = { ( count ) = > t ( 'billing.selectedServicesCount' , { count } ) }
selectedCountLabel = { ( count ) = > t ( 'billing.selectedServicesCount' , { count } ) }
clearLabel = { t ( 'billing.clearFilters' ) }
clearLabel = { t ( 'billing.clearFilters' ) }
triggerClassName = "h-9 text-xs rounded-xl bg-surface-2 border-border-soft"
triggerClassName = "h-9 text-xs rounded-xl bg-surface-2 border-border-soft w-full "
/ >
/ >
< / div >
< / div >
{ /* فیلتر چندگزینهای وضعیت صورتحساب */ }
{ /* فیلتر چندگزینهای وضعیت صورتحساب */ }
< div className = "w-full sm:w-52" >
< div className = "w-full sm:w-48 lg:w- 52" >
< MultiFilterSelect
< MultiFilterSelect
values = { selectedStatuses }
values = { selectedStatuses }
onChange = { handleSelectStatuses }
onChange = { handleSelectStatuses }
@ -530,7 +539,7 @@ export default function BillingPage() {
placeholder = { t ( 'billing.filterStatus' ) }
placeholder = { t ( 'billing.filterStatus' ) }
selectedCountLabel = { ( count ) = > t ( 'billing.selectedStatusesCount' , { count } ) }
selectedCountLabel = { ( count ) = > t ( 'billing.selectedStatusesCount' , { count } ) }
clearLabel = { t ( 'billing.clearFilters' ) }
clearLabel = { t ( 'billing.clearFilters' ) }
triggerClassName = "h-9 text-xs rounded-xl bg-surface-2 border-border-soft"
triggerClassName = "h-9 text-xs rounded-xl bg-surface-2 border-border-soft w-full "
/ >
/ >
< / div >
< / div >
@ -542,7 +551,7 @@ export default function BillingPage() {
variant = "ghost"
variant = "ghost"
size = "sm"
size = "sm"
onClick = { handleResetFilters }
onClick = { handleResetFilters }
className = "h-9 px-3 text-xs text-grey-3 hover:text-foreground shrink-0 rounded-xl hover:bg-surface-2"
className = "h-9 px-3 text-xs text-grey-3 hover:text-foreground shrink-0 rounded-xl hover:bg-surface-2 justify-center "
title = { t ( 'billing.clearFilters' ) }
title = { t ( 'billing.clearFilters' ) }
>
>
< Ic name = "x" className = "size-3.5 me-1" / >
< Ic name = "x" className = "size-3.5 me-1" / >
@ -556,8 +565,8 @@ export default function BillingPage() {
{ /* جدول دادهای جامع صورتحسابها و سفارشات */ }
{ /* جدول دادهای جامع صورتحسابها و سفارشات */ }
< Card className = "border-border-soft bg-card/40 shadow-xs overflow-hidden" >
< Card className = "border-border-soft bg-card/40 shadow-xs overflow-hidden" >
< CardHeader className = "py-3 px-4 border-b border-border-soft bg-card/60 flex flex-row items-center justify-between" >
< CardTitle className = "text-xs font-bold flex items-center gap-2 text-foreground" >
< CardHeader className = "py-3 px-4 border-b border-border-soft bg-card/60 flex flex-col sm:flex- row items-start sm:items- center justify-between gap-2 " >
< CardTitle className = "text-xs sm:text-sm font-bold flex items-center gap-2 text-foreground" >
< Ic name = "list" className = "size-4 text-primary" / >
< Ic name = "list" className = "size-4 text-primary" / >
< span > { t ( 'billing.invoiceList' ) } < / span >
< span > { t ( 'billing.invoiceList' ) } < / span >
{ selectedServices . length > 0 && ! selectedServices . includes ( 'all' ) && (
{ selectedServices . length > 0 && ! selectedServices . includes ( 'all' ) && (
@ -611,239 +620,363 @@ export default function BillingPage() {
} ) ( ) }
} ) ( ) }
< / div >
< / div >
) : (
) : (
< div className = "overflow-x-auto relative" >
{ /* خط انیمیشن لودینگ در بالای جدول هنگام بارگذاری مجدد */ }
{ isLoading && (
< div className = "absolute top-0 inset-x-0 h-0.5 bg-primary/20 overflow-hidden z-10" >
< div className = "h-full bg-primary animate-indeterminate origin-right" / >
< / div >
) }
< Table >
< TableHeader >
< TableRow className = "hover:bg-transparent text-xs border-b border-border-soft bg-secondary/15 text-grey-2" >
< TableHead className = "w-14 text-center" > { t ( 'billing.id' ) } < / TableHead >
< TableHead > { t ( 'billing.customer' ) } < / TableHead >
< TableHead className = "w-28 text-center" > { t ( 'billing.factorNumber' ) } < / TableHead >
< TableHead > { t ( 'billing.service' ) } < / TableHead >
< TableHead > { t ( 'billing.amount' ) } < / TableHead >
< TableHead > { t ( 'billing.liveStatus' ) } < / TableHead >
< TableHead className = "text-center" > { t ( 'billing.receipt' ) } < / TableHead >
< TableHead > { t ( 'billing.date' ) } < / TableHead >
< TableHead className = "text-end pl-4" > { t ( 'common.actions' ) } < / TableHead >
< / TableRow >
< / TableHeader >
< TableBody className = "text-xs divide-y divide-border-soft" >
{ isLoading ? (
Array . from ( { length : 8 } ) . map ( ( _ , i ) = > (
< TableRow key = { i } className = "hover:bg-transparent" >
{ /* ۱. شناسه */ }
< TableCell className = "text-center" >
< ShimmerBox width = { 28 } height = { 14 } rounded = "md" className = "mx-auto" / >
< / TableCell >
{ /* ۲. خریدار */ }
< TableCell >
< div className = "flex items-center gap-2.5" >
< ShimmerCircle size = { 32 } / >
< div className = "space-y-1.5 flex-1 min-w-0" >
< ShimmerLine width = { 96 } height = { 14 } rounded = "sm" / >
< ShimmerLine width = { 64 } height = { 10 } rounded = "sm" / >
< >
{ /* ۱. نمای جدول برای تبلت و دسکتاپ (md به بالا با حداقل عرض مشخص) */ }
< div className = "overflow-x-auto relative hidden md:block" >
{ /* خط انیمیشن لودینگ در بالای جدول هنگام بارگذاری مجدد */ }
{ isLoading && (
< div className = "absolute top-0 inset-x-0 h-0.5 bg-primary/20 overflow-hidden z-10" >
< div className = "h-full bg-primary animate-indeterminate origin-right" / >
< / div >
) }
< Table className = "w-full min-w-[1080px]" >
< TableHeader >
< TableRow className = "hover:bg-transparent text-xs border-b border-border-soft bg-secondary/15 text-grey-2" >
< TableHead className = "w-16 min-w-[64px] text-center" > { t ( 'billing.id' ) } < / TableHead >
< TableHead className = "min-w-[210px]" > { t ( 'billing.customer' ) } < / TableHead >
< TableHead className = "w-28 min-w-[110px] text-center" > { t ( 'billing.factorNumber' ) } < / TableHead >
< TableHead className = "w-28 min-w-[110px]" > { t ( 'billing.service' ) } < / TableHead >
< TableHead className = "w-24 min-w-[95px]" > { t ( 'billing.amount' ) } < / TableHead >
< TableHead className = "w-36 min-w-[135px]" > { t ( 'billing.liveStatus' ) } < / TableHead >
< TableHead className = "w-24 min-w-[90px] text-center" > { t ( 'billing.receipt' ) } < / TableHead >
< TableHead className = "w-36 min-w-[140px]" > { t ( 'billing.date' ) } < / TableHead >
< TableHead className = "w-32 min-w-[125px] text-end pe-4" > { t ( 'common.actions' ) } < / TableHead >
< / TableRow >
< / TableHeader >
< TableBody className = "text-xs divide-y divide-border-soft" >
{ isLoading ? (
Array . from ( { length : 8 } ) . map ( ( _ , i ) = > (
< TableRow key = { i } className = "hover:bg-transparent" >
{ /* ۱. شناسه */ }
< TableCell className = "text-center" >
< ShimmerBox width = { 28 } height = { 14 } rounded = "md" className = "mx-auto" / >
< / TableCell >
{ /* ۲. خریدار */ }
< TableCell >
< div className = "flex items-center gap-2.5" >
< ShimmerCircle size = { 32 } / >
< div className = "space-y-1.5 flex-1 min-w-0" >
< ShimmerLine width = { 96 } height = { 14 } rounded = "sm" / >
< ShimmerLine width = { 64 } height = { 10 } rounded = "sm" / >
< / div >
< / div >
< / div >
< / div >
< / TableCell >
{ /* ۳. شماره فاکتور */ }
< TableCell className = "text-center" >
< ShimmerBox width = { 56 } height = { 18 } rounded = "md" className = "mx-auto" / >
< / TableCell >
{ /* ۴. سرویس */ }
< TableCell >
< ShimmerBox width = { 80 } height = { 24 } rounded = "full" / >
< / TableCell >
{ /* ۵. مبلغ */ }
< TableCell >
< ShimmerLine width = { 64 } height = { 16 } rounded = "sm" / >
< / TableCell >
{ /* ۶. وضعیت */ }
< TableCell >
< ShimmerBox width = { 96 } height = { 24 } rounded = "full" / >
< / TableCell >
{ /* ۷. تصویر فیش */ }
< TableCell className = "text-center" >
< ShimmerBox width = { 36 } height = { 36 } rounded = "lg" className = "mx-auto" / >
< / TableCell >
{ /* ۸. تاریخ و ساعت */ }
< TableCell >
< ShimmerLine width = { 100 } height = { 14 } rounded = "sm" / >
< / TableCell >
{ /* ۹. عملیات */ }
< TableCell className = "text-end pl-4" >
< div className = "flex items-center justify-end" >
< ShimmerBox width = { 105 } height = { 32 } rounded = "xl" / >
< / div >
< / TableCell >
< / TableRow >
) )
) : (
factors . map ( ( factor ) = > {
< / TableCell >
{ /* ۳. شماره فاکتور */ }
< TableCell className = "text-center" >
< ShimmerBox width = { 56 } height = { 18 } rounded = "md" className = "mx-auto" / >
< / TableCell >
{ /* ۴. سرویس */ }
< TableCell >
< ShimmerBox width = { 80 } height = { 24 } rounded = "full" / >
< / TableCell >
{ /* ۵. مبلغ */ }
< TableCell >
< ShimmerLine width = { 64 } height = { 16 } rounded = "sm" / >
< / TableCell >
{ /* ۶. وضعیت */ }
< TableCell >
< ShimmerBox width = { 96 } height = { 24 } rounded = "full" / >
< / TableCell >
{ /* ۷. تصویر فیش */ }
< TableCell className = "text-center" >
< ShimmerBox width = { 36 } height = { 36 } rounded = "lg" className = "mx-auto" / >
< / TableCell >
{ /* ۸. تاریخ و ساعت */ }
< TableCell >
< ShimmerLine width = { 100 } height = { 14 } rounded = "sm" / >
< / TableCell >
{ /* ۹. عملیات */ }
< TableCell className = "text-end pe-4" >
< div className = "flex items-center justify-end" >
< ShimmerBox width = { 105 } height = { 32 } rounded = "xl" / >
< / div >
< / TableCell >
< / TableRow >
) )
) : (
factors . map ( ( factor ) = > {
const isPending = factor . status === 'pending'
const hasReceipt = Boolean ( factor . transaction_receipt )
const user = factor . user_info
return (
< TableRow
key = { factor . id }
className = { ` transition-colors hover:bg-surface-2/50 ${
isPending ? 'bg-blue-500/[0.03] hover:bg-blue-500/[0.07]' : ''
} ` }
>
{ /* ۱. شناسه دیتابیس */ }
< TableCell className = "text-center font-mono font-medium text-[11px] text-grey-3/80" >
{ factor . id }
< / TableCell >
{ /* ۲. مشخصات مشتری و خریدار */ }
< TableCell >
< div className = "flex items-center gap-2.5" >
< Avatar className = "size-8.5 shrink-0" / >
< div className = "min-w-0 space-y-0.5" >
< p className = "font-bold text-xs sm:text-[13px] text-foreground truncate max-w-[140px] sm:max-w-[190px]" >
{ user ? . fullname || '—' }
< / p >
< p className = "text-[11px] font-bold text-foreground/80 truncate max-w-[140px] sm:max-w-[190px] font-mono dir-ltr text-start tracking-tight" >
{ user ? . phone_number || user ? . email || '—' }
< / p >
< / div >
< / div >
< / TableCell >
{ /* ۳. شماره فاکتور (کلیک جهت کپی) */ }
< TableCell className = "text-center" >
{ factor . factor_number ? (
< button
type = "button"
onClick = { ( ) = > {
navigator . clipboard . writeText ( String ( factor . factor_number ) )
toast . success ( t ( 'billing.factorNumberCopied' ) )
} }
className = "font-mono font-black text-sm text-foreground hover:text-primary transition-colors cursor-pointer select-all tracking-wider py-1 px-2 rounded-lg hover:bg-surface-3 active:scale-95"
title = { t ( 'billing.factorNumberCopied' ) }
>
{ factor . factor_number }
< / button >
) : (
< span className = "text-grey-3 font-mono text-xs" > — < / span >
) }
< / TableCell >
{ /* ۴. سرویس */ }
< TableCell >
< BillingServiceBadge service = { factor . service } / >
< / TableCell >
{ /* ۵. مبلغ */ }
< TableCell >
< span className = "font-bold font-mono text-sm text-foreground" >
$ { Number ( factor . amount ) . toLocaleString ( ) }
< / span >
< / TableCell >
{ /* ۶. وضعیت لحظهای */ }
< TableCell >
< BillingStatusBadge status = { factor . status } / >
< / TableCell >
{ /* ۷. پیشنمایش تصویر فیش */ }
< TableCell className = "text-center" >
{ hasReceipt ? (
< button
type = "button"
onClick = { ( ) = > setSelectedFactorForLightbox ( factor ) }
className = "relative group size-8 rounded-lg overflow-hidden border border-primary/20 bg-primary/10 mx-auto flex items-center justify-center hover:border-primary/50 hover:scale-105 transition-all shadow-2xs cursor-pointer"
title = { t ( 'billing.viewReceipt' ) }
>
< img
src = { resolveMediaUrl ( factor . transaction_receipt ) }
alt = ""
className = "size-full object-cover"
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'
} }
/ >
< Ic
name = "image"
className = "size-3.5 text-primary pointer-events-none group-hover:scale-110 transition-transform"
/ >
< div className = "absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 flex items-center justify-center text-white transition-opacity" >
< Ic name = "eye" className = "size-3" / >
< / div >
< / button >
) : (
< span
className = "inline-block text-[10.5px] font-medium text-muted-foreground/70 bg-muted/40 px-2 py-0.5 rounded-md border border-border-soft select-none"
title = { t ( 'billing.noReceiptUploaded' ) }
>
{ t ( 'billing.noReceipt' ) }
< / span >
) }
< / TableCell >
{ /* ۸. تاریخ و ساعت صدور (با ساختار خوانا و مجزا) */ }
< TableCell className = "whitespace-nowrap" >
< div className = "flex flex-col text-[11px] leading-tight" dir = "rtl" >
< span className = "font-semibold text-foreground/90" >
{ formatDateByLanguage ( factor . created_at , language as any , { style : 'medium' } ) }
< / span >
< span className = "text-[10px] text-muted-foreground font-mono mt-0.5" dir = "ltr" >
{ new Date ( factor . created_at ) . toLocaleTimeString (
language === 'fa' ? 'fa-IR' : language === 'ar' ? 'ar-SA' : 'en-US' ,
{ hour : '2-digit' , minute : '2-digit' }
) }
< / span >
< / div >
< / TableCell >
{ /* دکمههای عملیات */ }
< TableCell className = "text-end pe-4" >
< div className = "flex items-center justify-end" >
< Button
variant = "default"
size = "sm"
className = "h-8 px-3 text-xs gap-1.5 rounded-lg font-bold shadow-2xs hover:brightness-105 active:scale-[0.98] transition-all shrink-0"
onClick = { ( ) = > setSelectedFactorForDetail ( factor ) }
title = { t ( 'billing.viewDetails' ) }
>
< Ic name = "eye" className = "size-3.5" / >
< span > { t ( 'billing.viewDetails' ) } < / span >
< / Button >
< / div >
< / TableCell >
< / TableRow >
)
} )
) }
< / TableBody >
< / Table >
< / div >
{ /* ۲. نمای کارتهای واکنشگرا برای گوشیهای موبایل (زیر md) */ }
< div className = "flex flex-col gap-3.5 p-3.5 sm:p-4 md:hidden" >
{ isLoading && factors . length === 0 ? (
Array . from ( { length : 4 } ) . map ( ( _ , i ) = > (
< div key = { i } className = "h-44 rounded-2xl bg-muted/40 border border-border-soft animate-pulse p-4 space-y-3" >
< div className = "flex justify-between items-center" >
< ShimmerBox width = { 90 } height = { 22 } rounded = "full" / >
< ShimmerBox width = { 80 } height = { 20 } rounded = "full" / >
< / div >
< div className = "flex items-center gap-2.5 pt-2" >
< ShimmerCircle size = { 36 } / >
< div className = "space-y-1.5 flex-1" >
< ShimmerLine width = { 110 } height = { 14 } rounded = "sm" / >
< ShimmerLine width = { 80 } height = { 10 } rounded = "sm" / >
< / div >
< / div >
< div className = "pt-2 border-t border-border-soft flex justify-between items-center" >
< ShimmerLine width = { 70 } height = { 14 } rounded = "sm" / >
< ShimmerBox width = { 90 } height = { 28 } rounded = "xl" / >
< / div >
< / div >
) )
) : (
factors . map ( ( factor ) = > {
const isPending = factor . status === 'pending'
const isPending = factor . status === 'pending'
const hasReceipt = Boolean ( factor . transaction_receipt )
const hasReceipt = Boolean ( factor . transaction_receipt )
const user = factor . user_info
const user = factor . user_info
return (
return (
< TableRow
< div
key = { factor . id }
key = { factor . id }
className = { ` transition-colors hover:bg-surface-2/50 ${
isPending ? 'bg-blue-500/[0.03] hover:bg-blue-500/[0.07]' : ''
className = { ` bg-card/80 border border-border-soft rounded-2xl p-4 shadow-xs space-y-3 transition-colors ${
isPending ? 'bg-blue-500/[0.04] border-blue-500/30 ' : ''
} ` }
} ` }
>
>
{ /* ۱. شناسه دیتابیس (بدون هشتگ و فونت ریزتر) */ }
< TableCell className = "text-center font-mono font-medium text-[11px] text-grey-3/80" >
{ factor . id }
< / TableCell >
{ /* ۲. مشخصات مشتری و خریدار (نام و اطلاعات تماس بولدتر) */ }
< TableCell >
< div className = "flex items-center gap-2.5" >
< Avatar className = "size-8.5 shrink-0" / >
{ /* سطر ۱: سرویس، شماره فاکتور، و وضعیت */ }
< div className = "flex items-center justify-between gap-2 border-b border-border-soft pb-2.5" >
< div className = "flex items-center gap-2 flex-wrap" >
< BillingServiceBadge service = { factor . service } / >
< span className = "font-mono text-xs font-bold text-foreground" >
# { factor . factor_number || factor . id }
< / span >
< / div >
< BillingStatusBadge status = { factor . status } / >
< / div >
{ /* سطر ۲: مشتری و مبلغ */ }
< div className = "flex items-center justify-between gap-3" >
< div className = "flex items-center gap-2.5 min-w-0" >
< Avatar className = "size-9 shrink-0" / >
< div className = "min-w-0 space-y-0.5" >
< div className = "min-w-0 space-y-0.5" >
< p className = "font-black text-xs sm:text-[13px] text-foreground truncate max-w-[140px] sm:max-w-[190px]" >
< p className = "font-bold text-xs text-foreground truncate " >
{ user ? . fullname || '—' }
{ user ? . fullname || '—' }
< / p >
< / p >
< p className = "text-[11px] font-bold text-foreground/80 truncate max-w-[140px] sm:max-w-[190px] font-mono dir-ltr text-start tracking-tight" >
< p className = "text-[11px] text-muted-foreground truncate font-mono" dir = "ltr " >
{ user ? . phone_number || user ? . email || '—' }
{ user ? . phone_number || user ? . email || '—' }
< / p >
< / p >
< / div >
< / div >
< / div >
< / div >
< / TableCell >
{ /* ۳. شماره فاکتور (بین مشتری و سرویس، فونت بزرگتر، کلیک جهت کپی) */ }
< TableCell className = "text-center" >
{ factor . factor_number ? (
< button
type = "button"
onClick = { ( ) = > {
navigator . clipboard . writeText ( String ( factor . factor_number ) )
toast . success ( t ( 'billing.factorNumberCopied' ) )
} }
className = "font-mono font-black text-sm text-foreground hover:text-primary transition-colors cursor-pointer select-all tracking-wider py-1 px-2 rounded-lg hover:bg-surface-3 active:scale-95"
title = { t ( 'billing.factorNumberCopied' ) }
>
{ factor . factor_number }
< / button >
) : (
< span className = "text-grey-3 font-mono text-xs" > — < / span >
) }
< / TableCell >
{ /* ۴. سرویس */ }
< TableCell >
< BillingServiceBadge service = { factor . service } / >
< / TableCell >
{ /* ۵. مبلغ */ }
< TableCell >
< span className = "font-bold font-mono text-sm text-foreground" >
$ { Number ( factor . amount ) . toLocaleString ( ) }
< / span >
< / TableCell >
{ /* ۶. وضعیت لحظهای */ }
< TableCell >
< BillingStatusBadge status = { factor . status } / >
< / TableCell >
{ /* ۷. پیشنمایش تصویر فیش */ }
< TableCell className = "text-center" >
{ hasReceipt ? (
< button
type = "button"
onClick = { ( ) = > setSelectedFactorForLightbox ( factor ) }
className = "relative group size-8 rounded-lg overflow-hidden border border-primary/20 bg-primary/10 mx-auto flex items-center justify-center hover:border-primary/50 hover:scale-105 transition-all shadow-2xs cursor-pointer"
title = { t ( 'billing.viewReceipt' ) }
>
< img
src = { resolveMediaUrl ( factor . transaction_receipt ) }
alt = ""
className = "size-full object-cover"
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'
} }
/ >
< Ic
name = "image"
className = "size-3.5 text-primary pointer-events-none group-hover:scale-110 transition-transform"
/ >
< div className = "absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 flex items-center justify-center text-white transition-opacity" >
< Ic name = "eye" className = "size-3" / >
< / div >
< / button >
) : (
< span
className = "inline-block text-[10.5px] font-medium text-muted-foreground/70 bg-muted/40 px-2 py-0.5 rounded-md border border-border-soft select-none"
title = { t ( 'billing.noReceiptUploaded' ) }
>
{ t ( 'billing.noReceipt' ) }
< div className = "text-end shrink-0" >
< span className = "text-[10px] text-muted-foreground block" > { t ( 'billing.amount' ) } < / span >
< span className = "font-black font-mono text-base text-foreground" >
$ { Number ( factor . amount ) . toLocaleString ( ) }
< / span >
< / span >
) }
< / TableCell >
{ /* ۸. تاریخ و ساعت صدور (با فونت ریزتر و همراه با ساعت) */ }
< TableCell className = "text-muted-foreground whitespace-nowrap text-[11px] font-mono font-medium" >
{ formatDateTimeByLanguage ( factor . created_at , language as any , { style : 'medium' } ) }
< / TableCell >
{ /* دکمههای عملیات */ }
< TableCell className = "text-end pl-4" >
< div className = "flex items-center justify-end" >
{ /* دکمه مشاهده جزئیات کامل خریدار و پرداخت */ }
< Button
variant = "default"
size = "sm"
className = "h-8 px-3 text-xs gap-1.5 rounded-lg font-bold shadow-2xs hover:brightness-105 active:scale-[0.98] transition-all shrink-0"
onClick = { ( ) = > setSelectedFactorForDetail ( factor ) }
title = { t ( 'billing.viewDetails' ) }
>
< Ic name = "eye" className = "size-3.5" / >
< span > { t ( 'billing.viewDetails' ) } < / span >
< / Button >
< / div >
< / div >
< / TableCell >
< / TableRow >
< / div >
{ /* سطر ۳: فیش پرداخت و تاریخ */ }
< div className = "pt-2 border-t border-border-soft flex items-center justify-between text-xs" >
< div className = "flex items-center gap-2" >
< span className = "text-muted-foreground text-[11px]" > { t ( 'billing.receipt' ) } : < / span >
{ hasReceipt ? (
< button
type = "button"
onClick = { ( ) = > setSelectedFactorForLightbox ( factor ) }
className = "flex items-center gap-1 text-[11px] text-primary font-bold hover:underline"
>
< Ic name = "image" className = "size-3.5" / >
< span > { t ( 'billing.viewReceipt' ) } < / span >
< / button >
) : (
< span className = "text-[10.5px] text-muted-foreground bg-muted/40 px-2 py-0.5 rounded-md border border-border-soft" >
{ t ( 'billing.noReceipt' ) }
< / span >
) }
< / div >
< span className = "text-[11px] text-muted-foreground font-mono" dir = "rtl" >
{ formatDateByLanguage ( factor . created_at , language as any , { style : 'short' } ) }
< / span >
< / div >
{ /* سطر ۴: دکمه مشاهده جزئیات */ }
< div className = "pt-2 border-t border-border-soft flex items-center justify-end" >
< Button
variant = "default"
size = "sm"
className = "w-full sm:w-auto h-8 px-4 text-xs gap-1.5 rounded-xl font-bold shadow-2xs hover:brightness-105 active:scale-[0.98] transition-all"
onClick = { ( ) = > setSelectedFactorForDetail ( factor ) }
>
< Ic name = "eye" className = "size-3.5" / >
< span > { t ( 'billing.viewDetails' ) } < / span >
< / Button >
< / div >
< / div >
)
)
} ) ) }
< / TableBody >
< / Table >
< / div >
} )
) }
< / div >
< / >
) }
) }
{ /* صفحهبندی */ }
{ /* صفحهبندی واکنشگرا */ }
{ totalPages > 1 && (
{ totalPages > 1 && (
< div className = "p-3 border-t border-border-soft flex items-center justify-between text-xs bg-card/40" >
< span className = "text-grey-3" >
< div className = "p-3.5 sm:p-4 border-t border-border-soft flex flex-col sm:flex-row items-center justify-between gap-3 text-xs bg-card/40" >
< span className = "text-grey-3 text-center sm:text-start order-2 sm:order-1 " >
{ t ( 'common.showing' ) } { ( currentPage - 1 ) * PAGE_SIZE + 1 } { t ( 'common.to' ) } { ' ' }
{ t ( 'common.showing' ) } { ( currentPage - 1 ) * PAGE_SIZE + 1 } { t ( 'common.to' ) } { ' ' }
{ Math . min ( currentPage * PAGE_SIZE , totalCount ) } { t ( 'common.of' ) } { totalCount } { t ( 'billing.invoices' ) }
{ Math . min ( currentPage * PAGE_SIZE , totalCount ) } { t ( 'common.of' ) } { totalCount } { t ( 'billing.invoices' ) }
< / span >
< / span >
< div className = "flex items-center gap-1.5" >
< div className = "flex items-center gap-1.5 order-1 sm:order-2 " >
< Button
< Button
variant = "outline"
variant = "outline"
size = "sm"
size = "sm"
className = "h-7 text-xs rounded-xl"
className = "h-8 text-xs rounded-xl"
onClick = { ( ) = > {
onClick = { ( ) = > {
const p = Math . max ( 1 , currentPage - 1 )
const p = Math . max ( 1 , currentPage - 1 )
setCurrentPage ( p )
setCurrentPage ( p )
@ -859,7 +992,7 @@ export default function BillingPage() {
< Button
< Button
variant = "outline"
variant = "outline"
size = "sm"
size = "sm"
className = "h-7 text-xs rounded-xl"
className = "h-8 text-xs rounded-xl"
onClick = { ( ) = > {
onClick = { ( ) = > {
const p = Math . min ( totalPages , currentPage + 1 )
const p = Math . min ( totalPages , currentPage + 1 )
setCurrentPage ( p )
setCurrentPage ( p )