|
| 1 | +import { Cell, Pie, PieChart, TooltipProps } from 'recharts' |
| 2 | +import { useTranslation } from 'react-i18next' |
| 3 | +import { Calendar, ChevronLeft, ChevronRight, Users } from 'lucide-react' |
| 4 | + |
| 5 | +import useDirDetection from '@/hooks/use-dir-detection' |
| 6 | +import { Period } from '@/service/api' |
| 7 | +import { formatTooltipDate } from '@/utils/chart-period-utils' |
| 8 | +import { Badge } from '@/components/ui/badge' |
| 9 | +import { Button } from '@/components/ui/button' |
| 10 | +import { type ChartConfig, ChartContainer, ChartTooltip } from '@/components/ui/chart' |
| 11 | +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' |
| 12 | + |
| 13 | +export type UserSubUpdateStatsModalData = { |
| 14 | + time: string |
| 15 | + _period_start: string |
| 16 | + [key: string]: string | number |
| 17 | +} |
| 18 | + |
| 19 | +export type UserSubUpdateStatsModalSeries = { |
| 20 | + key: string |
| 21 | + label: string |
| 22 | + color: string |
| 23 | +} |
| 24 | + |
| 25 | +type UserSubUpdateStatsModalProps = { |
| 26 | + open: boolean |
| 27 | + onClose: () => void |
| 28 | + data: UserSubUpdateStatsModalData | null |
| 29 | + period: Period |
| 30 | + series: UserSubUpdateStatsModalSeries[] |
| 31 | + allChartData?: UserSubUpdateStatsModalData[] |
| 32 | + currentIndex?: number |
| 33 | + onNavigate?: (index: number) => void |
| 34 | +} |
| 35 | + |
| 36 | +type PieDataPoint = { |
| 37 | + name: string |
| 38 | + count: number |
| 39 | + percentage: number |
| 40 | + fill: string |
| 41 | +} |
| 42 | + |
| 43 | +function ClientDistributionTooltip({ active, payload }: TooltipProps<number, string>) { |
| 44 | + const { t } = useTranslation() |
| 45 | + |
| 46 | + if (!active || !payload || !payload.length) return null |
| 47 | + |
| 48 | + const data = payload[0].payload as PieDataPoint |
| 49 | + |
| 50 | + return ( |
| 51 | + <div className="border-border bg-background/95 rounded-lg border p-2 text-xs shadow-sm backdrop-blur-sm"> |
| 52 | + <div className="mb-1 flex items-center gap-1.5"> |
| 53 | + <div className="border-border/20 h-2.5 w-2.5 rounded-full border" style={{ backgroundColor: data.fill }} /> |
| 54 | + <span className="text-foreground font-medium">{data.name}</span> |
| 55 | + </div> |
| 56 | + <div className="flex items-center justify-between gap-3"> |
| 57 | + <span className="text-muted-foreground">{t('statistics.subscriptions', { defaultValue: 'Subscriptions' })}</span> |
| 58 | + <span dir="ltr" className="text-foreground font-mono font-semibold"> |
| 59 | + {data.count.toLocaleString()} |
| 60 | + </span> |
| 61 | + </div> |
| 62 | + <div className="mt-1 flex items-center justify-between gap-3"> |
| 63 | + <span className="text-muted-foreground">{t('statistics.percentage', { defaultValue: 'Percentage' })}</span> |
| 64 | + <span dir="ltr" className="text-foreground font-mono">{`${data.percentage.toFixed(1)}%`}</span> |
| 65 | + </div> |
| 66 | + </div> |
| 67 | + ) |
| 68 | +} |
| 69 | + |
| 70 | +export default function UserSubUpdateStatsModal({ open, onClose, data, period, series, allChartData = [], currentIndex = 0, onNavigate }: UserSubUpdateStatsModalProps) { |
| 71 | + const { t, i18n } = useTranslation() |
| 72 | + const dir = useDirDetection() |
| 73 | + |
| 74 | + if (!data) return null |
| 75 | + |
| 76 | + const isRTL = dir === 'rtl' |
| 77 | + const formattedDate = data._period_start ? formatTooltipDate(data._period_start, period, i18n.language) : data.time |
| 78 | + const rows = series |
| 79 | + .map(item => ({ |
| 80 | + ...item, |
| 81 | + count: Number(data[item.key] || 0), |
| 82 | + })) |
| 83 | + .filter(item => item.count > 0) |
| 84 | + .sort((a, b) => b.count - a.count) |
| 85 | + const total = rows.reduce((sum, item) => sum + item.count, 0) |
| 86 | + const pieData: PieDataPoint[] = rows.map(item => ({ |
| 87 | + name: item.label, |
| 88 | + count: item.count, |
| 89 | + percentage: total > 0 ? (item.count * 100) / total : 0, |
| 90 | + fill: item.color, |
| 91 | + })) |
| 92 | + const pieChartConfig = pieData.reduce<ChartConfig>((acc, item) => { |
| 93 | + acc[item.name] = { label: item.name, color: item.fill } |
| 94 | + return acc |
| 95 | + }, {}) |
| 96 | + const piePaddingAngle = pieData.length > 1 ? 1 : 0 |
| 97 | + |
| 98 | + const hasNavigation = allChartData.length > 1 |
| 99 | + const canGoPrevious = hasNavigation && currentIndex > 0 |
| 100 | + const canGoNext = hasNavigation && currentIndex < allChartData.length - 1 |
| 101 | + |
| 102 | + const handlePrevious = () => { |
| 103 | + if (canGoPrevious) onNavigate?.(currentIndex - 1) |
| 104 | + } |
| 105 | + |
| 106 | + const handleNext = () => { |
| 107 | + if (canGoNext) onNavigate?.(currentIndex + 1) |
| 108 | + } |
| 109 | + |
| 110 | + const handleLeftButton = isRTL ? handleNext : handlePrevious |
| 111 | + const handleRightButton = isRTL ? handlePrevious : handleNext |
| 112 | + const canGoLeft = isRTL ? canGoNext : canGoPrevious |
| 113 | + const canGoRight = isRTL ? canGoPrevious : canGoNext |
| 114 | + |
| 115 | + return ( |
| 116 | + <Dialog open={open} onOpenChange={onClose}> |
| 117 | + <DialogContent className="flex max-h-[95dvh] w-[96vw] max-w-md flex-col overflow-hidden sm:max-w-2xl md:max-w-3xl" dir={dir}> |
| 118 | + <DialogHeader> |
| 119 | + <div className="flex flex-col items-start gap-3"> |
| 120 | + <DialogTitle className="flex items-center gap-2"> |
| 121 | + <Users className="h-4 w-4 sm:h-5 sm:w-5" /> |
| 122 | + {t('statistics.subscriptionDistributionDetails', { defaultValue: 'Subscription Client Details' })} |
| 123 | + </DialogTitle> |
| 124 | + {hasNavigation && ( |
| 125 | + <div className="flex w-full items-center justify-center gap-1"> |
| 126 | + <Button variant="outline" size="sm" onClick={handleLeftButton} disabled={!canGoLeft} className="h-8 w-8 p-0"> |
| 127 | + <ChevronLeft className={`h-4 w-4 ${isRTL ? 'rotate-180' : ''}`} /> |
| 128 | + </Button> |
| 129 | + <span dir="ltr" className="text-muted-foreground min-w-[60px] text-center text-sm font-medium"> |
| 130 | + {currentIndex + 1} / {allChartData.length} |
| 131 | + </span> |
| 132 | + <Button variant="outline" size="sm" onClick={handleRightButton} disabled={!canGoRight} className="h-8 w-8 p-0"> |
| 133 | + <ChevronRight className={`h-4 w-4 ${isRTL ? 'rotate-180' : ''}`} /> |
| 134 | + </Button> |
| 135 | + </div> |
| 136 | + )} |
| 137 | + </div> |
| 138 | + </DialogHeader> |
| 139 | + |
| 140 | + <div className="min-h-0 flex-1 space-y-3 overflow-x-hidden overflow-y-auto pr-1 sm:space-y-4"> |
| 141 | + <div className={`flex items-center justify-between gap-3 ${isRTL ? 'flex-row-reverse' : 'flex-row'}`}> |
| 142 | + <div className={`flex min-w-0 items-center gap-2 ${isRTL ? 'flex-row-reverse' : 'flex-row'}`}> |
| 143 | + <Calendar className="text-muted-foreground h-3 w-3 shrink-0 sm:h-4 sm:w-4" /> |
| 144 | + <span className="truncate text-xs font-medium sm:text-sm" dir="ltr"> |
| 145 | + {formattedDate} |
| 146 | + </span> |
| 147 | + </div> |
| 148 | + <Badge dir="ltr" variant="secondary" className="px-2 py-1 font-mono text-xs sm:text-sm"> |
| 149 | + {total.toLocaleString()} |
| 150 | + </Badge> |
| 151 | + </div> |
| 152 | + |
| 153 | + <div className="text-muted-foreground text-xs font-medium">{t('statistics.subscriptionDistribution', { defaultValue: 'Subscription Client Distribution' })}</div> |
| 154 | + |
| 155 | + {pieData.length > 0 ? ( |
| 156 | + <div className="flex flex-col gap-3 md:flex-row md:items-start"> |
| 157 | + <div className="flex justify-center md:w-[200px] md:flex-shrink-0"> |
| 158 | + <ChartContainer config={pieChartConfig} className="h-[132px] w-[132px] sm:h-[160px] sm:w-[160px] md:h-[180px] md:w-[180px] [&_.recharts-text]:fill-transparent"> |
| 159 | + <PieChart> |
| 160 | + <ChartTooltip content={<ClientDistributionTooltip />} /> |
| 161 | + <Pie data={pieData} dataKey="count" nameKey="name" innerRadius="58%" outerRadius="96%" paddingAngle={piePaddingAngle} strokeWidth={1.5}> |
| 162 | + {pieData.map(item => ( |
| 163 | + <Cell key={item.name} fill={item.fill} /> |
| 164 | + ))} |
| 165 | + </Pie> |
| 166 | + </PieChart> |
| 167 | + </ChartContainer> |
| 168 | + </div> |
| 169 | + |
| 170 | + <div dir="ltr" className="grid max-h-[36dvh] min-w-0 flex-1 gap-2 overflow-x-hidden overflow-y-auto sm:gap-3 md:max-h-80"> |
| 171 | + {pieData.map(item => ( |
| 172 | + <div key={item.name} className="flex items-center justify-between gap-3 rounded-lg border p-2 sm:p-3"> |
| 173 | + <div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3"> |
| 174 | + <div className="h-2 w-2 shrink-0 rounded-full sm:h-3 sm:w-3" style={{ backgroundColor: item.fill }} /> |
| 175 | + <div className="min-w-0 flex-1"> |
| 176 | + <div className="flex items-center gap-1.5"> |
| 177 | + <span dir="ltr" className="text-foreground font-mono text-[10px] font-bold sm:text-xs"> |
| 178 | + {item.percentage.toFixed(1)}% |
| 179 | + </span> |
| 180 | + <div className="truncate text-xs font-medium break-all sm:text-sm">{item.name}</div> |
| 181 | + </div> |
| 182 | + </div> |
| 183 | + </div> |
| 184 | + <span dir="ltr" className="shrink-0 font-mono text-xs font-semibold sm:text-sm"> |
| 185 | + {item.count.toLocaleString()} |
| 186 | + </span> |
| 187 | + </div> |
| 188 | + ))} |
| 189 | + </div> |
| 190 | + </div> |
| 191 | + ) : ( |
| 192 | + <div className="text-muted-foreground rounded-md border border-dashed p-3 text-center text-xs">{t('statistics.noDataInRange', { defaultValue: 'No data in selected range' })}</div> |
| 193 | + )} |
| 194 | + </div> |
| 195 | + </DialogContent> |
| 196 | + </Dialog> |
| 197 | + ) |
| 198 | +} |
0 commit comments