DEV Community

Nebula
Nebula

Posted on

analytics2

import React, { useState, useEffect, useMemo, useCallback } from 'react';
import styled from 'styled-components';
import { motion, AnimatePresence } from 'framer-motion';
import { Search, Filter, X, RefreshCw, AlertCircle, TrendingUp, TrendingDown, ArrowUpDown } from 'lucide-react';
import {
  ResponsiveContainer,
  BarChart,
  Bar,
  LineChart,
  Line,
  PieChart,
  Pie,
  Cell,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  Legend,
} from 'recharts';
import { toast } from 'react-hot-toast'; // Assuming you use react-hot-toast

// Assume 'api' is configured and imported from your api setup
// import api from '../api';
// Assume 'user' object is available from a context or props
// const { user } = useAuth();

// Styled Components (as provided in the prompt)
const colors = {
  primary: '#667eea',
  primaryDark: '#5a67d8',
  secondary: '#764ba2',
  success: '#48bb78',
  error: '#ff6b6b',
  warning: '#f6ad55',
  text: '#1a202c',
  textSecondary: '#4a5568',
  textMuted: '#a0aec0',
  background: 'rgba(255, 255, 255, 0.95)',
  border: 'rgba(102, 126, 234, 0.2)',
  borderHover: '#667eea',
  glass: 'rgba(255, 255, 255, 0.95)',
  glassLight: 'rgba(255, 255, 255, 0.8)',
  shadow: 'rgba(0, 0, 0, 0.1)',
  shadowHover: 'rgba(102, 126, 234, 0.1)'
};

const gradients = {
  primary: `linear-gradient(135deg, ${colors.primary}, ${colors.secondary})`,
};

const spacing = {
  sm: '8px',
  md: '12px',
  lg: '16px',
  xl: '20px',
  xxl: '24px',
  xxxl: '32px'
};

const Container = styled(motion.div)`
  padding: ${spacing.xxxl};
  max-width: 1600px;
  margin: 0 auto;
  h2 {
    font-size: 32px;
    font-weight: 800;
    color: ${colors.text};
    margin-bottom: ${spacing.xxxl};
    background: ${gradients.primary};
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
    text-align: center;
  }
  @media (max-width: 768px) { padding: ${spacing.xl}; }
`;

const FilterSection = styled(motion.div)`
  background: ${colors.glass};
  backdrop-filter: blur(20px);
  border-radius: 20px;
  padding: ${spacing.xxl};
  margin-bottom: ${spacing.xxxl};
  box-shadow: 0 8px 32px ${colors.shadow};
  border: 1px solid rgba(255, 255, 255, 0.2);
`;

const SearchContainer = styled.div`
  position: relative;
  margin-bottom: ${spacing.xl};
  svg {
    position: absolute;
    left: ${spacing.lg};
    top: 50%;
    transform: translateY(-50%);
    color: ${colors.textMuted};
  }
`;

const SearchInput = styled.input`
  width: 100%;
  padding: ${spacing.lg} ${spacing.lg} ${spacing.lg} 48px;
  border: 2px solid ${colors.border};
  border-radius: 12px;
  font-size: 16px;
  background: ${colors.glassLight};
  &:focus {
    outline: none;
    border-color: ${colors.borderHover};
    box-shadow: 0 0 0 4px ${colors.shadowHover};
  }
`;

const FiltersGrid = styled.div`
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: ${spacing.lg};
  margin-bottom: ${spacing.xl};
`;

const FilterSelect = styled.select`
  padding: ${spacing.lg};
  border: 2px solid ${colors.border};
  border-radius: 12px;
  font-size: 16px;
  background: ${colors.glassLight};
  cursor: pointer;
  &:focus {
    outline: none;
    border-color: ${colors.borderHover};
    box-shadow: 0 0 0 4px ${colors.shadowHover};
  }
`;

const DateInput = styled.input`
    padding: ${spacing.lg};
    border: 2px solid ${colors.border};
    border-radius: 12px;
    font-size: 16px;
    background: ${colors.glassLight};
    color: ${colors.textSecondary};
    cursor: pointer;
    &:focus {
        outline: none;
        border-color: ${colors.borderHover};
        box-shadow: 0 0 0 4px ${colors.shadowHover};
    }
`;

const ButtonGroup = styled.div`
  display: flex;
  gap: ${spacing.md};
  justify-content: flex-end;
  margin-top: ${spacing.xl};
`;

const Button = styled(motion.button)`
  display: flex;
  align-items: center;
  gap: ${spacing.sm};
  padding: ${spacing.md} ${spacing.xl};
  border-radius: 12px;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  border: 2px solid transparent;
  transition: all 0.3s ease;

  ${props => props.variant === 'primary' ? `
    background: ${gradients.primary};
    color: white;
    box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4);
    &:hover:not(:disabled) {
      transform: translateY(-2px);
      box-shadow: 0 8px 25px rgba(102, 126, 234, 0.5);
    }
  ` : `
    background: transparent;
    color: ${colors.textSecondary};
    border-color: #e2e8f0;
    &:hover:not(:disabled) {
      background: #f7fafc;
      border-color: #cbd5e0;
    }
  `}

  &:disabled { opacity: 0.6; cursor: not-allowed; }
`;

const AnalyticsGrid = styled(motion.div)`
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
    gap: ${spacing.xxl};
    margin-bottom: ${spacing.xxxl};
`;

const ChartContainer = styled.div`
    background: ${colors.glass};
    backdrop-filter: blur(20px);
    border-radius: 20px;
    padding: ${spacing.xl};
    box-shadow: 0 8px 32px ${colors.shadow};
    border: 1px solid rgba(255, 255, 255, 0.2);
    h3 {
        font-size: 18px;
        font-weight: 600;
        color: ${colors.text};
        margin-bottom: ${spacing.xl};
    }
`;

const TableContainer = styled.div`
  background: ${colors.glass};
  backdrop-filter: blur(20px);
  border-radius: 20px;
  overflow: hidden;
  box-shadow: 0 8px 32px ${colors.shadow};
`;

const Table = styled.table`
  width: 100%;
  border-collapse: separate;
  border-spacing: 0;
  th {
    background: ${gradients.primary};
    color: white;
    padding: ${spacing.lg};
    text-align: left;
    font-weight: 600;
    font-size: 14px;
    cursor: pointer;
    white-space: nowrap;
    &:hover { background: ${colors.primaryDark}; }
  }
  td {
    padding: ${spacing.lg};
    color: ${colors.textSecondary};
    border-bottom: 1px solid rgba(226, 232, 240, 0.8);
  }
  tr:last-child td { border-bottom: none; }
  tr:hover { background: ${colors.shadowHover}; }
`;

const StatusBadge = styled.span`
  padding: 6px ${spacing.md};
  border-radius: 20px;
  font-size: 12px;
  font-weight: 600;
  ${({ status }) => {
    switch (status) {
      case 'SUCCESS': return `background-color: ${colors.success}20; color: ${colors.success};`;
      case 'PENDING': return `background-color: ${colors.warning}20; color: ${colors.warning};`;
      case 'FAILED': return `background-color: ${colors.error}20; color: ${colors.error};`;
      default: return `background-color: ${colors.textMuted}20; color: ${colors.textMuted};`;
    }
  }}
`;

const TypeBadge = styled.span`
  padding: 6px ${spacing.md};
  border-radius: 20px;
  font-size: 12px;
  font-weight: 600;
  ${({ type }) => {
    switch (type) {
      case 'DEPOSIT': return `background-color: ${colors.success}20; color: ${colors.success};`;
      case 'WITHDRAW': return `background-color: ${colors.error}20; color: ${colors.error};`;
      case 'TRANSFER': return `background-color: ${colors.primary}20; color: ${colors.primary};`;
      default: return `background-color: ${colors.textMuted}20; color: ${colors.textMuted};`;
    }
  }}
`;

const LoadingSpinner = styled(motion.div)`
  display: flex; justify-content: center; padding: 64px;
  svg { animation: spin 1s linear infinite; }
  @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
`;

const ErrorMessage = styled(motion.div)`
  display: flex; flex-direction: column; align-items: center; gap: ${spacing.lg};
  padding: 64px; text-align: center; color: ${colors.error};
`;

const EmptyState = styled(motion.div)`
  display: flex; flex-direction: column; align-items: center; gap: ${spacing.lg};
  padding: 64px; text-align: center; color: ${colors.textMuted};
`;


// Mock user for demonstration. In a real app, this would come from an auth context.
const user = { id: 1, name: 'Kishan' };

// Mock API for demonstration
const api = {
    get: async (url) => {
        console.log(`Fetching ${url}...`);
        await new Promise(res => setTimeout(res, 500)); // Simulate network delay
        if (url.startsWith('/accounts/user/')) {
            return { data: { message: "Accounts retrieved successfully", data: [ { id: 1, accountNumber: "98765432155", accountType: "SAVINGS", balance: 1450.01, userDTO: { id: 1, name: "kishan" } }, { id: 52, accountNumber: "98765432188", accountType: "SALARY", balance: 25000.00, userDTO: { id: 1, name: "kishan" } } ] }};
        }
        if (url.startsWith('/transactions/user/')) {
            return { data: { message: "Transactions fetched successfully", data: [ { id: 1, fromAccount: { id: 52, accountNumber: "98765432188" }, toAccount: { id: 1, accountNumber: "98765432155" }, type: "TRANSFER", amount: 500.00, description: "Monthly savings", timestamp: "2025-07-01T10:00:00Z", status: "SUCCESS" }, { id: 2, toAccount: { id: 52, accountNumber: "98765432188" }, type: "DEPOSIT", amount: 25000.00, description: "July Salary", timestamp: "2025-07-01T09:00:00Z", status: "SUCCESS" }, { id: 3, fromAccount: { id: 1, accountNumber: "98765432155" }, type: "WITHDRAW", amount: 150.00, description: "Grocery Shopping", timestamp: "2025-07-02T18:30:00Z", status: "SUCCESS" }, { id: 4, fromAccount: { id: 1, accountNumber: "98765432155" }, type: "WITHDRAW", amount: 80.00, description: "Dinner with friends", timestamp: "2025-06-15T20:00:00Z", status: "SUCCESS" }, { id: 5, toAccount: { id: 1, accountNumber: "98765432155" }, type: "DEPOSIT", amount: 1000.00, description: "Freelance Payment", timestamp: "2025-06-20T14:00:00Z", status: "SUCCESS" } ] }};
        }
        return { data: { data: [] } };
    }
};

const TransactionAnalytics = () => {
  const [transactions, setTransactions] = useState([]);
  const [userAccounts, setUserAccounts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [accountsLoading, setAccountsLoading] = useState(true);
  const [error, setError] = useState(null);

  const [filters, setFilters] = useState({
    searchTerm: '',
    account: 'all',
    type: 'all',
    startDate: '',
    endDate: '',
  });

  const [sortConfig, setSortConfig] = useState({ key: 'timestamp', direction: 'desc' });

  // --- DATA FETCHING ---
  const fetchUserAccounts = useCallback(async () => {
    if (!user?.id) return;
    try {
      setAccountsLoading(true);
      const response = await api.get(`/accounts/user/${user.id}`);
      setUserAccounts(response.data.data);
    } catch (err) {
      console.error('Error fetching user accounts:', err);
      toast.error('Failed to load user accounts');
    } finally {
      setAccountsLoading(false);
    }
  }, []); // user.id is static in this example

  const fetchTransactions = useCallback(async () => {
    if (!user?.id) return;
    try {
      setLoading(true);
      setError(null);
      const response = await api.get(`/transactions/user/${user.id}`);
      setTransactions(response.data.data);
    } catch (err) {
      setError('Failed to load transactions. Please try again.');
      console.error('Error fetching transactions:', err);
    } finally {
      setLoading(false);
    }
  }, []); // user.id is static

  const handleRefresh = () => {
      toast.success('Refreshing data...');
      fetchUserAccounts();
      fetchTransactions();
  }

  useEffect(() => {
    handleRefresh();
  }, [fetchUserAccounts, fetchTransactions]);


  // --- FILTERING & SORTING ---
  const filteredAndSortedTransactions = useMemo(() => {
    let filtered = transactions.filter(t => {
      const transactionDate = new Date(t.timestamp);
      const startDate = filters.startDate ? new Date(filters.startDate) : null;
      const endDate = filters.endDate ? new Date(filters.endDate) : null;

      if(startDate) startDate.setHours(0,0,0,0);
      if(endDate) endDate.setHours(23,59,59,999);

      const inDateRange = (!startDate || transactionDate >= startDate) && (!endDate || transactionDate <= endDate);
      const inSearch = t.description.toLowerCase().includes(filters.searchTerm.toLowerCase());
      const inAccount = filters.account === 'all' || t.fromAccount?.accountNumber === filters.account || t.toAccount?.accountNumber === filters.account;
      const inType = filters.type === 'all' || t.type === filters.type;

      return inDateRange && inSearch && inAccount && inType;
    });

    return filtered.sort((a, b) => {
      if (a[sortConfig.key] < b[sortConfig.key]) {
        return sortConfig.direction === 'asc' ? -1 : 1;
      }
      if (a[sortConfig.key] > b[sortConfig.key]) {
        return sortConfig.direction === 'asc' ? 1 : -1;
      }
      return 0;
    });
  }, [transactions, filters, sortConfig]);

  const handleSort = (key) => {
    setSortConfig(prev => ({
      key,
      direction: prev.key === key && prev.direction === 'asc' ? 'desc' : 'asc'
    }));
  };

  const handleFilterChange = (e) => {
    const { name, value } = e.target;
    setFilters(prev => ({ ...prev, [name]: value }));
  };

  const resetFilters = () => {
    setFilters({ searchTerm: '', account: 'all', type: 'all', startDate: '', endDate: '' });
    setSortConfig({ key: 'timestamp', direction: 'desc' });
    toast.success("Filters Reset");
  };


  // --- CHART DATA PROCESSING ---
  const analyticsData = useMemo(() => {
    const incomeVsExpense = [
        { name: 'Income', amount: filteredAndSortedTransactions.filter(t => t.type === 'DEPOSIT').reduce((sum, t) => sum + t.amount, 0) },
        { name: 'Expenses', amount: filteredAndSortedTransactions.filter(t => ['WITHDRAW', 'TRANSFER'].includes(t.type)).reduce((sum, t) => sum + t.amount, 0) }
    ];

    const typeDistribution = Object.entries(
      filteredAndSortedTransactions.reduce((acc, t) => {
        acc[t.type] = (acc[t.type] || 0) + 1;
        return acc;
      }, {})
    ).map(([name, value]) => ({ name, value }));

    const trendData = filteredAndSortedTransactions
      .map(t => ({ ...t, date: new Date(t.timestamp).toISOString().split('T')[0] }))
      .reduce((acc, t) => {
        if (!acc[t.date]) acc[t.date] = { date: t.date, amount: 0 };
        acc[t.date].amount += t.amount;
        return acc;
      }, {});

    const sortedTrendData = Object.values(trendData).sort((a,b) => new Date(a.date) - new Date(b.date));

    return { incomeVsExpense, typeDistribution, trendData: sortedTrendData };
  }, [filteredAndSortedTransactions]);

  const PIE_COLORS = {
      DEPOSIT: colors.success,
      WITHDRAW: colors.error,
      TRANSFER: colors.primary
  };

  const CustomTooltip = ({ active, payload, label }) => {
    if (active && payload && payload.length) {
      return (
        <div style={{ background: 'white', padding: '10px', border: `1px solid ${colors.border}`, borderRadius: '10px' }}>
          <p style={{ fontWeight: 600, color: colors.text }}>{`Date: ${label}`}</p>
          <p style={{ color: colors.primary }}>{`Amount: $${payload[0].value.toFixed(2)}`}</p>
        </div>
      );
    }
    return null;
  };

  // --- RENDER LOGIC ---
  if (error) {
    return (
      <Container>
        <ErrorMessage initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}>
          <AlertCircle size={48} />
          <h3>Oops! Something went wrong.</h3>
          <p>{error}</p>
          <Button variant="primary" onClick={handleRefresh}><RefreshCw size={16} /> Try Again</Button>
        </ErrorMessage>
      </Container>
    );
  }

  return (
    <Container initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.5 }}>
      <h2>Transaction Analytics</h2>

      <FilterSection initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}>
        <SearchContainer>
          <Search size={20} />
          <SearchInput
            type="text"
            name="searchTerm"
            placeholder="Search by description..."
            value={filters.searchTerm}
            onChange={handleFilterChange}
          />
        </SearchContainer>
        <FiltersGrid>
          <FilterSelect name="account" value={filters.account} onChange={handleFilterChange} disabled={accountsLoading}>
            <option value="all">All Accounts</option>
            {userAccounts.map(acc => (
              <option key={acc.id} value={acc.accountNumber}>
                {acc.accountType} - {acc.accountNumber}
              </option>
            ))}
          </FilterSelect>
          <FilterSelect name="type" value={filters.type} onChange={handleFilterChange}>
            <option value="all">All Types</option>
            <option value="DEPOSIT">Deposit</option>
            <option value="WITHDRAW">Withdraw</option>
            <option value="TRANSFER">Transfer</option>
          </FilterSelect>
          <DateInput type="date" name="startDate" value={filters.startDate} onChange={handleFilterChange} />
          <DateInput type="date" name="endDate" value={filters.endDate} onChange={handleFilterChange} />
        </FiltersGrid>
        <ButtonGroup>
            <Button onClick={resetFilters} whileTap={{ scale: 0.95 }}>
                <X size={16} /> Reset
            </Button>
            <Button variant="primary" onClick={handleRefresh} disabled={loading || accountsLoading} whileTap={{ scale: 0.95 }}>
                <RefreshCw size={16} /> Refresh Data
            </Button>
        </ButtonGroup>
      </FilterSection>

      {loading ? (
        <LoadingSpinner><RefreshCw size={48} /></LoadingSpinner>
      ) : (
        <>
            <AnalyticsGrid initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.4 }}>
                <ChartContainer>
                    <h3>Income vs. Expense</h3>
                    <ResponsiveContainer width="100%" height={300}>
                        <BarChart data={analyticsData.incomeVsExpense} margin={{ top: 5, right: 20, left: -10, bottom: 5 }}>
                            <CartesianGrid strokeDasharray="3 3" stroke={colors.border}/>
                            <XAxis dataKey="name" stroke={colors.textSecondary} />
                            <YAxis stroke={colors.textSecondary}/>
                            <Tooltip cursor={{fill: 'rgba(102, 126, 234, 0.1)'}}/>
                            <Legend />
                            <Bar dataKey="amount" name="Amount">
                                {analyticsData.incomeVsExpense.map((entry, index) => (
                                    <Cell key={`cell-${index}`} fill={entry.name === 'Income' ? colors.success : colors.error} />
                                ))}
                            </Bar>
                        </BarChart>
                    </ResponsiveContainer>
                </ChartContainer>
                 <ChartContainer>
                    <h3>Transaction Trend</h3>
                    <ResponsiveContainer width="100%" height={300}>
                        <LineChart data={analyticsData.trendData} margin={{ top: 5, right: 20, left: -10, bottom: 5 }}>
                            <CartesianGrid strokeDasharray="3 3" stroke={colors.border} />
                            <XAxis dataKey="date" stroke={colors.textSecondary} tickFormatter={(str) => new Date(str).toLocaleDateString('en-US', {month: 'short', day: 'numeric'})} />
                            <YAxis stroke={colors.textSecondary}/>
                            <Tooltip content={<CustomTooltip />}/>
                            <Legend />
                            <Line type="monotone" dataKey="amount" stroke={colors.primary} strokeWidth={2} dot={{ r: 4 }} activeDot={{ r: 8 }} name="Total Amount"/>
                        </LineChart>
                    </ResponsiveContainer>
                </ChartContainer>
                <ChartContainer>
                    <h3>Transaction by Type</h3>
                    <ResponsiveContainer width="100%" height={300}>
                        <PieChart>
                            <Pie data={analyticsData.typeDistribution} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={100} label>
                                {analyticsData.typeDistribution.map((entry, index) => (
                                    <Cell key={`cell-${index}`} fill={PIE_COLORS[entry.name]} />
                                ))}
                            </Pie>
                            <Tooltip />
                            <Legend />
                        </PieChart>
                    </ResponsiveContainer>
                </ChartContainer>
            </AnalyticsGrid>

            <AnimatePresence>
                {filteredAndSortedTransactions.length > 0 ? (
                <TableContainer initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.6 }}>
                    <Table>
                    <thead>
                        <tr>
                            <th onClick={() => handleSort('timestamp')}>Date <ArrowUpDown size={14} /></th>
                            <th onClick={() => handleSort('description')}>Description <ArrowUpDown size={14} /></th>
                            <th>From</th>
                            <th>To</th>
                            <th onClick={() => handleSort('type')}>Type <ArrowUpDown size={14} /></th>
                            <th onClick={() => handleSort('amount')}>Amount <ArrowUpDown size={14} /></th>
                            <th onClick={() => handleSort('status')}>Status <ArrowUpDown size={14} /></th>
                        </tr>
                    </thead>
                    <tbody>
                        {filteredAndSortedTransactions.map(t => (
                        <motion.tr key={t.id} initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
                            <td>{new Date(t.timestamp).toLocaleDateString()}</td>
                            <td>{t.description}</td>
                            <td>{t.fromAccount?.accountNumber || 'N/A'}</td>
                            <td>{t.toAccount?.accountNumber || 'N/A'}</td>
                            <td><TypeBadge type={t.type}>{t.type}</TypeBadge></td>
                            <td style={{ color: ['DEPOSIT'].includes(t.type) ? colors.success : colors.error, fontWeight: '600' }}>
                               {['DEPOSIT'].includes(t.type) ? '+' : '-'} ${t.amount.toFixed(2)}
                            </td>
                            <td><StatusBadge status={t.status}>{t.status}</StatusBadge></td>
                        </motion.tr>
                        ))}
                    </tbody>
                    </Table>
                </TableContainer>
                ) : (
                <EmptyState initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.6 }}>
                    <Filter size={48} />
                    <h3>No Transactions Found</h3>
                    <p>Try adjusting your filters or click Refresh to fetch the latest data.</p>
                </EmptyState>
                )}
            </AnimatePresence>
        </>
      )}
    </Container>
  );
};

export default TransactionAnalytics;
Enter fullscreen mode Exit fullscreen mode

Top comments (0)