diff --git a/Customer-chat b/Customer-chat new file mode 100644 index 0000000..3e2c1c3 --- /dev/null +++ b/Customer-chat @@ -0,0 +1,13 @@ +#158 Customer Support Chat Integration +Repo Avatar +nathydre21/nepa +Description*: No integrated support system. Add chat with ticketing functionality. + +Technical Requirements: + +Integrate third-party chat service (Intercom, Zendesk) +Create ticket management system with SLA tracking +Implement chatbot for common queries using NLP +Add support agent dashboard with user context +Create knowledge base integration +Impact: Better customer support, reduced support costs diff --git a/nepa-frontend/package.json b/nepa-frontend/package.json index c0650de..8229326 100644 --- a/nepa-frontend/package.json +++ b/nepa-frontend/package.json @@ -18,6 +18,7 @@ "@walletconnect/sign-client": "^2.23.6", "albedo": "^0.1.3", "date-fns": "^4.1.0", + "lucide-react": "^0.479.0", "react": "^19.2.0", "react-dom": "^19.2.0", "recharts": "^3.7.0" diff --git a/nepa-frontend/src/App.tsx b/nepa-frontend/src/App.tsx index 73fef85..58a464d 100644 --- a/nepa-frontend/src/App.tsx +++ b/nepa-frontend/src/App.tsx @@ -166,9 +166,22 @@ const AppContent: React.FC = () => {

NEPA Platform

-
- - + + +
diff --git a/nepa-frontend/src/components/AdvancedDataTable.tsx b/nepa-frontend/src/components/AdvancedDataTable.tsx index 6ee288f..63120bc 100644 --- a/nepa-frontend/src/components/AdvancedDataTable.tsx +++ b/nepa-frontend/src/components/AdvancedDataTable.tsx @@ -1 +1,646 @@ -import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';\nimport { Search, Filter, Download, ChevronUp, ChevronDown, MoreVertical, Eye, Edit, Trash2, ChevronLeft, ChevronRight, X, Check } from 'lucide-react';\n\ninterface TableColumn {\n key: string;\n label: string;\n sortable?: boolean;\n filterable?: boolean;\n width?: string;\n render?: (value: any, row: any) => React.ReactNode;\n type?: 'text' | 'number' | 'date' | 'boolean' | 'custom';\n format?: (value: any) => string;\n}\n\ninterface TableAction {\n key: string;\n label: string;\n icon: React.ReactNode;\n onClick: (row: any) => void;\n disabled?: (row: any) => boolean;\n variant?: 'primary' | 'secondary' | 'danger';\n}\n\ninterface FilterOption {\n key: string;\n label: string;\n value: any;\n type: 'text' | 'select' | 'date' | 'number';\n options?: { label: string; value: any }[];\n}\n\ninterface AdvancedTableProps {\n data: any[];\n columns: TableColumn[];\n actions?: TableAction[];\n loading?: boolean;\n pagination?: {\n page: number;\n pageSize: number;\n total: number;\n onPageChange: (page: number) => void;\n onPageSizeChange: (pageSize: number) => void;\n };\n sorting?: {\n field: string;\n direction: 'asc' | 'desc';\n onSort: (field: string, direction: 'asc' | 'desc') => void;\n };\n filtering?: {\n filters: FilterOption[];\n values: { [key: string]: any };\n onFilterChange: (values: { [key: string]: any }) => void;\n };\n selection?: {\n selectedRows: any[];\n onSelectionChange: (selectedRows: any[]) => void;\n };\n exportOptions?: {\n csv?: boolean;\n excel?: boolean;\n pdf?: boolean;\n onExport: (format: 'csv' | 'excel' | 'pdf') => void;\n };\n className?: string;\n emptyMessage?: string;\n virtualScrolling?: boolean;\n rowHeight?: number;\n maxHeight?: number;\n}\n\nexport const AdvancedDataTable: React.FC = ({\n data,\n columns,\n actions = [],\n loading = false,\n pagination,\n sorting,\n filtering,\n selection,\n exportOptions,\n className = '',\n emptyMessage = 'No data available',\n virtualScrolling = false,\n rowHeight = 50,\n maxHeight = 400\n}) => {\n const [searchQuery, setSearchQuery] = useState('');\n const [showFilters, setShowFilters] = useState(false);\n const [showColumnSelector, setShowColumnSelector] = useState(false);\n const [visibleColumns, setVisibleColumns] = useState(columns.map(col => col.key));\n const [expandedRows, setExpandedRows] = useState>(new Set());\n const [actionMenuOpen, setActionMenuOpen] = useState(null);\n const tableRef = useRef(null);\n const actionMenuRef = useRef(null);\n\n // Filter and search data\n const filteredData = useMemo(() => {\n let filtered = [...data];\n\n // Apply search query\n if (searchQuery) {\n filtered = filtered.filter(row => {\n return columns.some(column => {\n const value = row[column.key];\n if (value === null || value === undefined) return false;\n return value.toString().toLowerCase().includes(searchQuery.toLowerCase());\n });\n });\n }\n\n // Apply filters\n if (filtering?.values) {\n filtered = filtered.filter(row => {\n return Object.entries(filtering.values).every(([key, value]) => {\n if (value === '' || value === null || value === undefined) return true;\n const rowValue = row[key];\n if (rowValue === null || rowValue === undefined) return false;\n return rowValue.toString().toLowerCase().includes(value.toString().toLowerCase());\n });\n });\n }\n\n return filtered;\n }, [data, searchQuery, filtering?.values, columns]);\n\n // Sort data\n const sortedData = useMemo(() => {\n if (!sorting?.field) return filteredData;\n\n return [...filteredData].sort((a, b) => {\n const aValue = a[sorting.field];\n const bValue = b[sorting.field];\n\n if (aValue === null || aValue === undefined) return 1;\n if (bValue === null || bValue === undefined) return -1;\n\n let comparison = 0;\n if (typeof aValue === 'number' && typeof bValue === 'number') {\n comparison = aValue - bValue;\n } else {\n comparison = aValue.toString().localeCompare(bValue.toString());\n }\n\n return sorting.direction === 'desc' ? -comparison : comparison;\n });\n }, [filteredData, sorting]);\n\n // Paginate data\n const paginatedData = useMemo(() => {\n if (!pagination) return sortedData;\n\n const startIndex = (pagination.page - 1) * pagination.pageSize;\n return sortedData.slice(startIndex, startIndex + pagination.pageSize);\n }, [sortedData, pagination]);\n\n // Get current page data for rendering\n const currentData = virtualScrolling ? sortedData : paginatedData;\n\n // Handle column sorting\n const handleSort = useCallback((column: TableColumn) => {\n if (!column.sortable || !sorting) return;\n\n const newDirection = sorting.field === column.key && sorting.direction === 'asc' ? 'desc' : 'asc';\n sorting.onSort(column.key, newDirection);\n }, [sorting]);\n\n // Handle row selection\n const handleRowSelection = useCallback((row: any, checked: boolean) => {\n if (!selection) return;\n\n let newSelection;\n if (checked) {\n newSelection = [...selection.selectedRows, row];\n } else {\n newSelection = selection.selectedRows.filter(r => r !== row);\n }\n\n selection.onSelectionChange(newSelection);\n }, [selection]);\n\n // Handle select all\n const handleSelectAll = useCallback((checked: boolean) => {\n if (!selection) return;\n\n selection.onSelectionChange(checked ? [...currentData] : []);\n }, [selection, currentData]);\n\n // Handle export\n const handleExport = useCallback((format: 'csv' | 'excel' | 'pdf') => {\n exportOptions?.onExport(format);\n }, [exportOptions]);\n\n // Format cell value\n const formatValue = useCallback((value: any, column: TableColumn) => {\n if (column.render) {\n return column.render(value, data.find(row => row[column.key] === value));\n }\n\n if (column.format) {\n return column.format(value);\n }\n\n if (column.type === 'date' && value) {\n return new Date(value).toLocaleDateString();\n }\n\n if (column.type === 'boolean') {\n return value ? 'Yes' : 'No';\n }\n\n if (column.type === 'number') {\n return new Intl.NumberFormat().format(value);\n }\n\n return value;\n }, [data]);\n\n // Close action menu when clicking outside\n useEffect(() => {\n const handleClickOutside = (event: MouseEvent) => {\n if (actionMenuRef.current && !actionMenuRef.current.contains(event.target as Node)) {\n setActionMenuOpen(null);\n }\n };\n\n document.addEventListener('mousedown', handleClickOutside);\n return () => document.removeEventListener('mousedown', handleClickOutside);\n }, []);\n\n const totalPages = pagination ? Math.ceil(pagination.total / pagination.pageSize) : 1;\n const isAllSelected = selection ? selection.selectedRows.length === currentData.length : false;\n const isIndeterminate = selection ? selection.selectedRows.length > 0 && selection.selectedRows.length < currentData.length : false;\n\n return (\n
\n {/* Header */}\n
\n
\n {/* Search */}\n
\n \n setSearchQuery(e.target.value)}\n className=\"w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent\"\n />\n
\n\n {/* Actions */}\n
\n {/* Filters */}\n {filtering && (\n
\n setShowFilters(!showFilters)}\n className=\"flex items-center space-x-2 px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50\"\n >\n \n Filters\n {Object.values(filtering.values).some(v => v !== '' && v !== null && v !== undefined) && (\n \n )}\n \n\n {/* Filter Dropdown */}\n {showFilters && (\n
\n
\n

Filters

\n setShowFilters(false)}\n className=\"p-1 hover:bg-gray-100 rounded\"\n >\n \n \n
\n
\n {filtering.filters.map(filter => (\n
\n \n {filter.type === 'text' && (\n filtering.onFilterChange({\n ...filtering.values,\n [filter.key]: e.target.value\n })}\n className=\"w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500\"\n />\n )}\n {filter.type === 'select' && (\n filtering.onFilterChange({\n ...filtering.values,\n [filter.key]: e.target.value\n })}\n className=\"w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500\"\n >\n \n {filter.options?.map(option => (\n \n ))}\n \n )}\n
\n ))}\n
\n
\n {\n filtering.onFilterChange({});\n setSearchQuery('');\n }}\n className=\"px-3 py-2 text-sm text-gray-600 hover:text-gray-900\"\n >\n Clear\n \n setShowFilters(false)}\n className=\"px-3 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700\"\n >\n Apply\n \n
\n
\n )}\n
\n )}\n\n {/* Export */}\n {exportOptions && (\n
\n \n \n Export\n \n
\n )}\n\n {/* Column Selector */}\n setShowColumnSelector(!showColumnSelector)}\n className=\"flex items-center space-x-2 px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50\"\n >\n \n \n
\n
\n
\n\n {/* Table */}\n
\n {loading ? (\n
\n
\n
\n ) : currentData.length === 0 ? (\n
\n
{emptyMessage}
\n
\n ) : (\n \n \n \n {/* Selection Column */}\n {selection && (\n \n )}\n \n {/* Data Columns */}\n {columns.filter(col => visibleColumns.includes(col.key)).map(column => (\n handleSort(column)}\n >\n
\n {column.label}\n {column.sortable && sorting && sorting.field === column.key && (\n sorting.direction === 'asc' ? (\n \n ) : (\n \n )\n )}\n
\n \n ))}\n \n {/* Actions Column */}\n {actions.length > 0 && (\n
\n )}\n \n \n \n {currentData.map((row, index) => {\n const isSelected = selection ? selection.selectedRows.includes(row) : false;\n const rowId = row.id || index;\n \n return (\n \n {/* Selection Cell */}\n {selection && (\n \n )}\n \n {/* Data Cells */}\n {columns.filter(col => visibleColumns.includes(col.key)).map(column => (\n \n ))}\n \n {/* Actions Cell */}\n {actions.length > 0 && (\n \n )}\n \n );\n })}\n \n
\n {\n if (el) el.indeterminate = isIndeterminate;\n }}\n onChange={(e) => handleSelectAll(e.target.checked)}\n className=\"rounded border-gray-300 text-blue-600 focus:ring-blue-500\"\n />\n \n Actions\n
\n handleRowSelection(row, e.target.checked)}\n className=\"rounded border-gray-300 text-blue-600 focus:ring-blue-500\"\n />\n \n {formatValue(row[column.key], column)}\n \n
\n setActionMenuOpen(actionMenuOpen === rowId ? null : rowId)}\n className=\"p-1 hover:bg-gray-100 rounded\"\n >\n \n \n \n {/* Action Menu */}\n {actionMenuOpen === rowId && (\n \n {actions.map(action => {\n const isDisabled = action.disabled ? action.disabled(row) : false;\n \n return (\n {\n if (!isDisabled) {\n action.onClick(row);\n setActionMenuOpen(null);\n }\n }}\n disabled={isDisabled}\n className={`\n w-full px-4 py-2 text-left text-sm flex items-center space-x-2\n ${isDisabled \n ? 'text-gray-400 cursor-not-allowed' \n : action.variant === 'danger'\n ? 'text-red-600 hover:bg-red-50'\n : 'text-gray-700 hover:bg-gray-100'\n }\n `}\n >\n {action.icon}\n {action.label}\n \n );\n })}\n
\n )}\n \n
\n )}\n
\n\n {/* Pagination */}\n {pagination && (\n
\n
\n
\n \n Showing {((pagination.page - 1) * pagination.pageSize) + 1} to{' '}\n {Math.min(pagination.page * pagination.pageSize, pagination.total)} of{' '}\n {pagination.total} results\n \n
\n \n
\n {/* Page Size Selector */}\n pagination.onPageSizeChange(Number(e.target.value))}\n className=\"px-3 py-1 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500\"\n >\n \n \n \n \n \n \n {/* Pagination Controls */}\n
\n pagination.onPageChange(1)}\n disabled={pagination.page === 1}\n className=\"p-1 hover:bg-gray-100 rounded disabled:opacity-50 disabled:cursor-not-allowed\"\n >\n \n \n \n \n pagination.onPageChange(pagination.page - 1)}\n disabled={pagination.page === 1}\n className=\"p-1 hover:bg-gray-100 rounded disabled:opacity-50 disabled:cursor-not-allowed\"\n >\n \n \n \n \n Page {pagination.page} of {totalPages}\n \n \n pagination.onPageChange(pagination.page + 1)}\n disabled={pagination.page === totalPages}\n className=\"p-1 hover:bg-gray-100 rounded disabled:opacity-50 disabled:cursor-not-allowed\"\n >\n \n \n \n pagination.onPageChange(totalPages)}\n disabled={pagination.page === totalPages}\n className=\"p-1 hover:bg-gray-100 rounded disabled:opacity-50 disabled:cursor-not-allowed\"\n >\n \n \n \n
\n
\n
\n
\n )}\n
\n );\n}; +import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { Search, Filter, Download, ChevronUp, ChevronDown, MoreVertical, Eye, Edit, Trash2, ChevronLeft, ChevronRight, X, Check } from 'lucide-react'; + +interface TableColumn { + key: string; + label: string; + sortable?: boolean; + filterable?: boolean; + width?: string; + render?: (value: any, row: any) => React.ReactNode; + type?: 'text' | 'number' | 'date' | 'boolean' | 'custom'; + format?: (value: any) => string; +} + +interface TableAction { + key: string; + label: string; + icon: React.ReactNode; + onClick: (row: any) => void; + disabled?: (row: any) => boolean; + variant?: 'primary' | 'secondary' | 'danger'; +} + +interface FilterOption { + key: string; + label: string; + value: any; + type: 'text' | 'select' | 'date' | 'number'; + options?: { label: string; value: any }[]; +} + +interface AdvancedTableProps { + data: any[]; + columns: TableColumn[]; + actions?: TableAction[]; + loading?: boolean; + pagination?: { + page: number; + pageSize: number; + total: number; + onPageChange: (page: number) => void; + onPageSizeChange: (pageSize: number) => void; + }; + sorting?: { + field: string; + direction: 'asc' | 'desc'; + onSort: (field: string, direction: 'asc' | 'desc') => void; + }; + filtering?: { + filters: FilterOption[]; + values: { [key: string]: any }; + onFilterChange: (values: { [key: string]: any }) => void; + }; + selection?: { + selectedRows: any[]; + onSelectionChange: (selectedRows: any[]) => void; + }; + bulkActions?: { + key: string; + label: string; + icon: React.ReactNode; + onClick: (selectedRows: any[]) => void; + variant?: 'primary' | 'secondary' | 'danger'; + }[]; + exportOptions?: { + csv?: boolean; + excel?: boolean; + pdf?: boolean; + onExport: (format: 'csv' | 'excel' | 'pdf') => void; + }; + className?: string; + emptyMessage?: string; + virtualScrolling?: boolean; + rowHeight?: number; + maxHeight?: number; +} + +export const AdvancedDataTable = ({ + data, + columns, + actions = [], + loading = false, + pagination, + sorting, + filtering, + selection, + bulkActions = [], + exportOptions, + className = '', + emptyMessage = 'No data available', + virtualScrolling = false, + rowHeight = 50, + maxHeight = 400 +}: AdvancedTableProps) => { + const [searchQuery, setSearchQuery] = useState(''); + const [showFilters, setShowFilters] = useState(false); + const [showColumnSelector, setShowColumnSelector] = useState(false); + const [visibleColumns, setVisibleColumns] = useState(columns.map(col => col.key)); + const [expandedRows, setExpandedRows] = useState>(new Set()); + const [actionMenuOpen, setActionMenuOpen] = useState(null); + const tableRef = useRef(null); + const actionMenuRef = useRef(null); + + // Filter and search data + const filteredData = useMemo(() => { + let filtered = [...data]; + + // Apply search query + if (searchQuery) { + filtered = filtered.filter(row => { + return columns.some(column => { + const value = row[column.key]; + if (value === null || value === undefined) return false; + return value.toString().toLowerCase().includes(searchQuery.toLowerCase()); + }); + }); + } + + // Apply filters + if (filtering?.values) { + filtered = filtered.filter(row => { + return Object.entries(filtering.values).every(([key, value]) => { + if (value === '' || value === null || value === undefined) return true; + const rowValue = row[key]; + if (rowValue === null || rowValue === undefined) return false; + return rowValue.toString().toLowerCase().includes(value.toString().toLowerCase()); + }); + }); + } + + return filtered; + }, [data, searchQuery, filtering?.values, columns]); + + // Sort data + const sortedData = useMemo(() => { + if (!sorting?.field) return filteredData; + + return [...filteredData].sort((a, b) => { + const aValue = a[sorting.field]; + const bValue = b[sorting.field]; + + if (aValue === null || aValue === undefined) return 1; + if (bValue === null || bValue === undefined) return -1; + + let comparison = 0; + if (typeof aValue === 'number' && typeof bValue === 'number') { + comparison = aValue - bValue; + } else { + comparison = aValue.toString().localeCompare(bValue.toString()); + } + + return sorting.direction === 'desc' ? -comparison : comparison; + }); + }, [filteredData, sorting]); + + // Paginate data + const paginatedData = useMemo(() => { + if (!pagination) return sortedData; + + const startIndex = (pagination.page - 1) * pagination.pageSize; + return sortedData.slice(startIndex, startIndex + pagination.pageSize); + }, [sortedData, pagination]); + + // Get current page data for rendering + const currentData = virtualScrolling ? sortedData : paginatedData; + + // Handle column sorting + const handleSort = useCallback((column: TableColumn) => { + if (!column.sortable || !sorting) return; + + const newDirection = sorting.field === column.key && sorting.direction === 'asc' ? 'desc' : 'asc'; + sorting.onSort(column.key, newDirection); + }, [sorting]); + + // Handle row selection + const handleRowSelection = useCallback((row: any, checked: boolean) => { + if (!selection) return; + + let newSelection; + if (checked) { + newSelection = [...selection.selectedRows, row]; + } else { + newSelection = selection.selectedRows.filter(r => r !== row); + } + + selection.onSelectionChange(newSelection); + }, [selection]); + + // Handle select all + const handleSelectAll = useCallback((checked: boolean) => { + if (!selection) return; + + selection.onSelectionChange(checked ? [...currentData] : []); + }, [selection, currentData]); + + // Handle export + const handleExport = useCallback((format: 'csv' | 'excel' | 'pdf') => { + exportOptions?.onExport(format); + }, [exportOptions]); + + // Format cell value + const formatValue = useCallback((value: any, column: TableColumn) => { + if (column.render) { + return column.render(value, data.find(row => row[column.key] === value)); + } + + if (column.format) { + return column.format(value); + } + + if (column.type === 'date' && value) { + return new Date(value).toLocaleDateString(); + } + + if (column.type === 'boolean') { + return value ? 'Yes' : 'No'; + } + + if (column.type === 'number') { + return new Intl.NumberFormat().format(value); + } + + return value; + }, [data]); + + // Close action menu when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (actionMenuRef.current && !actionMenuRef.current.contains(event.target as Node)) { + setActionMenuOpen(null); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const totalPages = pagination ? Math.ceil(pagination.total / pagination.pageSize) : 1; + const isAllSelected = selection ? selection.selectedRows.length === currentData.length : false; + const isIndeterminate = selection ? selection.selectedRows.length > 0 && selection.selectedRows.length < currentData.length : false; + + return ( +
+ {/* Header */} +
+ {selection && selection.selectedRows.length > 0 && bulkActions.length > 0 && ( + ({ + ...action, + onClick: () => action.onClick(selection.selectedRows) + }))} + onClear={() => selection.onSelectionChange([])} + /> + )} +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ + {/* Actions */} +
+ {/* Filters */} + {filtering && ( +
+ + + {/* Filter Dropdown */} + {showFilters && ( +
+
+

Filters

+ +
+
+ {filtering.filters.map(filter => ( +
+ + {filter.type === 'text' && ( + filtering.onFilterChange({ + ...filtering.values, + [filter.key]: e.target.value + })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + )} + {filter.type === 'select' && ( + + )} +
+ ))} +
+
+ + +
+
+ )} +
+ )} + + {/* Export */} + {exportOptions && ( +
+ +
+ )} + + {/* Column Selector */} + +
+
+
+ + {/* Table */} +
+ {loading ? ( +
+
+
+ ) : currentData.length === 0 ? ( +
+
{emptyMessage}
+
+ ) : ( + + + + {/* Selection Column */} + {selection && ( + + )} + + {/* Data Columns */} + {columns.filter(col => visibleColumns.includes(col.key)).map(column => ( + + ))} + + {/* Actions Column */} + {actions.length > 0 && ( + + )} + + + + {currentData.map((row, index) => { + const isSelected = selection ? selection.selectedRows.includes(row) : false; + const rowId = row.id || index; + + return ( + + {/* Selection Cell */} + {selection && ( + + )} + + {/* Data Cells */} + {columns.filter(col => visibleColumns.includes(col.key)).map(column => ( + + ))} + + {/* Actions Cell */} + {actions.length > 0 && ( + + )} + + ); + })} + +
+ { + if (el) el.indeterminate = isIndeterminate; + }} + onChange={(e) => handleSelectAll(e.target.checked)} + className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> + handleSort(column)} + > +
+ {column.label} + {column.sortable && sorting && sorting.field === column.key && ( + sorting.direction === 'asc' ? ( + + ) : ( + + ) + )} +
+
+ Actions +
+ handleRowSelection(row, e.target.checked)} + className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> + + {formatValue(row[column.key], column)} + +
+ + + {/* Action Menu */} + {actionMenuOpen === rowId && ( +
+ {actions.map(action => { + const isDisabled = action.disabled ? action.disabled(row) : false; + + return ( + + ); + })} +
+ )} +
+
+ )} +
+ + {/* Pagination */} + {pagination && ( +
+
+
+ + Showing {((pagination.page - 1) * pagination.pageSize) + 1} to{' '} + {Math.min(pagination.page * pagination.pageSize, pagination.total)} of{' '} + {pagination.total} results + +
+ +
+ {/* Page Size Selector */} + + + {/* Pagination Controls */} +
+ + + + + + Page {pagination.page} of {totalPages} + + + + + +
+
+
+
+ )} +
+ ); +}; + +interface BulkActionBarProps { + selectedCount: number; + actions: { + key: string; + label: string; + icon: React.ReactNode; + onClick: () => void; + variant?: 'primary' | 'secondary' | 'danger'; + }[]; + onClear: () => void; +} + +const BulkActionBar: React.FC = ({ selectedCount, actions, onClear }) => { + return ( +
+
+ + {selectedCount} + + Selected Items +
+ +
+ {actions.map(action => ( + + ))} +
+ + +
+ ); +}; diff --git a/nepa-frontend/src/components/TransactionHistory.tsx b/nepa-frontend/src/components/TransactionHistory.tsx index 8ba38e3..4476723 100644 --- a/nepa-frontend/src/components/TransactionHistory.tsx +++ b/nepa-frontend/src/components/TransactionHistory.tsx @@ -1,6 +1,9 @@ import React, { useState, useEffect, useMemo } from 'react'; import { Transaction, TransactionHistory, TransactionFilters, PaymentStatus } from '../types'; import TransactionService from '../services/transactionService'; +import BookmarkService from '../services/bookmarkService'; +import { Star, Trash2, CheckCircle, FileText, Download } from 'lucide-react'; +import { AdvancedDataTable } from './AdvancedDataTable'; interface Props { className?: string; @@ -20,6 +23,14 @@ export const TransactionHistoryComponent: React.FC = ({ className = '' }) totalCount: 0, hasNextPage: false, }); + const [bookmarkedIds, setBookmarkedIds] = useState>(new Set()); + const [selectedRows, setSelectedRows] = useState([]); + + // Check bookmarks on mount + useEffect(() => { + const bookmarks = BookmarkService.getBookmarks(); + setBookmarkedIds(new Set(bookmarks.map(b => b.id))); + }, []); // Load transactions on component mount and filter changes useEffect(() => { @@ -100,6 +111,25 @@ export const TransactionHistoryComponent: React.FC = ({ className = '' }) } }; + const handleToggleBookmark = (transaction: Transaction) => { + const isBookmarked = BookmarkService.toggleBookmark({ + id: transaction.id, + type: 'transaction', + title: `Transaction ${transaction.id}`, + data: transaction + }); + + setBookmarkedIds(prev => { + const next = new Set(prev); + if (isBookmarked) { + next.add(transaction.id); + } else { + next.delete(transaction.id); + } + return next; + }); + }; + const clearFilters = () => { setFilters({}); }; @@ -128,7 +158,7 @@ export const TransactionHistoryComponent: React.FC = ({ className = '' })
@@ -176,7 +206,7 @@ export const TransactionHistoryComponent: React.FC = ({ className = '' }) handleFilterChange('dateTo', e.target.value)} + onChange={(e: React.ChangeEvent) => handleFilterChange('dateTo', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" /> @@ -188,7 +218,7 @@ export const TransactionHistoryComponent: React.FC = ({ className = '' }) type="text" placeholder="METER-123" value={filters.meterId || ''} - onChange={(e) => handleFilterChange('meterId', e.target.value)} + onChange={(e: React.ChangeEvent) => handleFilterChange('meterId', e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" /> @@ -198,7 +228,7 @@ export const TransactionHistoryComponent: React.FC = ({ className = '' }) handleSearch(e.target.value)} + onChange={(e: React.ChangeEvent) => handleSearch(e.target.value)} className="w-full px-4 py-3 pl-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" /> 🔍 - {/* Transactions List */} - {filteredTransactions.length === 0 && !loading ? ( -
-
No transactions found
-

Try adjusting your filters or search terms

-
- ) : ( -
-
- - - - - - - - - - - - - {filteredTransactions.map((transaction) => ( - - - - - - - - - ))} - -
- Date & Time - - Transaction ID - - Meter ID - - Amount - - Status - - Actions -
- {TransactionService.formatDate(transaction.date)} - - {transaction.id} - - {transaction.meterId} - - {TransactionService.formatAmount(transaction.amount)} - - - {TransactionService.getStatusIcon(transaction.status)} - {transaction.status} - - -
- - -
-
-
-
- )} - - {/* Pagination */} - {pagination.totalPages > 1 && ( -
- - - - Page {pagination.currentPage} of {pagination.totalPages} - - - -
- )} + {/* Transactions Table */} + handleFilterChange('page', page)} + onViewReceipt={handleViewReceipt} + onDownloadPDF={handleDownloadReceipt} + onToggleBookmark={handleToggleBookmark} + bookmarkedIds={bookmarkedIds} + /> {/* Receipt Modal */} {showReceiptModal && selectedTransaction && ( @@ -446,3 +386,149 @@ export const TransactionHistoryComponent: React.FC = ({ className = '' }) ); }; + +// Sub-component for Transaction List integrated with AdvancedDataTable +export const TransactionHistoryTable: React.FC<{ + transactions: Transaction[]; + loading: boolean; + pagination: any; + onPageChange: (page: number) => void; + onViewReceipt: (t: Transaction) => void; + onDownloadPDF: (id: string) => void; + onToggleBookmark: (t: Transaction) => void; + bookmarkedIds: Set; +}> = ({ + transactions, + loading, + pagination, + onPageChange, + onViewReceipt, + onDownloadPDF, + onToggleBookmark, + bookmarkedIds +}) => { + const [selectedRows, setSelectedRows] = useState([]); + + const columns = [ + { + key: 'date', + label: 'Date & Time', + sortable: true, + render: (value: string) => TransactionService.formatDate(value) + }, + { + key: 'id', + label: 'Transaction ID', + render: (value: string) => {value} + }, + { key: 'meterId', label: 'Meter ID', sortable: true }, + { + key: 'amount', + label: 'Amount', + sortable: true, + render: (value: number) => ( + {TransactionService.formatAmount(value)} + ) + }, + { + key: 'status', + label: 'Status', + sortable: true, + render: (value: PaymentStatus) => ( + + {TransactionService.getStatusIcon(value)} + {value} + + ) + }, + { + key: 'bookmark', + label: 'Bookmark', + render: (_: any, row: Transaction) => ( + + ) + } + ]; + + const actions = [ + { + key: 'view', + label: 'View Receipt', + icon: , + onClick: (row: Transaction) => onViewReceipt(row) + }, + { + key: 'download', + label: 'Download PDF', + icon: , + onClick: (row: Transaction) => onDownloadPDF(row.id) + } + ]; + + const bulkActions = [ + { + key: 'bulk-delete', + label: 'Delete Selected', + icon: , + variant: 'danger' as const, + onClick: (rows: Transaction[]) => { + if (confirm(`Are you sure you want to delete ${rows.length} transactions?`)) { + console.log('Deleting rows:', rows.map(r => r.id)); + // In a real app, call service.deleteTransactions(ids) + } + } + }, + { + key: 'bulk-bookmark', + label: 'Bookmark All', + icon: , + onClick: (rows: Transaction[]) => { + rows.forEach(row => { + if (!bookmarkedIds.has(row.id)) { + onToggleBookmark(row); + } + }); + } + }, + { + key: 'bulk-success', + label: 'Mark as Success', + icon: , + onClick: (rows: Transaction[]) => { + console.log('Marking as success:', rows.map(r => r.id)); + } + } + ]; + + return ( + console.log('Page size change:', size) + }} + emptyMessage="No transactions found matching your criteria." + /> + ); +}; diff --git a/nepa-frontend/src/components/charts/EnhancedChart.tsx b/nepa-frontend/src/components/charts/EnhancedChart.tsx index e4ba982..2fae6cd 100644 --- a/nepa-frontend/src/components/charts/EnhancedChart.tsx +++ b/nepa-frontend/src/components/charts/EnhancedChart.tsx @@ -266,7 +266,14 @@ export const EnhancedChart: React.FC = ({ label={{ value: yAxisLabel, angle: -90, position: 'insideLeft' }} {...axisProps} /> - {showTooltip && } + {showTooltip && ( + + )} {showLegend && } {showBrush && } = ({ label={{ value: yAxisLabel, angle: -90, position: 'insideLeft' }} {...axisProps} /> - {showTooltip && } + {showTooltip && ( + + )} {showLegend && } {showBrush && } = ({ label={{ value: yAxisLabel, angle: -90, position: 'insideLeft' }} {...axisProps} /> - {showTooltip && } + {showTooltip && ( + + )} {showLegend && } {showBrush && } = ({ /> ))} - {showTooltip && } + {showTooltip && ( + + )} {showLegend && } ); diff --git a/nepa-frontend/src/pages/TransactionHistoryPage.tsx b/nepa-frontend/src/pages/TransactionHistoryPage.tsx index a361fef..bbb62c1 100644 --- a/nepa-frontend/src/pages/TransactionHistoryPage.tsx +++ b/nepa-frontend/src/pages/TransactionHistoryPage.tsx @@ -95,24 +95,29 @@ export const TransactionHistoryPage: React.FC = () => { {/* Footer */} diff --git a/nepa-frontend/src/services/bookmarkService.ts b/nepa-frontend/src/services/bookmarkService.ts new file mode 100644 index 0000000..612c6c7 --- /dev/null +++ b/nepa-frontend/src/services/bookmarkService.ts @@ -0,0 +1,58 @@ +import { Transaction } from '../types'; + +const BOOKMARKS_KEY = 'nepa_bookmarks'; + +export interface Bookmark { + id: string; + type: 'transaction' | 'meter' | 'content'; + title: string; + data: any; + createdAt: string; +} + +class BookmarkService { + getBookmarks(): Bookmark[] { + const stored = localStorage.getItem(BOOKMARKS_KEY); + if (!stored) return []; + try { + return JSON.parse(stored); + } catch (e) { + console.error('Failed to parse bookmarks', e); + return []; + } + } + + addBookmark(bookmark: Omit): void { + const bookmarks = this.getBookmarks(); + if (bookmarks.some(b => b.id === bookmark.id)) return; + + const newBookmark: Bookmark = { + ...bookmark, + createdAt: new Date().toISOString() + }; + + localStorage.setItem(BOOKMARKS_KEY, JSON.stringify([...bookmarks, newBookmark])); + } + + removeBookmark(id: string): void { + const bookmarks = this.getBookmarks(); + localStorage.setItem(BOOKMARKS_KEY, JSON.stringify(bookmarks.filter(b => b.id !== id))); + } + + isBookmarked(id: string): boolean { + const bookmarks = this.getBookmarks(); + return bookmarks.some(b => b.id === id); + } + + toggleBookmark(bookmark: Omit): boolean { + if (this.isBookmarked(bookmark.id)) { + this.removeBookmark(bookmark.id); + return false; + } else { + this.addBookmark(bookmark); + return true; + } + } +} + +export default new BookmarkService(); diff --git a/nepa-frontend/src/services/transactionService.ts b/nepa-frontend/src/services/transactionService.ts index b89165f..df36673 100644 --- a/nepa-frontend/src/services/transactionService.ts +++ b/nepa-frontend/src/services/transactionService.ts @@ -242,7 +242,7 @@ class TransactionService { /** * Format currency amount */ - static formatAmount(amount: string | number): string { + public formatAmount(amount: string | number): string { const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount; return new Intl.NumberFormat('en-NG', { style: 'currency', @@ -253,7 +253,7 @@ class TransactionService { /** * Format date for display */ - static formatDate(date: string | Date): string { + public formatDate(date: string | Date): string { const dateObj = typeof date === 'string' ? new Date(date) : date; return new Intl.DateTimeFormat('en-NG', { year: 'numeric', @@ -267,7 +267,7 @@ class TransactionService { /** * Get status color for UI */ - static getStatusColor(status: string): string { + public getStatusColor(status: string): string { switch (status.toUpperCase()) { case 'SUCCESS': return 'text-green-600 bg-green-100'; @@ -285,7 +285,7 @@ class TransactionService { /** * Get status icon */ - static getStatusIcon(status: string): string { + public getStatusIcon(status: string): string { switch (status.toUpperCase()) { case 'SUCCESS': return '✅';