Spaces:
Running
Running
| import { useState } from 'react'; | |
| import { useTranslation } from 'react-i18next'; | |
| import { Loader2 } from 'lucide-react'; | |
| import { cn } from '@/lib/utils'; | |
| interface BranchData { | |
| branch: string; | |
| count: number; | |
| amount: number; | |
| } | |
| interface BranchChartProps { | |
| branches: BranchData[]; | |
| isLoading?: boolean; | |
| } | |
| const barColors = [ | |
| { gradient: 'from-blue-500 to-blue-400', glow: 'shadow-[0_0_15px_rgba(59,130,246,0.3)]', hover: 'group-hover:text-blue-500' }, | |
| { gradient: 'from-purple-500 to-purple-400', glow: 'shadow-[0_0_15px_rgba(139,92,246,0.3)]', hover: 'group-hover:text-purple-500' }, | |
| { gradient: 'from-teal-500 to-teal-400', glow: 'shadow-[0_0_15px_rgba(20,184,166,0.3)]', hover: 'group-hover:text-teal-500' }, | |
| { gradient: 'from-sky-500 to-sky-400', glow: 'shadow-[0_0_15px_rgba(14,165,233,0.3)]', hover: 'group-hover:text-sky-500' }, | |
| { gradient: 'from-indigo-500 to-indigo-400', glow: 'shadow-[0_0_15px_rgba(99,102,241,0.3)]', hover: 'group-hover:text-indigo-500' }, | |
| { gradient: 'from-rose-500 to-rose-400', glow: 'shadow-[0_0_15px_rgba(244,63,94,0.3)]', hover: 'group-hover:text-rose-500' }, | |
| { gradient: 'from-amber-500 to-amber-400', glow: 'shadow-[0_0_15px_rgba(245,158,11,0.3)]', hover: 'group-hover:text-amber-500' }, | |
| { gradient: 'from-emerald-500 to-emerald-400', glow: 'shadow-[0_0_15px_rgba(16,185,129,0.3)]', hover: 'group-hover:text-emerald-500' }, | |
| ]; | |
| function formatAmount(amount: number): string { | |
| if (amount >= 1_000_000) return `${(amount / 1_000_000).toFixed(1)}M`; | |
| if (amount >= 1_000) return `${(amount / 1_000).toFixed(0)}k`; | |
| return amount.toFixed(0); | |
| } | |
| export default function BranchChart({ branches, isLoading }: BranchChartProps) { | |
| const { t } = useTranslation(); | |
| const [hoveredIndex, setHoveredIndex] = useState<number | null>(null); | |
| const maxAmount = Math.max(...branches.map((b) => b.amount), 1); | |
| // Generate Y-axis labels based on actual data | |
| const yMax = Math.ceil(maxAmount / 1000) * 1000; | |
| const yLabels = [formatAmount(yMax), formatAmount(yMax * 0.66), formatAmount(yMax * 0.33), '0']; | |
| return ( | |
| <div className="lg:col-span-2 bg-card rounded-xl border border-border p-6 shadow-[0_10px_15px_-3px_rgba(0,0,0,0.05),0_4px_6px_-2px_rgba(0,0,0,0.025)] flex flex-col"> | |
| <div className="flex justify-between items-center mb-8"> | |
| <div> | |
| <h3 className="text-lg font-bold text-foreground"> | |
| {t('reports.chart.title')} | |
| </h3> | |
| <p className="text-xs text-muted-foreground"> | |
| {t('reports.chart.subtitle')} | |
| </p> | |
| </div> | |
| </div> | |
| {isLoading ? ( | |
| <div className="flex-1 flex items-center justify-center h-64"> | |
| <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> | |
| </div> | |
| ) : branches.length === 0 ? ( | |
| <div className="flex-1 flex items-center justify-center h-64 text-muted-foreground text-sm"> | |
| {t('reports.chart.noData', 'Aucune donnée disponible')} | |
| </div> | |
| ) : ( | |
| <div className="flex-1 flex items-end gap-6 md:gap-10 h-64 w-full px-4 pb-2 relative"> | |
| {/* Grid lines */} | |
| <div className="absolute inset-0 flex flex-col justify-between pointer-events-none pb-2 pl-8"> | |
| {[...Array(3)].map((_, i) => ( | |
| <div key={i} className="w-full border-t border-slate-100 border-dashed h-0" /> | |
| ))} | |
| <div className="w-full border-t border-slate-200 h-0" /> | |
| </div> | |
| {/* Y-axis labels */} | |
| <div className="absolute left-0 bottom-0 top-0 w-8 flex flex-col justify-between text-[10px] font-medium text-slate-400 h-full py-2 text-right pr-2"> | |
| {yLabels.map((label, i) => ( | |
| <span key={i}>{label}</span> | |
| ))} | |
| </div> | |
| {/* Bars */} | |
| {branches.map((branch, index) => { | |
| const color = barColors[index % barColors.length]; | |
| const heightPct = Math.max(5, (branch.amount / maxAmount) * 100); | |
| const formattedAmt = branch.amount.toLocaleString('fr-CA', { | |
| style: 'currency', | |
| currency: 'CAD', | |
| minimumFractionDigits: 0, | |
| }); | |
| return ( | |
| <div | |
| key={branch.branch} | |
| className="flex-1 flex flex-col justify-end group cursor-pointer h-full z-10" | |
| onMouseEnter={() => setHoveredIndex(index)} | |
| onMouseLeave={() => setHoveredIndex(null)} | |
| > | |
| <div | |
| className="w-full bg-slate-50 rounded-t-lg relative transition-all duration-300 flex flex-col justify-end overflow-hidden" | |
| style={{ height: `${heightPct}%` }} | |
| > | |
| <div | |
| className={cn( | |
| 'w-full h-full rounded-t-lg relative transition-all duration-300 origin-bottom', | |
| `bg-gradient-to-t ${color.gradient}`, | |
| color.glow, | |
| 'group-hover:brightness-110 group-hover:scale-y-105' | |
| )} | |
| > | |
| {/* Tooltip */} | |
| <div | |
| className={cn( | |
| 'absolute -top-14 left-1/2 -translate-x-1/2 bg-slate-800 text-white text-xs font-medium rounded-lg py-1.5 px-3 pointer-events-none whitespace-nowrap z-20 shadow-xl border border-slate-700 transition-opacity', | |
| hoveredIndex === index ? 'opacity-100' : 'opacity-0' | |
| )} | |
| > | |
| <div className="font-bold text-blue-200">{formattedAmt}</div> | |
| <div className="text-[10px] text-slate-300"> | |
| {branch.count} Transactions | |
| </div> | |
| <div className="absolute bottom-[-4px] left-1/2 -translate-x-1/2 w-2 h-2 bg-slate-800 rotate-45 border-r border-b border-slate-700" /> | |
| </div> | |
| </div> | |
| </div> | |
| <span | |
| className={cn( | |
| 'text-xs text-center mt-3 text-muted-foreground font-semibold truncate transition-colors', | |
| color.hover | |
| )} | |
| > | |
| {branch.branch || 'Montreal'} | |
| </span> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |