training, support, certifications, knowledge base, targets, and sales managment
This commit is contained in:
parent
7f8480a03d
commit
8ee22fba2b
1137
package-lock.json
generated
1137
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@ -11,10 +11,22 @@
|
|||||||
"@testing-library/jest-dom": "^6.6.4",
|
"@testing-library/jest-dom": "^6.6.4",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"@tiptap/extension-code-block": "^3.2.0",
|
||||||
|
"@tiptap/extension-highlight": "^3.2.0",
|
||||||
|
"@tiptap/extension-image": "^3.2.0",
|
||||||
|
"@tiptap/extension-link": "^3.2.0",
|
||||||
|
"@tiptap/extension-table": "^3.2.0",
|
||||||
|
"@tiptap/extension-table-cell": "^3.2.0",
|
||||||
|
"@tiptap/extension-table-header": "^3.2.0",
|
||||||
|
"@tiptap/extension-table-row": "^3.2.0",
|
||||||
|
"@tiptap/pm": "^3.2.0",
|
||||||
|
"@tiptap/react": "^3.2.0",
|
||||||
|
"@tiptap/starter-kit": "^3.2.0",
|
||||||
"@types/jest": "^27.5.2",
|
"@types/jest": "^27.5.2",
|
||||||
"@types/node": "^16.18.126",
|
"@types/node": "^16.18.126",
|
||||||
"@types/react": "^19.1.9",
|
"@types/react": "^19.1.9",
|
||||||
"@types/react-dom": "^19.1.7",
|
"@types/react-dom": "^19.1.7",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@types/socket.io-client": "^1.4.36",
|
"@types/socket.io-client": "^1.4.36",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@ -23,9 +35,11 @@
|
|||||||
"react-cookie": "^8.0.1",
|
"react-cookie": "^8.0.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-hot-toast": "^2.5.2",
|
"react-hot-toast": "^2.5.2",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"react-router-dom": "^6.30.1",
|
"react-router-dom": "^6.30.1",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
"recharts": "^3.1.0",
|
"recharts": "^3.1.0",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
|||||||
65
src/App.tsx
65
src/App.tsx
@ -31,11 +31,13 @@ import ResellerLogin from './pages/reseller/Login';
|
|||||||
import ResellerSignup from './pages/reseller/Signup';
|
import ResellerSignup from './pages/reseller/Signup';
|
||||||
import ResellerDashboardMain from './pages/reseller/Dashboard';
|
import ResellerDashboardMain from './pages/reseller/Dashboard';
|
||||||
import ResellerDashboardCustomers from './pages/reseller/Customers';
|
import ResellerDashboardCustomers from './pages/reseller/Customers';
|
||||||
|
import ResellerProducts from './pages/reseller/Products';
|
||||||
import ResellerDashboardInstances from './pages/reseller/Instances';
|
import ResellerDashboardInstances from './pages/reseller/Instances';
|
||||||
import ResellerBilling from './pages/reseller/Billing';
|
import ResellerBilling from './pages/reseller/Billing';
|
||||||
import ResellerSupport from './pages/reseller/Support';
|
import ResellerSupport from './pages/reseller/Support';
|
||||||
import ResellerReports from './pages/reseller/Reports';
|
import ResellerReports from './pages/reseller/Reports';
|
||||||
import ResellerTraining from './pages/reseller/Training';
|
import ResellerTraining from './pages/reseller/Training';
|
||||||
|
import ResellerCertifications from './pages/reseller/Certifications';
|
||||||
import Receipts from './pages/reseller/Receipts';
|
import Receipts from './pages/reseller/Receipts';
|
||||||
import ResellerLayout from './components/Layout/ResellerLayout';
|
import ResellerLayout from './components/Layout/ResellerLayout';
|
||||||
|
|
||||||
@ -52,13 +54,15 @@ import AdminSettings from './pages/admin/Settings';
|
|||||||
import AdminFeedback from './pages/admin/Feedback';
|
import AdminFeedback from './pages/admin/Feedback';
|
||||||
import RegisteredVendors from './pages/admin/RegisteredVendors';
|
import RegisteredVendors from './pages/admin/RegisteredVendors';
|
||||||
import Resellers from './pages/admin/Resellers';
|
import Resellers from './pages/admin/Resellers';
|
||||||
|
import Logs from './pages/admin/Logs';
|
||||||
import Unauthorized from './pages/Unauthorized';
|
import Unauthorized from './pages/Unauthorized';
|
||||||
import CookieConsent from './components/CookieConsent';
|
import CookieConsent from './components/CookieConsent';
|
||||||
import AuthDebug from './components/AuthDebug';
|
import AuthDebug from './components/AuthDebug';
|
||||||
import DeveloperFeedback from './components/DeveloperFeedback';
|
|
||||||
import socketService from './services/socketService';
|
import socketService from './services/socketService';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
import VendorSalesDashboard from './components/VendorSalesDashboard';
|
||||||
|
import KnowledgeBase from './pages/KnowledgeBase';
|
||||||
|
import AdminKnowledgeBase from './pages/admin/KnowledgeBase';
|
||||||
|
|
||||||
// Component to handle role-based redirects
|
// Component to handle role-based redirects
|
||||||
const RoleBasedRedirect: React.FC = () => {
|
const RoleBasedRedirect: React.FC = () => {
|
||||||
@ -108,9 +112,13 @@ function App() {
|
|||||||
if (savedTheme) {
|
if (savedTheme) {
|
||||||
store.dispatch(setTheme(savedTheme as 'light' | 'dark'));
|
store.dispatch(setTheme(savedTheme as 'light' | 'dark'));
|
||||||
} else {
|
} else {
|
||||||
// Check system preference
|
// Default to dark theme instead of system preference
|
||||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
store.dispatch(setTheme('dark'));
|
||||||
store.dispatch(setTheme(systemTheme));
|
}
|
||||||
|
|
||||||
|
// Ensure dark theme is applied to document element on first load
|
||||||
|
if (!savedTheme) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for system theme changes
|
// Listen for system theme changes
|
||||||
@ -194,7 +202,7 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
<Route path="/training" element={
|
<Route path="/training" element={
|
||||||
<ProtectedRoute requiredRole="channel_partner_admin">
|
<ProtectedRoute>
|
||||||
<Layout>
|
<Layout>
|
||||||
<Training />
|
<Training />
|
||||||
</Layout>
|
</Layout>
|
||||||
@ -228,10 +236,10 @@ function App() {
|
|||||||
</Layout>
|
</Layout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
<Route path="/targets" element={
|
<Route path="/sales-management" element={
|
||||||
<ProtectedRoute requiredRole="channel_partner_admin">
|
<ProtectedRoute requiredRole="channel_partner_admin">
|
||||||
<Layout>
|
<Layout>
|
||||||
<PlaceholderPage title="Targets" description="Set and track performance targets" />
|
<VendorSalesDashboard />
|
||||||
</Layout>
|
</Layout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
@ -259,7 +267,7 @@ function App() {
|
|||||||
<Route path="/knowledge-base" element={
|
<Route path="/knowledge-base" element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<Layout>
|
<Layout>
|
||||||
<PlaceholderPage title="Knowledge Base" description="Access documentation and resources" />
|
<KnowledgeBase />
|
||||||
</Layout>
|
</Layout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
@ -313,6 +321,13 @@ function App() {
|
|||||||
</ResellerLayout>
|
</ResellerLayout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/reseller-dashboard/products" element={
|
||||||
|
<ProtectedRoute requiredRole="reseller_admin">
|
||||||
|
<ResellerLayout>
|
||||||
|
<ResellerProducts />
|
||||||
|
</ResellerLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
<Route path="/reseller-dashboard/instances" element={
|
<Route path="/reseller-dashboard/instances" element={
|
||||||
<ProtectedRoute requiredRole="reseller_admin">
|
<ProtectedRoute requiredRole="reseller_admin">
|
||||||
<ResellerLayout>
|
<ResellerLayout>
|
||||||
@ -348,6 +363,13 @@ function App() {
|
|||||||
</ResellerLayout>
|
</ResellerLayout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/reseller-dashboard/training" element={
|
||||||
|
<ProtectedRoute requiredRole="reseller_admin">
|
||||||
|
<ResellerLayout>
|
||||||
|
<ResellerTraining />
|
||||||
|
</ResellerLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
{/* Admin Routes */}
|
{/* Admin Routes */}
|
||||||
<Route path="/admin" element={
|
<Route path="/admin" element={
|
||||||
@ -427,6 +449,20 @@ function App() {
|
|||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/admin/logs" element={
|
||||||
|
<ProtectedRoute requiredRole="system_admin">
|
||||||
|
<AdminLayout>
|
||||||
|
<Logs />
|
||||||
|
</AdminLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/admin/knowledge-base" element={
|
||||||
|
<ProtectedRoute requiredRole="system_admin">
|
||||||
|
<AdminLayout>
|
||||||
|
<AdminKnowledgeBase />
|
||||||
|
</AdminLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
<Route path="/reseller-dashboard/wallet" element={
|
<Route path="/reseller-dashboard/wallet" element={
|
||||||
<ProtectedRoute requiredRole="reseller_admin">
|
<ProtectedRoute requiredRole="reseller_admin">
|
||||||
<ResellerLayout>
|
<ResellerLayout>
|
||||||
@ -451,14 +487,14 @@ function App() {
|
|||||||
<Route path="/reseller-dashboard/certifications" element={
|
<Route path="/reseller-dashboard/certifications" element={
|
||||||
<ProtectedRoute requiredRole="reseller_admin">
|
<ProtectedRoute requiredRole="reseller_admin">
|
||||||
<ResellerLayout>
|
<ResellerLayout>
|
||||||
<PlaceholderPage title="Certifications" description="Manage your professional certifications and achievements" />
|
<ResellerCertifications />
|
||||||
</ResellerLayout>
|
</ResellerLayout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
<Route path="/reseller-dashboard/knowledge-base" element={
|
<Route path="/reseller-dashboard/knowledge-base" element={
|
||||||
<ProtectedRoute requiredRole="reseller_admin">
|
<ProtectedRoute requiredRole="reseller_admin">
|
||||||
<ResellerLayout>
|
<ResellerLayout>
|
||||||
<PlaceholderPage title="Knowledge Base" description="Access documentation, guides, and helpful resources" />
|
<KnowledgeBase />
|
||||||
</ResellerLayout>
|
</ResellerLayout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
@ -486,12 +522,12 @@ function App() {
|
|||||||
} />
|
} />
|
||||||
<Route path="/reseller/certifications" element={
|
<Route path="/reseller/certifications" element={
|
||||||
<Layout>
|
<Layout>
|
||||||
<PlaceholderPage title="Certifications" description="Manage your professional certifications and achievements" />
|
<ResellerCertifications />
|
||||||
</Layout>
|
</Layout>
|
||||||
} />
|
} />
|
||||||
<Route path="/reseller/knowledge-base" element={
|
<Route path="/reseller/knowledge-base" element={
|
||||||
<Layout>
|
<Layout>
|
||||||
<PlaceholderPage title="Knowledge Base" description="Access documentation, guides, and helpful resources" />
|
<KnowledgeBase />
|
||||||
</Layout>
|
</Layout>
|
||||||
} />
|
} />
|
||||||
<Route path="/reseller/settings" element={
|
<Route path="/reseller/settings" element={
|
||||||
@ -506,7 +542,8 @@ function App() {
|
|||||||
<CookieConsent />
|
<CookieConsent />
|
||||||
<Toast />
|
<Toast />
|
||||||
{/* <AuthDebug /> */}
|
{/* <AuthDebug /> */}
|
||||||
<DeveloperFeedback />
|
{/* DeveloperFeedback - Only visible to admin users */}
|
||||||
|
{/* Moved to AdminLayout component for proper auth context */}
|
||||||
</div>
|
</div>
|
||||||
</Router>
|
</Router>
|
||||||
</AuthInitializer>
|
</AuthInitializer>
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import { formatCurrencyDualDisplay } from '../utils/format';
|
import { formatCurrencyDualDisplay } from '../utils/format';
|
||||||
|
|
||||||
interface DualCurrencyDisplayProps {
|
interface DualCurrencyDisplayProps {
|
||||||
amount: number;
|
amount: number | undefined | null;
|
||||||
currency?: 'USD' | 'INR';
|
currency?: 'USD' | 'INR';
|
||||||
className?: string;
|
className?: string;
|
||||||
showSecondary?: boolean;
|
showSecondary?: boolean;
|
||||||
@ -14,7 +14,8 @@ const DualCurrencyDisplay: React.FC<DualCurrencyDisplayProps> = ({
|
|||||||
className = '',
|
className = '',
|
||||||
showSecondary = true
|
showSecondary = true
|
||||||
}) => {
|
}) => {
|
||||||
const formatted = formatCurrencyDualDisplay(amount, currency);
|
const safeAmount = amount ?? 0;
|
||||||
|
const formatted = formatCurrencyDualDisplay(safeAmount, currency);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col ${className}`}>
|
<div className={`flex flex-col ${className}`}>
|
||||||
@ -31,4 +32,4 @@ const DualCurrencyDisplay: React.FC<DualCurrencyDisplayProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DualCurrencyDisplay;
|
export default DualCurrencyDisplay;
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import AdminSidebar from './AdminSidebar';
|
import AdminSidebar from './AdminSidebar';
|
||||||
import NotificationBell from '../NotificationBell';
|
import NotificationBell from '../NotificationBell';
|
||||||
|
import DeveloperFeedback from '../DeveloperFeedback';
|
||||||
|
|
||||||
interface AdminLayoutProps {
|
interface AdminLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@ -22,16 +23,16 @@ const AdminLayout: React.FC<AdminLayoutProps> = ({ children }) => {
|
|||||||
<div className="px-6 py-4">
|
<div className="px-6 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="w-10 h-10 bg-gradient-to-r from-purple-600 to-indigo-600 rounded-xl flex items-center justify-center">
|
<div className="w-10 h-10 bg-gradient-to-r from-purple-600 to-indigo-600 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-xl font-semibold text-slate-900 dark:text-white">
|
<h1 className="text-xl font-semibold text-slate-900 dark:text-white truncate">
|
||||||
Admin Panel
|
Admin Panel
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
<p className="text-sm text-slate-600 dark:text-slate-400 truncate">
|
||||||
System Administration & Management
|
System Administration & Management
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -43,12 +44,12 @@ const AdminLayout: React.FC<AdminLayoutProps> = ({ children }) => {
|
|||||||
|
|
||||||
{/* User Menu */}
|
{/* User Menu */}
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="w-8 h-8 bg-gradient-to-r from-purple-500 to-indigo-500 rounded-full flex items-center justify-center">
|
<div className="w-8 h-8 bg-gradient-to-r from-purple-500 to-indigo-500 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
<span className="text-white text-sm font-medium">A</span>
|
<span className="text-white text-sm font-medium">A</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block min-w-0">
|
||||||
<p className="text-sm font-medium text-slate-900 dark:text-white">Admin User</p>
|
<p className="text-sm font-medium text-slate-900 dark:text-white truncate">Admin User</p>
|
||||||
<p className="text-xs text-slate-600 dark:text-slate-400">System Administrator</p>
|
<p className="text-xs text-slate-600 dark:text-slate-400 truncate">System Administrator</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -58,9 +59,14 @@ const AdminLayout: React.FC<AdminLayoutProps> = ({ children }) => {
|
|||||||
|
|
||||||
{/* Page Content */}
|
{/* Page Content */}
|
||||||
<div className="flex-1 overflow-auto min-h-0">
|
<div className="flex-1 overflow-auto min-h-0">
|
||||||
{children}
|
<div className="max-w-full">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Developer Feedback - Only visible on admin pages */}
|
||||||
|
<DeveloperFeedback />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useAppSelector, useAppDispatch } from '../../store/hooks';
|
import { useAppSelector, useAppDispatch } from '../../store/hooks';
|
||||||
import { toggleTheme } from '../../store/slices/themeSlice';
|
import { toggleTheme } from '../../store/slices/themeSlice';
|
||||||
import {
|
import {
|
||||||
Home, Users, Building, Clock, Settings, Menu, X, Sun, Moon, LogOut,
|
Home, Users, Building, Clock, Settings, Menu, X, Sun, Moon, LogOut,
|
||||||
Shield, BarChart3, FileText, UserCheck, UserX, TrendingUp, Activity, Package, MessageSquare
|
Shield, BarChart3, FileText, UserCheck, UserX, TrendingUp, Activity, Package, MessageSquare, BookOpen
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { logout } from '../../store/slices/authSlice';
|
import { logout } from '../../store/slices/authSlice';
|
||||||
import { cn } from '../../utils/cn';
|
import { cn } from '../../utils/cn';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
const adminNavigation = [
|
const adminNavigation = [
|
||||||
{ name: 'Dashboard', href: '/admin', icon: Home },
|
{ name: 'Dashboard', href: '/admin', icon: Home },
|
||||||
@ -18,18 +19,30 @@ const adminNavigation = [
|
|||||||
{ name: 'Products', href: '/admin/products', icon: Package },
|
{ name: 'Products', href: '/admin/products', icon: Package },
|
||||||
{ name: 'Analytics', href: '/admin/analytics', icon: BarChart3 },
|
{ name: 'Analytics', href: '/admin/analytics', icon: BarChart3 },
|
||||||
{ name: 'Reports', href: '/admin/reports', icon: FileText },
|
{ name: 'Reports', href: '/admin/reports', icon: FileText },
|
||||||
|
{ name: 'Logs', href: '/admin/logs', icon: Activity },
|
||||||
{ name: 'Feedback', href: '/admin/feedback', icon: MessageSquare },
|
{ name: 'Feedback', href: '/admin/feedback', icon: MessageSquare },
|
||||||
|
{ name: 'Knowledge Base', href: '/admin/knowledge-base', icon: BookOpen },
|
||||||
{ name: 'Settings', href: '/admin/settings', icon: Settings },
|
{ name: 'Settings', href: '/admin/settings', icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
const AdminSidebar: React.FC = () => {
|
const AdminSidebar: React.FC = () => {
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { theme } = useAppSelector((state) => state.theme);
|
const { theme } = useAppSelector((state) => state.theme);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
|
// Clear Redux state
|
||||||
dispatch(logout());
|
dispatch(logout());
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
toast.success('Logged out successfully');
|
||||||
|
|
||||||
|
// Wait a bit for Redux state to update, then navigate
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate('/login');
|
||||||
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -40,21 +53,22 @@ const AdminSidebar: React.FC = () => {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-slate-700/50">
|
<div className="flex items-center justify-between p-4 border-b border-slate-700/50">
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-3 min-w-0">
|
||||||
<div className="w-8 h-8 bg-gradient-to-r from-purple-500 to-indigo-500 rounded-lg flex items-center justify-center">
|
<div className="w-8 h-8 bg-gradient-to-r from-purple-500 to-indigo-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
<Shield className="w-5 h-5 text-white" />
|
<Shield className="w-5 h-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-lg font-semibold text-white">
|
<span className="text-lg font-semibold text-white truncate">
|
||||||
Admin
|
Admin
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
className="p-1 rounded-md hover:bg-slate-700/50 transition-colors"
|
className="p-2 rounded-lg hover:bg-slate-700/50 transition-colors flex-shrink-0"
|
||||||
|
title={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||||
>
|
>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<Menu className="w-6 h-6 text-slate-400" />
|
<Menu className="w-5 h-5 text-slate-400" />
|
||||||
) : (
|
) : (
|
||||||
<X className="w-5 h-5 text-slate-400" />
|
<X className="w-5 h-5 text-slate-400" />
|
||||||
)}
|
)}
|
||||||
@ -63,8 +77,8 @@ const AdminSidebar: React.FC = () => {
|
|||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className={cn(
|
<nav className={cn(
|
||||||
"flex-1 py-4 space-y-1 overflow-y-auto",
|
"flex-1 py-4 space-y-2 overflow-y-auto",
|
||||||
isCollapsed ? "px-3" : "px-2"
|
isCollapsed ? "px-2" : "px-3"
|
||||||
)}>
|
)}>
|
||||||
{adminNavigation.map((item) => {
|
{adminNavigation.map((item) => {
|
||||||
const isActive = location.pathname === item.href;
|
const isActive = location.pathname === item.href;
|
||||||
@ -73,12 +87,13 @@ const AdminSidebar: React.FC = () => {
|
|||||||
key={item.name}
|
key={item.name}
|
||||||
to={item.href}
|
to={item.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center text-sm font-medium rounded-xl transition-all duration-200 group",
|
"flex items-center text-sm font-medium rounded-xl transition-all duration-200 group relative",
|
||||||
isCollapsed ? "px-2 py-3 justify-center" : "px-4 py-3",
|
isCollapsed ? "px-2 py-3 justify-center" : "px-4 py-3",
|
||||||
isActive
|
isActive
|
||||||
? "bg-gradient-to-r from-purple-500/20 to-indigo-500/20 text-purple-400 border border-purple-500/30 shadow-lg"
|
? "bg-gradient-to-r from-purple-500/20 to-indigo-500/20 text-purple-400 border border-purple-500/30 shadow-lg"
|
||||||
: "text-slate-300 hover:bg-slate-800/50 hover:text-white"
|
: "text-slate-300 hover:bg-slate-800/50 hover:text-white"
|
||||||
)}
|
)}
|
||||||
|
title={isCollapsed ? item.name : undefined}
|
||||||
>
|
>
|
||||||
<item.icon className={cn(
|
<item.icon className={cn(
|
||||||
"flex-shrink-0 transition-colors duration-200",
|
"flex-shrink-0 transition-colors duration-200",
|
||||||
@ -88,7 +103,10 @@ const AdminSidebar: React.FC = () => {
|
|||||||
: "text-slate-400 group-hover:text-white"
|
: "text-slate-400 group-hover:text-white"
|
||||||
)} />
|
)} />
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<span className="ml-3">{item.name}</span>
|
<span className="ml-3 truncate">{item.name}</span>
|
||||||
|
)}
|
||||||
|
{isActive && !isCollapsed && (
|
||||||
|
<div className="absolute right-2 w-2 h-2 bg-purple-400 rounded-full"></div>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
@ -96,7 +114,7 @@ const AdminSidebar: React.FC = () => {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User Profile & Actions */}
|
{/* User Profile & Actions */}
|
||||||
<div className="border-t border-slate-700/50 p-4 space-y-2">
|
<div className="border-t border-slate-700/50 p-4 space-y-3">
|
||||||
{/* Theme Toggle */}
|
{/* Theme Toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={() => dispatch(toggleTheme())}
|
onClick={() => dispatch(toggleTheme())}
|
||||||
@ -108,12 +126,12 @@ const AdminSidebar: React.FC = () => {
|
|||||||
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
>
|
>
|
||||||
{theme === 'dark' ? (
|
{theme === 'dark' ? (
|
||||||
<Sun className="w-6 h-6 text-amber-400" />
|
<Sun className="w-5 h-5 text-amber-400 flex-shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<Moon className="w-6 h-6 text-slate-400" />
|
<Moon className="w-5 h-5 text-slate-400 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<span className="ml-3">
|
<span className="ml-3 truncate">
|
||||||
{theme === 'dark' ? 'Light Mode' : 'Dark Mode'}
|
{theme === 'dark' ? 'Light Mode' : 'Dark Mode'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -129,8 +147,8 @@ const AdminSidebar: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
title="Logout"
|
title="Logout"
|
||||||
>
|
>
|
||||||
<LogOut className="w-6 h-6" />
|
<LogOut className="w-5 h-5 flex-shrink-0" />
|
||||||
{!isCollapsed && <span className="ml-3">Logout</span>}
|
{!isCollapsed && <span className="ml-3 truncate">Logout</span>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useAppSelector } from '../../store/hooks';
|
||||||
|
import { Bell, CheckCircle, XCircle, Clock, AlertCircle } from 'lucide-react';
|
||||||
import ResellerSidebar from './ResellerSidebar';
|
import ResellerSidebar from './ResellerSidebar';
|
||||||
|
|
||||||
interface ResellerLayoutProps {
|
interface ResellerLayoutProps {
|
||||||
@ -6,81 +8,256 @@ interface ResellerLayoutProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ResellerLayout: React.FC<ResellerLayoutProps> = ({ children }) => {
|
const ResellerLayout: React.FC<ResellerLayoutProps> = ({ children }) => {
|
||||||
|
const { user } = useAppSelector(state => state.auth);
|
||||||
|
const [notifications, setNotifications] = useState<any[]>([]);
|
||||||
|
const [showNotifications, setShowNotifications] = useState(false);
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
|
||||||
|
// Fetch notifications from the backend
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchNotifications = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const response = await fetch('http://localhost:5000/api/notifications/reseller', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const userNotifications = data.data || [];
|
||||||
|
setNotifications(userNotifications);
|
||||||
|
setUnreadCount(userNotifications.filter((n: any) => !n.isRead).length);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching notifications:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchNotifications();
|
||||||
|
|
||||||
|
// Set up polling for new notifications every 30 seconds
|
||||||
|
const interval = setInterval(fetchNotifications, 30000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Mark notification as read
|
||||||
|
const markAsRead = async (notificationId: number) => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const response = await fetch(`http://localhost:5000/api/notifications/${notificationId}/read`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setNotifications(prev =>
|
||||||
|
prev.map(n =>
|
||||||
|
n.id === notificationId ? { ...n, isRead: true } : n
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking notification as read:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close notifications when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Element;
|
||||||
|
if (showNotifications && !target.closest('.notifications-dropdown')) {
|
||||||
|
setShowNotifications(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [showNotifications]);
|
||||||
|
|
||||||
|
// Get notification icon based on type
|
||||||
|
const getNotificationIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'SALE_APPROVED':
|
||||||
|
return <CheckCircle className="w-5 h-5 text-green-500" />;
|
||||||
|
case 'SALE_REJECTED':
|
||||||
|
return <XCircle className="w-5 h-5 text-red-500" />;
|
||||||
|
case 'SALE_PENDING':
|
||||||
|
return <Clock className="w-5 h-5 text-yellow-500" />;
|
||||||
|
case 'COMMISSION_EARNED':
|
||||||
|
return <CheckCircle className="w-5 h-5 text-emerald-500" />;
|
||||||
|
default:
|
||||||
|
return <AlertCircle className="w-5 h-5 text-blue-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get notification color based on type
|
||||||
|
const getNotificationColor = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'SALE_APPROVED':
|
||||||
|
return 'border-l-green-500 bg-green-50 dark:bg-green-900/20';
|
||||||
|
case 'SALE_REJECTED':
|
||||||
|
return 'border-l-red-500 bg-red-50 dark:bg-red-900/20';
|
||||||
|
case 'SALE_PENDING':
|
||||||
|
return 'border-l-yellow-500 bg-yellow-50 dark:bg-yellow-900/20';
|
||||||
|
case 'COMMISSION_EARNED':
|
||||||
|
return 'border-l-emerald-500 bg-emerald-50 dark:bg-emerald-900/20';
|
||||||
|
default:
|
||||||
|
return 'border-l-blue-500 bg-blue-50 dark:bg-blue-900/20';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-emerald-50 to-teal-50 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900">
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-emerald-50 to-teal-50 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900">
|
||||||
<div className="flex h-screen">
|
{/* Top Navigation Bar - Full Width */}
|
||||||
|
<div className="bg-white/90 dark:bg-slate-900/95 backdrop-blur-xl border-b border-slate-200/50 dark:border-slate-700/50 sticky top-0 z-40 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between px-8 py-5 h-20">
|
||||||
|
{/* Left Side - Logo and Title */}
|
||||||
|
<div className="flex items-center space-x-5">
|
||||||
|
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 via-teal-500 to-cyan-500 rounded-xl flex items-center justify-center flex-shrink-0 shadow-lg">
|
||||||
|
<div className="w-5 h-5 bg-white rounded-md shadow-sm"></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col justify-center">
|
||||||
|
<h1 className="text-xl font-bold text-slate-900 dark:text-white leading-tight tracking-tight">
|
||||||
|
Reseller Portal
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm font-medium text-slate-500 dark:text-slate-400 leading-tight">
|
||||||
|
Cloud Services Management
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Side - Search, Notifications, User */}
|
||||||
|
<div className="flex items-center space-x-6">
|
||||||
|
{/* Notifications */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNotifications(!showNotifications)}
|
||||||
|
className="relative p-3 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100/80 dark:hover:bg-slate-700/50 rounded-xl transition-all duration-200 flex-shrink-0 group"
|
||||||
|
>
|
||||||
|
<Bell className="w-6 h-6" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs rounded-full flex items-center justify-center font-medium">
|
||||||
|
{unreadCount > 9 ? '9+' : unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Notifications Dropdown */}
|
||||||
|
{showNotifications && (
|
||||||
|
<div className="notifications-dropdown absolute right-0 top-full mt-2 w-80 bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 z-50 max-h-96 overflow-y-auto">
|
||||||
|
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">Notifications</h3>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
{unreadCount > 0 ? `${unreadCount} unread` : 'All caught up!'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-2">
|
||||||
|
{notifications.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Bell className="w-12 h-12 text-slate-400 mx-auto mb-3" />
|
||||||
|
<p className="text-slate-600 dark:text-slate-400">No notifications yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{notifications.slice(0, 10).map((notification) => (
|
||||||
|
<div
|
||||||
|
key={notification.id}
|
||||||
|
className={`p-3 rounded-lg border-l-4 transition-all duration-200 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700/50 ${
|
||||||
|
getNotificationColor(notification.type)
|
||||||
|
} ${notification.isRead ? 'opacity-75' : ''}`}
|
||||||
|
onClick={() => markAsRead(notification.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="flex-shrink-0 mt-0.5">
|
||||||
|
{getNotificationIcon(notification.type)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className={`text-sm font-medium ${
|
||||||
|
notification.isRead
|
||||||
|
? 'text-slate-600 dark:text-slate-400'
|
||||||
|
: 'text-slate-900 dark:text-white'
|
||||||
|
}`}>
|
||||||
|
{notification.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400 mt-1">
|
||||||
|
{notification.message}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-500 mt-2">
|
||||||
|
{new Date(notification.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!notification.isRead && (
|
||||||
|
<div className="w-2 h-2 bg-red-500 rounded-full flex-shrink-0 mt-2"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{notifications.length > 10 && (
|
||||||
|
<div className="p-3 border-t border-slate-200 dark:border-slate-700 text-center">
|
||||||
|
<button className="text-sm text-emerald-600 dark:text-emerald-400 hover:text-emerald-700 dark:hover:text-emerald-300">
|
||||||
|
View all notifications
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="w-px h-8 bg-slate-200 dark:bg-slate-700"></div>
|
||||||
|
|
||||||
|
{/* User Menu */}
|
||||||
|
<div className="flex items-center space-x-4 flex-shrink-0 group cursor-pointer">
|
||||||
|
<div className="text-right flex flex-col justify-center">
|
||||||
|
<p className="text-sm font-semibold text-slate-900 dark:text-white leading-tight">
|
||||||
|
{user?.firstName && user?.lastName ? `${user.firstName} ${user.lastName}` : user?.email || 'User'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 leading-tight">
|
||||||
|
{user?.company || 'Company'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 via-teal-500 to-cyan-500 rounded-xl flex items-center justify-center flex-shrink-0 shadow-lg group-hover:shadow-xl transition-all duration-200">
|
||||||
|
<span className="text-white text-sm font-bold">
|
||||||
|
{user?.firstName && user?.lastName
|
||||||
|
? `${user.firstName.charAt(0)}${user.lastName.charAt(0)}`
|
||||||
|
: user?.email?.charAt(0)?.toUpperCase() || 'U'
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content Area with Sidebar */}
|
||||||
|
<div className="flex h-[calc(100vh-5rem)]">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<ResellerSidebar />
|
<ResellerSidebar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Page Content */}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 overflow-auto">
|
||||||
{/* Top Navigation Bar */}
|
<div className="p-6">
|
||||||
<div className="bg-white/90 dark:bg-slate-900/95 backdrop-blur-xl border-b border-slate-200/50 dark:border-slate-700/50 sticky top-0 z-40 shadow-sm">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="flex items-center justify-between px-8 py-5 h-20">
|
{children}
|
||||||
{/* Left Side - Logo and Title */}
|
|
||||||
<div className="flex items-center space-x-5">
|
|
||||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 via-teal-500 to-cyan-500 rounded-xl flex items-center justify-center flex-shrink-0 shadow-lg">
|
|
||||||
<div className="w-5 h-5 bg-white rounded-md shadow-sm"></div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col justify-center">
|
|
||||||
<h1 className="text-xl font-bold text-slate-900 dark:text-white leading-tight tracking-tight">
|
|
||||||
Reseller Portal
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm font-medium text-slate-500 dark:text-slate-400 leading-tight">
|
|
||||||
Cloud Services Management
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Side - Search, Notifications, User */}
|
|
||||||
<div className="flex items-center space-x-6">
|
|
||||||
{/* Search Bar */}
|
|
||||||
<div className="relative flex-shrink-0">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search anything..."
|
|
||||||
className="w-72 pl-12 pr-6 py-3 bg-slate-50 dark:bg-slate-800/80 border border-slate-200/50 dark:border-slate-600/50 rounded-xl text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500/50 transition-all duration-200 backdrop-blur-sm"
|
|
||||||
/>
|
|
||||||
<div className="absolute left-4 top-1/2 transform -translate-y-1/2">
|
|
||||||
<svg className="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notifications */}
|
|
||||||
<button className="relative p-3 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100/80 dark:hover:bg-slate-700/50 rounded-xl transition-all duration-200 flex-shrink-0 group">
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-5 5v-5zM4.5 19.5h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
|
|
||||||
</svg>
|
|
||||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full border-2 border-white dark:border-slate-900"></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="w-px h-8 bg-slate-200 dark:bg-slate-700"></div>
|
|
||||||
|
|
||||||
{/* User Menu */}
|
|
||||||
<div className="flex items-center space-x-4 flex-shrink-0 group cursor-pointer">
|
|
||||||
<div className="text-right flex flex-col justify-center">
|
|
||||||
<p className="text-sm font-semibold text-slate-900 dark:text-white leading-tight">John Reseller</p>
|
|
||||||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 leading-tight">Tech Solutions Inc</p>
|
|
||||||
</div>
|
|
||||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 via-teal-500 to-cyan-500 rounded-xl flex items-center justify-center flex-shrink-0 shadow-lg group-hover:shadow-xl transition-all duration-200">
|
|
||||||
<span className="text-white text-sm font-bold">JR</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Page Content */}
|
|
||||||
<div className="flex-1 overflow-auto">
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,10 +5,8 @@ import {
|
|||||||
Home,
|
Home,
|
||||||
Users,
|
Users,
|
||||||
Cloud,
|
Cloud,
|
||||||
CreditCard,
|
|
||||||
Headphones,
|
Headphones,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Wallet,
|
|
||||||
BookOpen,
|
BookOpen,
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
Award,
|
Award,
|
||||||
@ -23,7 +21,9 @@ import {
|
|||||||
Target,
|
Target,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Package,
|
Package,
|
||||||
FileText
|
FileText,
|
||||||
|
User,
|
||||||
|
ChevronRight
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { RootState } from '../../store';
|
import { RootState } from '../../store';
|
||||||
import { toggleTheme } from '../../store/slices/themeSlice';
|
import { toggleTheme } from '../../store/slices/themeSlice';
|
||||||
@ -34,15 +34,13 @@ const resellerNavigation = [
|
|||||||
{ name: 'Dashboard', href: '/reseller-dashboard', icon: Home },
|
{ name: 'Dashboard', href: '/reseller-dashboard', icon: Home },
|
||||||
{ name: 'Customers', href: '/reseller-dashboard/customers', icon: Users },
|
{ name: 'Customers', href: '/reseller-dashboard/customers', icon: Users },
|
||||||
{ name: 'Products', href: '/reseller-dashboard/products', icon: Package },
|
{ name: 'Products', href: '/reseller-dashboard/products', icon: Package },
|
||||||
{ name: 'Billing', href: '/reseller-dashboard/billing', icon: CreditCard },
|
{ name: 'Receipts & Sales', href: '/reseller-dashboard/receipts', icon: FileText },
|
||||||
{ name: 'Support', href: '/reseller-dashboard/support', icon: Headphones },
|
{ name: 'Support', href: '/reseller-dashboard/support', icon: Headphones },
|
||||||
{ name: 'Reports', href: '/reseller-dashboard/reports', icon: BarChart3 },
|
|
||||||
{ name: 'Receipts', href: '/reseller-dashboard/receipts', icon: FileText },
|
|
||||||
{ name: 'Wallet', href: '/reseller-dashboard/wallet', icon: Wallet },
|
|
||||||
{ name: 'Training', href: '/reseller-dashboard/training', icon: BookOpen },
|
{ name: 'Training', href: '/reseller-dashboard/training', icon: BookOpen },
|
||||||
{ name: 'Marketplace', href: '/reseller-dashboard/marketplace', icon: ShoppingBag },
|
|
||||||
{ name: 'Certifications', href: '/reseller-dashboard/certifications', icon: Award },
|
{ name: 'Certifications', href: '/reseller-dashboard/certifications', icon: Award },
|
||||||
{ name: 'Knowledge Base', href: '/reseller-dashboard/knowledge-base', icon: HelpCircle },
|
{ name: 'Knowledge Base', href: '/reseller-dashboard/knowledge-base', icon: HelpCircle },
|
||||||
|
{ name: 'Reports', href: '/reseller-dashboard/reports', icon: BarChart3 },
|
||||||
|
{ name: 'Marketplace', href: '/reseller-dashboard/marketplace', icon: ShoppingBag },
|
||||||
{ name: 'Settings', href: '/reseller-dashboard/settings', icon: Settings },
|
{ name: 'Settings', href: '/reseller-dashboard/settings', icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -62,35 +60,35 @@ const ResellerSidebar: React.FC = () => {
|
|||||||
"flex flex-col h-full bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900 border-r border-slate-700/50 transition-all duration-300",
|
"flex flex-col h-full bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900 border-r border-slate-700/50 transition-all duration-300",
|
||||||
isCollapsed ? "w-16" : "w-64"
|
isCollapsed ? "w-16" : "w-64"
|
||||||
)}>
|
)}>
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-slate-700/50">
|
|
||||||
{!isCollapsed && (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="w-8 h-8 bg-gradient-to-r from-emerald-500 to-teal-500 rounded-lg flex items-center justify-center">
|
|
||||||
<Cloud className="w-5 h-5 text-white" />
|
|
||||||
</div>
|
|
||||||
<span className="text-lg font-semibold text-white">
|
|
||||||
Reseller
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
|
||||||
className="p-1 rounded-md hover:bg-slate-700/50 transition-colors"
|
|
||||||
>
|
|
||||||
{isCollapsed ? (
|
|
||||||
<Menu className="w-6 h-6 text-slate-400" />
|
|
||||||
) : (
|
|
||||||
<X className="w-5 h-5 text-slate-400" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className={cn(
|
<nav className={cn(
|
||||||
"flex-1 py-4 space-y-1 overflow-y-auto",
|
"flex-1 py-4 space-y-1 overflow-y-auto",
|
||||||
isCollapsed ? "px-3" : "px-2"
|
isCollapsed ? "px-3" : "px-2"
|
||||||
)}>
|
)}>
|
||||||
|
{/* Collapse/Expand Button */}
|
||||||
|
<div className={cn(
|
||||||
|
"flex mb-4",
|
||||||
|
isCollapsed ? "justify-center" : "justify-end"
|
||||||
|
)}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
|
className={cn(
|
||||||
|
"p-2 rounded-md hover:bg-slate-700/50 transition-colors text-slate-400 hover:text-white",
|
||||||
|
isCollapsed ? "w-full justify-center" : ""
|
||||||
|
)}
|
||||||
|
title={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||||
|
>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
<ChevronRight className="w-4 h-4 ml-1" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{resellerNavigation.map((item) => {
|
{resellerNavigation.map((item) => {
|
||||||
const isActive = location.pathname === item.href;
|
const isActive = location.pathname === item.href;
|
||||||
return (
|
return (
|
||||||
@ -98,65 +96,64 @@ const ResellerSidebar: React.FC = () => {
|
|||||||
key={item.name}
|
key={item.name}
|
||||||
to={item.href}
|
to={item.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center text-sm font-medium rounded-xl transition-all duration-200 group",
|
"flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors duration-200",
|
||||||
isCollapsed ? "px-2 py-3 justify-center" : "px-4 py-3",
|
|
||||||
isActive
|
isActive
|
||||||
? "bg-gradient-to-r from-emerald-500/20 to-teal-500/20 text-emerald-400 border border-emerald-500/30 shadow-lg"
|
? "bg-emerald-600 text-white shadow-lg"
|
||||||
: "text-slate-300 hover:bg-slate-800/50 hover:text-white"
|
: "text-slate-300 hover:bg-slate-700/50 hover:text-white"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className={cn(
|
<item.icon className={cn(
|
||||||
"flex-shrink-0 transition-colors duration-200",
|
"flex-shrink-0 w-5 h-5",
|
||||||
isCollapsed ? "w-6 h-6" : "w-5 h-5",
|
isCollapsed ? "" : "mr-3",
|
||||||
isActive
|
isActive ? "text-white" : "text-slate-400"
|
||||||
? "text-emerald-400"
|
|
||||||
: "text-slate-400 group-hover:text-white"
|
|
||||||
)} />
|
)} />
|
||||||
{!isCollapsed && (
|
{!isCollapsed && <span>{item.name}</span>}
|
||||||
<span className="ml-3">{item.name}</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User Profile & Actions */}
|
{/* Footer */}
|
||||||
<div className="border-t border-slate-700/50 p-4 space-y-2">
|
<div className="p-4 border-t border-slate-700/50">
|
||||||
{/* Theme Toggle */}
|
<div className="flex items-center justify-between">
|
||||||
<button
|
|
||||||
onClick={() => dispatch(toggleTheme())}
|
|
||||||
className={cn(
|
|
||||||
"w-full flex items-center rounded-xl text-sm font-medium transition-all duration-200",
|
|
||||||
isCollapsed ? "justify-center px-2 py-3" : "px-4 py-3",
|
|
||||||
"text-slate-300 hover:bg-slate-800/50 hover:text-white"
|
|
||||||
)}
|
|
||||||
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
|
||||||
>
|
|
||||||
{theme === 'dark' ? (
|
|
||||||
<Sun className="w-6 h-6 text-amber-400" />
|
|
||||||
) : (
|
|
||||||
<Moon className="w-6 h-6 text-slate-400" />
|
|
||||||
)}
|
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<span className="ml-3">
|
<div className="flex items-center space-x-2">
|
||||||
{theme === 'dark' ? 'Light Mode' : 'Dark Mode'}
|
<div className="w-8 h-8 bg-gradient-to-r from-emerald-500 to-teal-500 rounded-lg flex items-center justify-center">
|
||||||
</span>
|
<User className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-white truncate">
|
||||||
|
{user?.firstName && user?.lastName ? `${user.firstName} ${user.lastName}` : user?.email || 'User'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400 truncate">
|
||||||
|
{user?.email || 'reseller@example.com'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
{/* Logout */}
|
<button
|
||||||
<button
|
onClick={() => dispatch(toggleTheme())}
|
||||||
onClick={handleLogout}
|
className="p-2 rounded-md hover:bg-slate-700/50 transition-colors"
|
||||||
className={cn(
|
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
"w-full flex items-center rounded-xl text-sm font-medium transition-all duration-200",
|
>
|
||||||
isCollapsed ? "justify-center px-2 py-3" : "px-4 py-3",
|
{theme === 'dark' ? (
|
||||||
"text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
<Sun className="w-4 h-4 text-slate-400" />
|
||||||
)}
|
) : (
|
||||||
title="Logout"
|
<Moon className="w-4 h-4 text-slate-400" />
|
||||||
>
|
)}
|
||||||
<LogOut className="w-6 h-6" />
|
</button>
|
||||||
{!isCollapsed && <span className="ml-3">Logout</span>}
|
|
||||||
</button>
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="p-2 rounded-md hover:bg-slate-700/50 transition-colors"
|
||||||
|
title="Logout"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4 text-slate-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useAppSelector, useAppDispatch } from '../../store/hooks';
|
import { useAppSelector, useAppDispatch } from '../../store/hooks';
|
||||||
import {
|
import {
|
||||||
Home,
|
Home,
|
||||||
@ -35,15 +35,15 @@ import toast from 'react-hot-toast';
|
|||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: 'Dashboard', href: '/dashboard', icon: Home },
|
{ name: 'Dashboard', href: '/dashboard', icon: Home },
|
||||||
{ name: 'Product Management', href: '/product-management', icon: Package },
|
{ name: 'Product Management', href: '/product-management', icon: Package },
|
||||||
{ name: 'Reseller Requests', href: '/resellers', icon: Users },
|
{ name: 'Sales Management', href: '/sales-management', icon: ShoppingBag },
|
||||||
{ name: 'Approved Resellers', href: '/approved-resellers', icon: Handshake },
|
{ name: 'Reseller Requests', href: '/resellers', icon: Users },
|
||||||
// { name: 'Deals', href: '/deals', icon: Briefcase },
|
{ name: 'Approved Resellers', href: '/approved-resellers', icon: Handshake },
|
||||||
|
// { name: 'Deals', href: '/deals', icon: Briefcase },
|
||||||
{ name: 'Commissions', href: '/commissions', icon: Wallet },
|
{ name: 'Commissions', href: '/commissions', icon: Wallet },
|
||||||
{ name: 'Training', href: '/training', icon: BookOpen },
|
{ name: 'Training', href: '/training', icon: BookOpen },
|
||||||
{ name: 'Support', href: '/support', icon: Headphones },
|
{ name: 'Support', href: '/support', icon: Headphones },
|
||||||
{ name: 'Analytics', href: '/analytics', icon: BarChart3 },
|
{ name: 'Analytics', href: '/analytics', icon: BarChart3 },
|
||||||
{ name: 'Reports', href: '/reports', icon: FileText },
|
{ name: 'Reports', href: '/reports', icon: FileText },
|
||||||
{ name: 'Targets', href: '/targets', icon: Target },
|
|
||||||
{ name: 'Performance', href: '/performance', icon: TrendingUp },
|
{ name: 'Performance', href: '/performance', icon: TrendingUp },
|
||||||
{ name: 'Marketplace', href: '/marketplace', icon: ShoppingBag },
|
{ name: 'Marketplace', href: '/marketplace', icon: ShoppingBag },
|
||||||
{ name: 'Certifications', href: '/certifications', icon: Award },
|
{ name: 'Certifications', href: '/certifications', icon: Award },
|
||||||
@ -54,6 +54,7 @@ const navigation = [
|
|||||||
const Sidebar: React.FC = () => {
|
const Sidebar: React.FC = () => {
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { theme } = useAppSelector((state: RootState) => state.theme);
|
const { theme } = useAppSelector((state: RootState) => state.theme);
|
||||||
const { user } = useAppSelector((state: RootState) => state.auth);
|
const { user } = useAppSelector((state: RootState) => state.auth);
|
||||||
@ -61,6 +62,11 @@ const Sidebar: React.FC = () => {
|
|||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
dispatch(logout());
|
dispatch(logout());
|
||||||
toast.success('Logged out successfully');
|
toast.success('Logged out successfully');
|
||||||
|
|
||||||
|
// Wait a bit for Redux state to update, then navigate
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate('/login');
|
||||||
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -57,10 +57,14 @@ const NotificationBell: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.on('SUPPORT_TICKET', (data: { title: string }) => {
|
socket.on('SUPPORT_TICKET', (data: { title: string }) => {
|
||||||
|
// Only show support ticket notifications for admin users
|
||||||
|
const isAdmin = user?.roles?.[0]?.name === 'system_admin' || user?.role === 'system_admin';
|
||||||
|
if (isAdmin) {
|
||||||
toast.error(`New support ticket: ${data.title}`, {
|
toast.error(`New support ticket: ${data.title}`, {
|
||||||
duration: 6000,
|
duration: 6000,
|
||||||
});
|
});
|
||||||
fetchUnreadCount();
|
fetchUnreadCount();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('VENDOR_NOTIFICATION', (data: { message: string; type: 'success' | 'error' | 'info' }) => {
|
socket.on('VENDOR_NOTIFICATION', (data: { message: string; type: 'success' | 'error' | 'info' }) => {
|
||||||
@ -71,6 +75,35 @@ const NotificationBell: React.FC = () => {
|
|||||||
fetchUnreadCount();
|
fetchUnreadCount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reseller-related events
|
||||||
|
socket.on('NEW_RESELLER_REQUEST', (data: { resellerName: string; company: string; vendorId: string }) => {
|
||||||
|
toast.success(`New reseller request: ${data.resellerName} from ${data.company}`, {
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
fetchUnreadCount();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('RESELLER_CREATED', (data: { resellerName: string; company: string; vendorId: string }) => {
|
||||||
|
toast.success(`New reseller account created: ${data.resellerName} from ${data.company}`, {
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
fetchUnreadCount();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('RESELLER_APPROVED', (data: { resellerName: string; company: string; vendorId: string }) => {
|
||||||
|
toast.success(`Reseller approved: ${data.resellerName} from ${data.company}`, {
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
fetchUnreadCount();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('RESELLER_REJECTED', (data: { resellerName: string; company: string; vendorId: string; reason?: string }) => {
|
||||||
|
toast.error(`Reseller rejected: ${data.resellerName} from ${data.company}`, {
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
fetchUnreadCount();
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off('connect');
|
socket.off('connect');
|
||||||
socket.off('disconnect');
|
socket.off('disconnect');
|
||||||
@ -79,9 +112,13 @@ const NotificationBell: React.FC = () => {
|
|||||||
socket.off('PAYMENT_RECEIVED');
|
socket.off('PAYMENT_RECEIVED');
|
||||||
socket.off('SUPPORT_TICKET');
|
socket.off('SUPPORT_TICKET');
|
||||||
socket.off('VENDOR_NOTIFICATION');
|
socket.off('VENDOR_NOTIFICATION');
|
||||||
|
socket.off('NEW_RESELLER_REQUEST');
|
||||||
|
socket.off('RESELLER_CREATED');
|
||||||
|
socket.off('RESELLER_APPROVED');
|
||||||
|
socket.off('RESELLER_REJECTED');
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [socket]);
|
}, [socket, user]);
|
||||||
|
|
||||||
const fetchUnreadCount = async () => {
|
const fetchUnreadCount = async () => {
|
||||||
try {
|
try {
|
||||||
@ -95,6 +132,8 @@ const NotificationBell: React.FC = () => {
|
|||||||
endpoint = '/resellers/notifications/stats';
|
endpoint = '/resellers/notifications/stats';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('Fetching notification stats from:', endpoint);
|
||||||
|
|
||||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}${endpoint}`, {
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}${endpoint}`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
@ -104,10 +143,18 @@ const NotificationBell: React.FC = () => {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setUnreadCount(data.data?.unreadNotifications || 0);
|
console.log('Notification stats response:', data);
|
||||||
|
setUnreadCount(data.data?.unread || data.unread || 0);
|
||||||
|
} else if (response.status === 404) {
|
||||||
|
console.log('Notification stats endpoint not found, setting count to 0');
|
||||||
|
setUnreadCount(0);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch notification stats:', response.status);
|
||||||
|
setUnreadCount(0);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching notification count:', error);
|
console.error('Error fetching notification count:', error);
|
||||||
|
setUnreadCount(0);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,6 +33,13 @@ const NotificationPanel: React.FC<NotificationPanelProps> = ({ isOpen, onClose }
|
|||||||
const [deletingNotificationId, setDeletingNotificationId] = useState<string | null>(null);
|
const [deletingNotificationId, setDeletingNotificationId] = useState<string | null>(null);
|
||||||
const { user } = useAppSelector((state) => state.auth);
|
const { user } = useAppSelector((state) => state.auth);
|
||||||
|
|
||||||
|
// Ensure admin users always see unread notifications
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.role === 'system_admin') {
|
||||||
|
setActiveTab('unread');
|
||||||
|
}
|
||||||
|
}, [user?.role]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
fetchNotifications();
|
fetchNotifications();
|
||||||
@ -76,7 +83,8 @@ const NotificationPanel: React.FC<NotificationPanelProps> = ({ isOpen, onClose }
|
|||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
page: '1',
|
page: '1',
|
||||||
limit: '50',
|
limit: '50',
|
||||||
...(activeTab === 'unread' && { unreadOnly: 'true' })
|
...(activeTab === 'unread' && { unreadOnly: 'true' }),
|
||||||
|
...(user?.role === 'system_admin' && { unreadOnly: 'true' })
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}${endpoint}?${params}`, {
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}${endpoint}?${params}`, {
|
||||||
@ -88,7 +96,12 @@ const NotificationPanel: React.FC<NotificationPanelProps> = ({ isOpen, onClose }
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setNotifications(data.data.notifications);
|
// Filter out read notifications for admin users
|
||||||
|
let filteredNotifications = data.data.notifications;
|
||||||
|
if (user?.role === 'system_admin') {
|
||||||
|
filteredNotifications = filteredNotifications.filter((n: Notification) => !n.isRead);
|
||||||
|
}
|
||||||
|
setNotifications(filteredNotifications);
|
||||||
setUnreadCount(data.data.notifications.filter((n: Notification) => !n.isRead).length);
|
setUnreadCount(data.data.notifications.filter((n: Notification) => !n.isRead).length);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -233,7 +246,7 @@ const NotificationPanel: React.FC<NotificationPanelProps> = ({ isOpen, onClose }
|
|||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Bell className="w-6 h-6 text-gray-600 dark:text-gray-400" />
|
<Bell className="w-6 h-6 text-gray-600 dark:text-gray-400" />
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
Notifications
|
{user?.role === 'system_admin' ? 'Unread Notifications' : 'Notifications'}
|
||||||
</h2>
|
</h2>
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<span className="bg-red-500 text-white text-xs font-bold px-2 py-1 rounded-full">
|
<span className="bg-red-500 text-white text-xs font-bold px-2 py-1 rounded-full">
|
||||||
@ -242,7 +255,7 @@ const NotificationPanel: React.FC<NotificationPanelProps> = ({ isOpen, onClose }
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && user?.role !== 'system_admin' && (
|
||||||
<button
|
<button
|
||||||
onClick={markAllAsRead}
|
onClick={markAllAsRead}
|
||||||
className="text-sm text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
|
className="text-sm text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
|
||||||
@ -261,25 +274,27 @@ const NotificationPanel: React.FC<NotificationPanelProps> = ({ isOpen, onClose }
|
|||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||||
<button
|
{user?.role !== 'system_admin' && (
|
||||||
onClick={() => setActiveTab('all')}
|
<button
|
||||||
className={`flex-1 py-3 px-4 text-sm font-medium ${
|
onClick={() => setActiveTab('all')}
|
||||||
activeTab === 'all'
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||||
? 'text-blue-600 border-b-2 border-blue-600'
|
activeTab === 'all'
|
||||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||||
}`}
|
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||||
>
|
}`}
|
||||||
All
|
>
|
||||||
</button>
|
All
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('unread')}
|
onClick={() => setActiveTab('unread')}
|
||||||
className={`flex-1 py-3 px-4 text-sm font-medium ${
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||||
activeTab === 'unread'
|
activeTab === 'unread'
|
||||||
? 'text-blue-600 border-b-2 border-blue-600'
|
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Unread ({unreadCount})
|
{user?.role === 'system_admin' ? 'Unread Notifications' : 'Unread'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -292,8 +307,12 @@ const NotificationPanel: React.FC<NotificationPanelProps> = ({ isOpen, onClose }
|
|||||||
) : notifications.length === 0 ? (
|
) : notifications.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-gray-500 dark:text-gray-400">
|
<div className="flex flex-col items-center justify-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
<Bell className="w-12 h-12 mb-4 opacity-50" />
|
<Bell className="w-12 h-12 mb-4 opacity-50" />
|
||||||
<p className="text-lg font-medium">No notifications</p>
|
<p className="text-lg font-medium">
|
||||||
<p className="text-sm">You're all caught up!</p>
|
{user?.role === 'system_admin' ? 'No unread notifications' : 'No notifications'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
{user?.role === 'system_admin' ? 'All notifications have been read' : "You're all caught up!"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
notifications.map((notification) => (
|
notifications.map((notification) => (
|
||||||
@ -342,13 +361,16 @@ const NotificationPanel: React.FC<NotificationPanelProps> = ({ isOpen, onClose }
|
|||||||
<Check className="w-4 h-4" />
|
<Check className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
{/* Hide delete button for admin users */}
|
||||||
onClick={() => deleteNotification(notification.id)}
|
{user?.role !== 'system_admin' && (
|
||||||
className="p-1 text-gray-400 hover:text-red-600 dark:hover:text-red-400"
|
<button
|
||||||
title="Delete"
|
onClick={() => deleteNotification(notification.id)}
|
||||||
>
|
className="p-1 text-gray-400 hover:text-red-600 dark:hover:text-red-400"
|
||||||
<Trash2 className="w-4 h-4" />
|
title="Delete"
|
||||||
</button>
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -360,7 +382,7 @@ const NotificationPanel: React.FC<NotificationPanelProps> = ({ isOpen, onClose }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete Confirmation Modal */}
|
{/* Delete Confirmation Modal */}
|
||||||
{isDeleteModalOpen && (
|
{isDeleteModalOpen && user?.role !== 'system_admin' && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999] p-4" style={{ backdropFilter: 'blur(4px)' }}>
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999] p-4" style={{ backdropFilter: 'blur(4px)' }}>
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl p-6 w-full max-w-md">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl p-6 w-full max-w-md">
|
||||||
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Confirm Delete</h2>
|
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Confirm Delete</h2>
|
||||||
|
|||||||
446
src/components/ResellerCertificates.tsx
Normal file
446
src/components/ResellerCertificates.tsx
Normal file
@ -0,0 +1,446 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Award, Search, Download, Eye, Calendar, BookOpen, Star } from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface Certificate {
|
||||||
|
id: number;
|
||||||
|
certificateNumber: string;
|
||||||
|
issuedAt: string;
|
||||||
|
completionDate: string;
|
||||||
|
grade: string;
|
||||||
|
score: number;
|
||||||
|
course: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
level: string;
|
||||||
|
category: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CertificateStats {
|
||||||
|
totalCertificates: number;
|
||||||
|
thisMonthCertificates: number;
|
||||||
|
thisYearCertificates: number;
|
||||||
|
averageScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResellerCertificates: React.FC = () => {
|
||||||
|
const [certificates, setCertificates] = useState<Certificate[]>([]);
|
||||||
|
const [stats, setStats] = useState<CertificateStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedCertificate, setSelectedCertificate] = useState<Certificate | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCertificates();
|
||||||
|
fetchCertificateStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchCertificates = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/training-new/certificates`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setCertificates(data.data || []);
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to fetch certificates');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching certificates:', error);
|
||||||
|
toast.error('Failed to fetch certificates');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchCertificateStats = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/training-new/certificates/stats`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setStats(data.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching certificate stats:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGradeColor = (grade: string) => {
|
||||||
|
switch (grade) {
|
||||||
|
case 'Pass': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||||
|
case 'Merit': return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
|
||||||
|
case 'Distinction': return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200';
|
||||||
|
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLevelColor = (level: string) => {
|
||||||
|
switch (level) {
|
||||||
|
case 'Beginner': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||||
|
case 'Intermediate': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
|
||||||
|
case 'Advanced': return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||||
|
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getScoreColor = (score: number) => {
|
||||||
|
if (score >= 90) return 'text-purple-600 dark:text-purple-400';
|
||||||
|
if (score >= 80) return 'text-blue-600 dark:text-blue-400';
|
||||||
|
if (score >= 70) return 'text-green-600 dark:text-green-400';
|
||||||
|
if (score >= 60) return 'text-yellow-600 dark:text-yellow-400';
|
||||||
|
return 'text-red-600 dark:text-red-400';
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredCertificates = certificates.filter(certificate =>
|
||||||
|
certificate.course.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
certificate.course.category.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
certificate.grade.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-secondary-900 dark:text-white mb-3">
|
||||||
|
My Certificates
|
||||||
|
</h1>
|
||||||
|
<p className="text-secondary-600 dark:text-secondary-400 text-lg">
|
||||||
|
View and download your earned training certificates
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
{stats && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
Total Certificates
|
||||||
|
</p>
|
||||||
|
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{stats.totalCertificates}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||||
|
<Award className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
This Month
|
||||||
|
</p>
|
||||||
|
<p className="text-3xl font-bold text-green-600 dark:text-green-400">
|
||||||
|
{stats.thisMonthCertificates}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center">
|
||||||
|
<Calendar className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
This Year
|
||||||
|
</p>
|
||||||
|
<p className="text-3xl font-bold text-yellow-600 dark:text-yellow-400">
|
||||||
|
{stats.thisYearCertificates}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center">
|
||||||
|
<Calendar className="w-6 h-6 text-yellow-600 dark:text-yellow-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
Average Score
|
||||||
|
</p>
|
||||||
|
<p className="text-3xl font-bold text-purple-600 dark:text-purple-400">
|
||||||
|
{stats.averageScore}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900 rounded-full flex items-center justify-center">
|
||||||
|
<BookOpen className="w-6 h-6 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<form onSubmit={handleSearch} className="max-w-md">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search certificates..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Certificates Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{filteredCertificates.map((certificate) => (
|
||||||
|
<div key={certificate.id} className="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-xl transition-shadow duration-300">
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Certificate Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="w-12 h-12 bg-gradient-to-br from-yellow-400 to-orange-500 rounded-full flex items-center justify-center">
|
||||||
|
<Award className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">Certificate #</div>
|
||||||
|
<div className="text-sm font-mono text-gray-900 dark:text-white">
|
||||||
|
{certificate.certificateNumber}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Course Info */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2 line-clamp-2">
|
||||||
|
{certificate.course.title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center space-x-2 mb-3">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getLevelColor(certificate.course.level)}`}>
|
||||||
|
{certificate.course.level}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{certificate.course.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grade and Score */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getGradeColor(certificate.grade)}`}>
|
||||||
|
{certificate.grade}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<Star className="w-4 h-4 text-yellow-500" />
|
||||||
|
<span className={`text-lg font-bold ${getScoreColor(certificate.score)}`}>
|
||||||
|
{certificate.score}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dates */}
|
||||||
|
<div className="space-y-2 mb-6 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Completed:</span>
|
||||||
|
<span className="text-gray-900 dark:text-white">
|
||||||
|
{formatDate(certificate.completionDate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Issued:</span>
|
||||||
|
<span className="text-gray-900 dark:text-white">
|
||||||
|
{formatDate(certificate.issuedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedCertificate(certificate)}
|
||||||
|
className="flex-1 btn btn-outline btn-sm"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4 mr-2" />
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
<button className="flex-1 btn btn-primary btn-sm">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredCertificates.length === 0 && !loading && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Award className="w-8 h-8 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||||
|
{search ? 'No certificates found' : 'No certificates earned yet'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
{search ? 'Try adjusting your search terms' : 'Complete training courses to earn your first certificate'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Certificate Detail Modal */}
|
||||||
|
{selectedCertificate && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-12 h-12 bg-gradient-to-br from-yellow-400 to-orange-500 rounded-full flex items-center justify-center">
|
||||||
|
<Award className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Certificate Details
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
{selectedCertificate.certificateNumber}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedCertificate(null)}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Course Information */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
|
Course Information
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Course Title:</span>
|
||||||
|
<p className="text-gray-900 dark:text-white font-medium">
|
||||||
|
{selectedCertificate.course.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Level:</span>
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getLevelColor(selectedCertificate.course.level)}`}>
|
||||||
|
{selectedCertificate.course.level}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Category:</span>
|
||||||
|
<p className="text-gray-900 dark:text-white">
|
||||||
|
{selectedCertificate.course.category}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Description:</span>
|
||||||
|
<p className="text-gray-900 dark:text-white line-clamp-2">
|
||||||
|
{selectedCertificate.course.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Achievement Details */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
|
Achievement Details
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Grade:</span>
|
||||||
|
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getGradeColor(selectedCertificate.grade)}`}>
|
||||||
|
{selectedCertificate.grade}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Score:</span>
|
||||||
|
<p className={`text-lg font-bold ${getScoreColor(selectedCertificate.score)}`}>
|
||||||
|
{selectedCertificate.score}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Completion Date:</span>
|
||||||
|
<p className="text-gray-900 dark:text-white">
|
||||||
|
{formatDate(selectedCertificate.completionDate)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Issued Date:</span>
|
||||||
|
<p className="text-gray-900 dark:text-white">
|
||||||
|
{formatDate(selectedCertificate.issuedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex space-x-3 pt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedCertificate(null)}
|
||||||
|
className="flex-1 btn btn-outline"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button className="flex-1 btn btn-primary">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download Certificate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResellerCertificates;
|
||||||
264
src/components/ResellerTargetsDisplay.tsx
Normal file
264
src/components/ResellerTargetsDisplay.tsx
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Target, TrendingUp, Calendar, DollarSign, CheckCircle, Clock, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ResellerTarget {
|
||||||
|
id: number;
|
||||||
|
targetType: string;
|
||||||
|
targetPeriod: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
salesTarget: number;
|
||||||
|
quantityTarget?: number;
|
||||||
|
baseCommissionRate: number;
|
||||||
|
bonusCommissionRate?: number;
|
||||||
|
currentSales: number;
|
||||||
|
currentQuantity: number;
|
||||||
|
achievementPercentage: number;
|
||||||
|
status: string;
|
||||||
|
isTargetMet: boolean;
|
||||||
|
reseller: {
|
||||||
|
id: number;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
company: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResellerTargetsDisplayProps {
|
||||||
|
targets: ResellerTarget[];
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResellerTargetsDisplay: React.FC<ResellerTargetsDisplayProps> = ({ targets, loading = false }) => {
|
||||||
|
// Immediate debugging
|
||||||
|
console.log('ResellerTargetsDisplay rendering with props:', {
|
||||||
|
targets,
|
||||||
|
targetsType: typeof targets,
|
||||||
|
targetsIsArray: Array.isArray(targets),
|
||||||
|
loading,
|
||||||
|
hasTargets: !!targets
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure targets is always an array
|
||||||
|
const safeTargets = Array.isArray(targets) ? targets : [];
|
||||||
|
|
||||||
|
console.log('ResellerTargetsDisplay: Safe targets created:', {
|
||||||
|
safeTargets,
|
||||||
|
safeTargetsType: typeof safeTargets,
|
||||||
|
safeTargetsIsArray: Array.isArray(safeTargets),
|
||||||
|
safeTargetsLength: safeTargets.length
|
||||||
|
});
|
||||||
|
|
||||||
|
const getTargetStatusColor = (target: ResellerTarget) => {
|
||||||
|
if (target.isTargetMet) return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
|
||||||
|
if (target.achievementPercentage >= 80) return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300';
|
||||||
|
if (target.achievementPercentage >= 50) return 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300';
|
||||||
|
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTargetStatusIcon = (target: ResellerTarget) => {
|
||||||
|
if (target.isTargetMet) return <CheckCircle className="w-4 h-4" />;
|
||||||
|
if (target.achievementPercentage >= 80) return <TrendingUp className="w-4 h-4" />;
|
||||||
|
if (target.achievementPercentage >= 50) return <Clock className="w-4 h-4" />;
|
||||||
|
return <AlertCircle className="w-4 h-4" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProgressColor = (percentage: number) => {
|
||||||
|
if (percentage >= 100) return 'bg-green-500';
|
||||||
|
if (percentage >= 80) return 'bg-yellow-500';
|
||||||
|
if (percentage >= 50) return 'bg-orange-500';
|
||||||
|
return 'bg-red-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-1/3 mb-4"></div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!safeTargets || safeTargets.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Target className="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">Set Your First Reseller Target</h3>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 mb-6 max-w-md mx-auto">
|
||||||
|
Create performance targets for your resellers to track their progress and calculate commissions based on achievement.
|
||||||
|
</p>
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg max-w-md mx-auto">
|
||||||
|
<h4 className="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">Why Set Targets?</h4>
|
||||||
|
<ul className="text-xs text-blue-700 dark:text-blue-300 space-y-1 text-left">
|
||||||
|
<li>• <strong>Performance Tracking</strong> - Monitor reseller progress</li>
|
||||||
|
<li>• <strong>Commission Calculation</strong> - Pay based on achievement</li>
|
||||||
|
<li>• <strong>Motivation</strong> - Clear goals drive better results</li>
|
||||||
|
<li>• <strong>Analytics</strong> - Data-driven business decisions</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Reseller Targets & Performance
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Target className="w-5 h-5 text-blue-500" />
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{safeTargets.length} active target{safeTargets.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{safeTargets.map((target) => (
|
||||||
|
<div key={target.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
{/* Target Header */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||||
|
<Target className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{target.reseller.firstName} {target.reseller.lastName}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">{target.reseller.company}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`px-3 py-1 rounded-full text-xs font-medium flex items-center space-x-1 ${getTargetStatusColor(target)}`}>
|
||||||
|
{getTargetStatusIcon(target)}
|
||||||
|
<span>{target.isTargetMet ? 'Target Met' : `${target.achievementPercentage.toFixed(1)}%`}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Target Details */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Target Period</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">{target.targetPeriod}</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">{target.targetType}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Sales Target</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
${target.salesTarget.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{target.quantityTarget ? `${target.quantityTarget} units` : 'Amount only'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Current Progress</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
${target.currentSales.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{target.currentQuantity} units
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Commission Rate</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{target.baseCommissionRate}%
|
||||||
|
</p>
|
||||||
|
{target.bonusCommissionRate && (
|
||||||
|
<p className="text-xs text-green-600 dark:text-green-400">
|
||||||
|
+{target.bonusCommissionRate}% bonus
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
<span>Progress</span>
|
||||||
|
<span>{target.achievementPercentage.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full transition-all duration-300 ${getProgressColor(target.achievementPercentage)}`}
|
||||||
|
style={{ width: `${Math.min(target.achievementPercentage, 100)}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Target Dates */}
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
<span>Start: {new Date(target.startDate).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
<span>End: {new Date(target.endDate).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Commission Info */}
|
||||||
|
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">Commission Status:</span>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{target.isTargetMet ? (
|
||||||
|
<div className="flex items-center space-x-1 text-green-600 dark:text-green-400">
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium">Eligible for {target.baseCommissionRate}%</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center space-x-1 text-orange-600 dark:text-orange-400">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Need ${(target.salesTarget - target.currentSales).toLocaleString()} more
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Stats */}
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{safeTargets.filter(t => t && t.isTargetMet).length}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">Targets Met</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||||
|
{safeTargets.filter(t => t && t.achievementPercentage >= 80).length}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">On Track</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-orange-600 dark:text-orange-400">
|
||||||
|
{safeTargets.filter(t => t && t.achievementPercentage < 50).length}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">Needs Attention</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResellerTargetsDisplay;
|
||||||
292
src/components/RichTextEditor.tsx
Normal file
292
src/components/RichTextEditor.tsx
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useEditor, EditorContent } from '@tiptap/react';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import CodeBlock from '@tiptap/extension-code-block';
|
||||||
|
import Highlight from '@tiptap/extension-highlight';
|
||||||
|
import Link from '@tiptap/extension-link';
|
||||||
|
import Image from '@tiptap/extension-image';
|
||||||
|
import { Table } from '@tiptap/extension-table';
|
||||||
|
import { TableRow } from '@tiptap/extension-table-row';
|
||||||
|
import { TableCell } from '@tiptap/extension-table-cell';
|
||||||
|
import { TableHeader } from '@tiptap/extension-table-header';
|
||||||
|
import {
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
Underline,
|
||||||
|
Strikethrough,
|
||||||
|
Code,
|
||||||
|
Quote,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
Heading3,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Table as TableIcon,
|
||||||
|
Undo,
|
||||||
|
Redo,
|
||||||
|
Highlighter,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface RichTextEditorProps {
|
||||||
|
content: string;
|
||||||
|
onChange: (content: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RichTextEditor: React.FC<RichTextEditorProps> = ({
|
||||||
|
content,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Start writing your article...'
|
||||||
|
}) => {
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
CodeBlock,
|
||||||
|
Highlight,
|
||||||
|
Link.configure({
|
||||||
|
openOnClick: false,
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'text-blue-600 underline cursor-pointer',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Image,
|
||||||
|
Table.configure({
|
||||||
|
resizable: true,
|
||||||
|
}),
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableHeader,
|
||||||
|
],
|
||||||
|
content,
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
onChange(editor.getHTML());
|
||||||
|
},
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: 'prose prose-slate dark:prose-invert max-w-none focus:outline-none min-h-[400px] p-4',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor && content !== editor.getHTML()) {
|
||||||
|
editor.commands.setContent(content);
|
||||||
|
}
|
||||||
|
}, [content, editor]);
|
||||||
|
|
||||||
|
if (!editor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const addLink = () => {
|
||||||
|
const url = window.prompt('Enter URL');
|
||||||
|
if (url) {
|
||||||
|
editor.chain().focus().setLink({ href: url }).run();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addImage = () => {
|
||||||
|
const url = window.prompt('Enter image URL');
|
||||||
|
if (url) {
|
||||||
|
editor.chain().focus().setImage({ src: url }).run();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTable = () => {
|
||||||
|
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
||||||
|
};
|
||||||
|
|
||||||
|
const MenuBar = () => (
|
||||||
|
<div className="border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800 p-2 rounded-t-lg">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{/* Text Formatting */}
|
||||||
|
<div className="flex items-center gap-1 border-r border-slate-300 dark:border-slate-600 pr-2">
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
|
className={`p-2 rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors ${
|
||||||
|
editor.isActive('bold') ? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300' : ''
|
||||||
|
}`}
|
||||||
|
title="Bold"
|
||||||
|
>
|
||||||
|
<Bold className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
|
className={`p-2 rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors ${
|
||||||
|
editor.isActive('italic') ? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300' : ''
|
||||||
|
}`}
|
||||||
|
title="Italic"
|
||||||
|
>
|
||||||
|
<Italic className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||||
|
className={`p-2 rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors ${
|
||||||
|
editor.isActive('underline') ? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300' : ''
|
||||||
|
}`}
|
||||||
|
title="Underline"
|
||||||
|
>
|
||||||
|
<Underline className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||||
|
className={`p-2 rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors ${
|
||||||
|
editor.isActive('strike') ? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300' : ''
|
||||||
|
}`}
|
||||||
|
title="Strikethrough"
|
||||||
|
>
|
||||||
|
<Strikethrough className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Headings */}
|
||||||
|
<div className="flex items-center gap-1 border-r border-slate-300 dark:border-slate-600 pr-2">
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||||
|
className={`p-2 rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors ${
|
||||||
|
editor.isActive('heading', { level: 1 }) ? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300' : ''
|
||||||
|
}`}
|
||||||
|
title="Heading 1"
|
||||||
|
>
|
||||||
|
<Heading1 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||||
|
className={`p-2 rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors ${
|
||||||
|
editor.isActive('heading', { level: 2 }) ? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300' : ''
|
||||||
|
}`}
|
||||||
|
title="Heading 2"
|
||||||
|
>
|
||||||
|
<Heading2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||||
|
className={`p-2 rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors ${
|
||||||
|
editor.isActive('heading', { level: 3 }) ? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300' : ''
|
||||||
|
}`}
|
||||||
|
title="Heading 3"
|
||||||
|
>
|
||||||
|
<Heading3 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lists */}
|
||||||
|
<div className="flex items-center gap-1 border-r border-slate-300 dark:border-slate-600 pr-2">
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
|
className={`p-2 rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors ${
|
||||||
|
editor.isActive('bulletList') ? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300' : ''
|
||||||
|
}`}
|
||||||
|
title="Bullet List"
|
||||||
|
>
|
||||||
|
<List className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
|
className={`p-2 rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors ${
|
||||||
|
editor.isActive('orderedList') ? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300' : ''
|
||||||
|
}`}
|
||||||
|
title="Ordered List"
|
||||||
|
>
|
||||||
|
<ListOrdered className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Special Elements */}
|
||||||
|
<div className="flex items-center gap-1 border-r border-slate-300 dark:border-slate-600 pr-2">
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||||
|
className={`p-2 rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors ${
|
||||||
|
editor.isActive('codeBlock') ? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300' : ''
|
||||||
|
}`}
|
||||||
|
title="Code Block"
|
||||||
|
>
|
||||||
|
<Code className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||||
|
className={`p-2 rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors ${
|
||||||
|
editor.isActive('blockquote') ? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300' : ''
|
||||||
|
}`}
|
||||||
|
title="Quote"
|
||||||
|
>
|
||||||
|
<Quote className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().toggleHighlight().run()}
|
||||||
|
className={`p-2 rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors ${
|
||||||
|
editor.isActive('highlight') ? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300' : ''
|
||||||
|
}`}
|
||||||
|
title="Highlight"
|
||||||
|
>
|
||||||
|
<Highlighter className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Insert Elements */}
|
||||||
|
<div className="flex items-center gap-1 border-r border-slate-300 dark:border-slate-600 pr-2">
|
||||||
|
<button
|
||||||
|
onClick={addLink}
|
||||||
|
className="p-2 rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
|
||||||
|
title="Add Link"
|
||||||
|
>
|
||||||
|
<LinkIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={addImage}
|
||||||
|
className="p-2 rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
|
||||||
|
title="Add Image"
|
||||||
|
>
|
||||||
|
<ImageIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={addTable}
|
||||||
|
className="p-2 rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
|
||||||
|
title="Add Table"
|
||||||
|
>
|
||||||
|
<TableIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* History */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().undo().run()}
|
||||||
|
className="p-2 rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
|
||||||
|
title="Undo"
|
||||||
|
>
|
||||||
|
<Undo className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editor.chain().focus().redo().run()}
|
||||||
|
className="p-2 rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
|
||||||
|
title="Redo"
|
||||||
|
>
|
||||||
|
<Redo className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden bg-white dark:bg-slate-900">
|
||||||
|
<MenuBar />
|
||||||
|
<EditorContent
|
||||||
|
editor={editor}
|
||||||
|
className="min-h-[400px] focus:outline-none"
|
||||||
|
/>
|
||||||
|
{!content && (
|
||||||
|
<div className="absolute top-16 left-4 text-slate-400 pointer-events-none">
|
||||||
|
{placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RichTextEditor;
|
||||||
159
src/components/SalesWorkflowVisual.tsx
Normal file
159
src/components/SalesWorkflowVisual.tsx
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { CheckCircle, Clock, XCircle, FileText, Target, TrendingUp, DollarSign } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SalesWorkflowVisualProps {
|
||||||
|
currentStep: 'created' | 'pending_verification' | 'verified' | 'rejected' | 'completed';
|
||||||
|
saleData?: {
|
||||||
|
id: number;
|
||||||
|
productName: string;
|
||||||
|
customerName: string;
|
||||||
|
quantity: number;
|
||||||
|
totalAmount: number;
|
||||||
|
createdAt: string;
|
||||||
|
verificationNotes?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SalesWorkflowVisual: React.FC<SalesWorkflowVisualProps> = ({ currentStep, saleData }) => {
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
id: 'created',
|
||||||
|
title: 'Sale Created',
|
||||||
|
description: 'Reseller creates sale request',
|
||||||
|
icon: FileText,
|
||||||
|
status: 'completed'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pending_verification',
|
||||||
|
title: 'Pending Verification',
|
||||||
|
description: 'Vendor reviews sale details',
|
||||||
|
icon: Clock,
|
||||||
|
status: currentStep === 'pending_verification' ? 'current' :
|
||||||
|
['verified', 'rejected', 'completed'].includes(currentStep) ? 'completed' : 'pending'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'verified',
|
||||||
|
title: 'Vendor Approved',
|
||||||
|
description: 'Sale verified, stock updated, receipt generated',
|
||||||
|
icon: CheckCircle,
|
||||||
|
status: currentStep === 'verified' ? 'current' :
|
||||||
|
currentStep === 'completed' ? 'completed' : 'pending'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'completed',
|
||||||
|
title: 'Sale Completed',
|
||||||
|
description: 'Payment processed, commission calculated',
|
||||||
|
icon: DollarSign,
|
||||||
|
status: currentStep === 'completed' ? 'current' : 'pending'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const getStepStatus = (step: any) => {
|
||||||
|
if (step.status === 'completed') return 'bg-green-500 text-white';
|
||||||
|
if (step.status === 'current') return 'bg-blue-500 text-white';
|
||||||
|
return 'bg-gray-300 text-gray-600';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStepIcon = (step: any) => {
|
||||||
|
const Icon = step.icon;
|
||||||
|
if (step.status === 'completed') return <CheckCircle className="w-5 h-5" />;
|
||||||
|
if (step.status === 'current') return <Icon className="w-5 h-5" />;
|
||||||
|
return <Icon className="w-5 h-5" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">
|
||||||
|
Sales Workflow Status
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Workflow Steps */}
|
||||||
|
<div className="relative">
|
||||||
|
{/* Progress Line */}
|
||||||
|
<div className="absolute top-6 left-0 right-0 h-0.5 bg-gray-200 dark:bg-gray-700 -z-10"></div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<div key={step.id} className="flex flex-col items-center">
|
||||||
|
{/* Step Icon */}
|
||||||
|
<div className={`w-12 h-12 rounded-full flex items-center justify-center mb-3 ${getStepStatus(step)}`}>
|
||||||
|
{getStepIcon(step)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step Title */}
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white text-center mb-1">
|
||||||
|
{step.title}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* Step Description */}
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 text-center leading-tight">
|
||||||
|
{step.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Status Details */}
|
||||||
|
{saleData && (
|
||||||
|
<div className="mt-8 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
|
||||||
|
Sale Details
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Product:</span>
|
||||||
|
<span className="ml-2 font-medium text-gray-900 dark:text-white">{saleData.productName}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Customer:</span>
|
||||||
|
<span className="ml-2 font-medium text-gray-900 dark:text-white">{saleData.customerName}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Quantity:</span>
|
||||||
|
<span className="ml-2 font-medium text-gray-900 dark:text-white">{saleData.quantity}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Amount:</span>
|
||||||
|
<span className="ml-2 font-medium text-gray-900 dark:text-white">${saleData.totalAmount}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Created:</span>
|
||||||
|
<span className="ml-2 font-medium text-gray-900 dark:text-white">
|
||||||
|
{new Date(saleData.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Status:</span>
|
||||||
|
<span className="ml-2 font-medium text-gray-900 dark:text-white capitalize">
|
||||||
|
{currentStep.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saleData.verificationNotes && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-600">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400 text-sm">Notes:</span>
|
||||||
|
<p className="text-sm text-gray-900 dark:text-white mt-1">{saleData.verificationNotes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Workflow Information */}
|
||||||
|
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||||
|
<h4 className="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">
|
||||||
|
How the Workflow Works
|
||||||
|
</h4>
|
||||||
|
<div className="text-xs text-blue-700 dark:text-blue-300 space-y-1">
|
||||||
|
<p>• <strong>Stock is NOT reduced</strong> until vendor approves the sale</p>
|
||||||
|
<p>• Vendor can approve, reject, or request changes</p>
|
||||||
|
<p>• Receipt is automatically generated upon approval</p>
|
||||||
|
<p>• Commissions are calculated based on target achievement</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SalesWorkflowVisual;
|
||||||
436
src/components/VendorCertificates.tsx
Normal file
436
src/components/VendorCertificates.tsx
Normal file
@ -0,0 +1,436 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Award, Search, Filter, Download, Eye, Calendar, User, BookOpen } from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface Certificate {
|
||||||
|
id: number;
|
||||||
|
certificateNumber: string;
|
||||||
|
issuedAt: string;
|
||||||
|
completionDate: string;
|
||||||
|
grade: string;
|
||||||
|
score: number;
|
||||||
|
course: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
level: string;
|
||||||
|
category: string;
|
||||||
|
};
|
||||||
|
user: {
|
||||||
|
id: number;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
avatar?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CertificateStats {
|
||||||
|
totalCertificates: number;
|
||||||
|
thisMonthCertificates: number;
|
||||||
|
thisYearCertificates: number;
|
||||||
|
averageScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VendorCertificates: React.FC = () => {
|
||||||
|
const [certificates, setCertificates] = useState<Certificate[]>([]);
|
||||||
|
const [stats, setStats] = useState<CertificateStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedCourse, setSelectedCourse] = useState<string>('all');
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [courses, setCourses] = useState<Array<{ id: number; title: string }>>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCertificates();
|
||||||
|
fetchCertificateStats();
|
||||||
|
fetchCourses();
|
||||||
|
}, [currentPage, search, selectedCourse]);
|
||||||
|
|
||||||
|
const fetchCertificates = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: currentPage.toString(),
|
||||||
|
limit: '10'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (search) params.append('search', search);
|
||||||
|
if (selectedCourse !== 'all') params.append('courseId', selectedCourse);
|
||||||
|
|
||||||
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/training-new/vendor/certificates?${params}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setCertificates(data.data.certificates || []);
|
||||||
|
setTotalPages(data.data.totalPages || 1);
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to fetch certificates');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching certificates:', error);
|
||||||
|
toast.error('Failed to fetch certificates');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchCertificateStats = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/training-new/vendor/certificates/stats`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setStats(data.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching certificate stats:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchCourses = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/training-new/courses`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setCourses(data.data.courses || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching courses:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCourseFilter = (courseId: string) => {
|
||||||
|
setSelectedCourse(courseId);
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGradeColor = (grade: string) => {
|
||||||
|
switch (grade) {
|
||||||
|
case 'Pass': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||||
|
case 'Merit': return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
|
||||||
|
case 'Distinction': return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200';
|
||||||
|
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLevelColor = (level: string) => {
|
||||||
|
switch (level) {
|
||||||
|
case 'Beginner': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||||
|
case 'Intermediate': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
|
||||||
|
case 'Advanced': return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||||
|
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && certificates.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-secondary-900 dark:text-white mb-3">
|
||||||
|
Certificates Issued
|
||||||
|
</h1>
|
||||||
|
<p className="text-secondary-600 dark:text-secondary-400 text-lg">
|
||||||
|
Track all certificates issued to your resellers
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
{stats && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
Total Certificates
|
||||||
|
</p>
|
||||||
|
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{stats.totalCertificates}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||||
|
<Award className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
This Month
|
||||||
|
</p>
|
||||||
|
<p className="text-3xl font-bold text-green-600 dark:text-green-400">
|
||||||
|
{stats.thisMonthCertificates}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center">
|
||||||
|
<Calendar className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
This Year
|
||||||
|
</p>
|
||||||
|
<p className="text-3xl font-bold text-yellow-600 dark:text-yellow-400">
|
||||||
|
{stats.thisYearCertificates}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center">
|
||||||
|
<Calendar className="w-6 h-6 text-yellow-600 dark:text-yellow-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
Average Score
|
||||||
|
</p>
|
||||||
|
<p className="text-3xl font-bold text-purple-600 dark:text-purple-400">
|
||||||
|
{stats.averageScore}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900 rounded-full flex items-center justify-center">
|
||||||
|
<BookOpen className="w-6 h-6 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters and Search */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4 items-center justify-between">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 flex-1">
|
||||||
|
<form onSubmit={handleSearch} className="flex-1">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by reseller name, email, or course..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Filter className="w-4 h-4 text-gray-400" />
|
||||||
|
<select
|
||||||
|
value={selectedCourse}
|
||||||
|
onChange={(e) => handleCourseFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
<option value="all">All Courses</option>
|
||||||
|
{courses.map((course) => (
|
||||||
|
<option key={course.id} value={course.id}>
|
||||||
|
{course.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Certificates Table */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Reseller
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Course
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Certificate
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Grade & Score
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Issued Date
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{certificates.map((certificate) => (
|
||||||
|
<tr key={certificate.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-10 h-10 bg-gray-200 dark:bg-gray-600 rounded-full flex items-center justify-center mr-3">
|
||||||
|
{certificate.user.avatar ? (
|
||||||
|
<img src={certificate.user.avatar} alt={`${certificate.user.firstName} ${certificate.user.lastName}`} className="w-10 h-10 rounded-full" />
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-600 dark:text-gray-400 font-medium">
|
||||||
|
{certificate.user.firstName.charAt(0)}{certificate.user.lastName.charAt(0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{certificate.user.firstName} {certificate.user.lastName}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{certificate.user.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{certificate.course.title}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 mt-1">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getLevelColor(certificate.course.level)}`}>
|
||||||
|
{certificate.course.level}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{certificate.course.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-900 dark:text-white font-mono">
|
||||||
|
{certificate.certificateNumber}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getGradeColor(certificate.grade)}`}>
|
||||||
|
{certificate.grade}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-900 dark:text-white">
|
||||||
|
{certificate.score}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||||
|
{formatDate(certificate.issuedAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
|
title="View Certificate"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300"
|
||||||
|
title="Download Certificate"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{certificates.length === 0 && !loading && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Award className="w-8 h-8 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||||
|
No certificates issued yet
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
Certificates will appear here once resellers complete your courses
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handlePageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VendorCertificates;
|
||||||
2388
src/components/VendorSalesDashboard.tsx
Normal file
2388
src/components/VendorSalesDashboard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { User, Package, DollarSign, Calendar, CheckCircle, X } from 'lucide-react';
|
import { User, Package, CheckCircle, X, Upload, FileText, Plus, Trash2, AlertCircle, ArrowRight, ArrowLeft } from 'lucide-react';
|
||||||
import { cn } from '../../utils/cn';
|
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
interface MarkProductSoldFormProps {
|
interface MarkProductSoldFormProps {
|
||||||
@ -11,6 +10,13 @@ interface MarkProductSoldFormProps {
|
|||||||
customers: any[];
|
customers: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ReceiptFile {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
const MarkProductSoldForm: React.FC<MarkProductSoldFormProps> = ({
|
const MarkProductSoldForm: React.FC<MarkProductSoldFormProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@ -18,6 +24,7 @@ const MarkProductSoldForm: React.FC<MarkProductSoldFormProps> = ({
|
|||||||
products,
|
products,
|
||||||
customers
|
customers
|
||||||
}) => {
|
}) => {
|
||||||
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
productId: '',
|
productId: '',
|
||||||
customerId: '',
|
customerId: '',
|
||||||
@ -26,13 +33,53 @@ const MarkProductSoldForm: React.FC<MarkProductSoldFormProps> = ({
|
|||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
company: '',
|
company: '',
|
||||||
phone: ''
|
phone: '',
|
||||||
|
address: '',
|
||||||
|
gstNumber: '',
|
||||||
|
panNumber: ''
|
||||||
|
},
|
||||||
|
saleDetails: {
|
||||||
|
saleDate: new Date().toISOString().split('T')[0],
|
||||||
|
paymentMethod: '',
|
||||||
|
paymentReference: '',
|
||||||
|
notes: ''
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const [selectedProduct, setSelectedProduct] = useState<any>(null);
|
const [selectedProduct, setSelectedProduct] = useState<any>(null);
|
||||||
const [selectedCustomer, setSelectedCustomer] = useState<any>(null);
|
const [selectedCustomer, setSelectedCustomer] = useState<any>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [receipts, setReceipts] = useState<ReceiptFile[]>([]);
|
||||||
|
const [isNewCustomer, setIsNewCustomer] = useState(false);
|
||||||
|
|
||||||
|
// Reset form when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setCurrentStep(1);
|
||||||
|
setReceipts([]);
|
||||||
|
setIsNewCustomer(false);
|
||||||
|
setFormData({
|
||||||
|
productId: '',
|
||||||
|
customerId: '',
|
||||||
|
quantity: 1,
|
||||||
|
customerDetails: {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
company: '',
|
||||||
|
phone: '',
|
||||||
|
address: '',
|
||||||
|
gstNumber: '',
|
||||||
|
panNumber: ''
|
||||||
|
},
|
||||||
|
saleDetails: {
|
||||||
|
saleDate: new Date().toISOString().split('T')[0],
|
||||||
|
paymentMethod: '',
|
||||||
|
paymentReference: '',
|
||||||
|
notes: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (formData.productId) {
|
if (formData.productId) {
|
||||||
@ -42,92 +89,125 @@ const MarkProductSoldForm: React.FC<MarkProductSoldFormProps> = ({
|
|||||||
}, [formData.productId, products]);
|
}, [formData.productId, products]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (formData.customerId) {
|
if (formData.customerId && formData.customerId !== 'new') {
|
||||||
const customer = customers.find(c => c.id.toString() === formData.customerId);
|
const customer = customers.find(c => c.id.toString() === formData.customerId);
|
||||||
setSelectedCustomer(customer);
|
setSelectedCustomer(customer);
|
||||||
|
setIsNewCustomer(false);
|
||||||
|
} else if (formData.customerId === 'new') {
|
||||||
|
setIsNewCustomer(true);
|
||||||
|
setSelectedCustomer(null);
|
||||||
}
|
}
|
||||||
}, [formData.customerId, customers]);
|
}, [formData.customerId, customers]);
|
||||||
|
|
||||||
const handleInputChange = (field: string, value: any) => {
|
const handleInputChange = (field: string, value: any) => {
|
||||||
setFormData(prev => ({ ...prev, [field]: value }));
|
if (field.includes('.')) {
|
||||||
};
|
const [parent, child] = field.split('.');
|
||||||
|
setFormData(prev => ({
|
||||||
const handleCustomerDetailsChange = (field: string, value: string) => {
|
...prev,
|
||||||
setFormData(prev => ({
|
[parent]: {
|
||||||
...prev,
|
...(prev[parent as keyof typeof prev] as any),
|
||||||
customerDetails: {
|
[child]: value
|
||||||
...prev.customerDetails,
|
}
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
[field]: value
|
[field]: value
|
||||||
}
|
}));
|
||||||
}));
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateCommission = () => {
|
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!selectedProduct) return 0;
|
const files = event.target.files;
|
||||||
const totalAmount = selectedProduct.price * formData.quantity;
|
if (files) {
|
||||||
return (totalAmount * selectedProduct.commissionRate) / 100;
|
const newReceipts: ReceiptFile[] = Array.from(files).map(file => ({
|
||||||
|
id: Math.random().toString(36).substr(2, 9),
|
||||||
|
file,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size
|
||||||
|
}));
|
||||||
|
setReceipts(prev => [...prev, ...newReceipts]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeReceipt = (id: string) => {
|
||||||
|
setReceipts(prev => prev.filter(receipt => receipt.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateTotalAmount = () => {
|
const calculateTotalAmount = () => {
|
||||||
if (!selectedProduct) return 0;
|
if (!selectedProduct) return 0;
|
||||||
return selectedProduct.price * formData.quantity;
|
return (selectedProduct.price * formData.quantity).toFixed(2);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const validateStep1 = () => {
|
||||||
e.preventDefault();
|
return formData.productId && formData.customerId &&
|
||||||
|
(formData.customerId === 'new' ?
|
||||||
if (!formData.productId || !formData.customerId) {
|
(formData.customerDetails.name && formData.customerDetails.email) : true);
|
||||||
toast.error('Please select both product and customer');
|
};
|
||||||
|
|
||||||
|
const validateStep2 = () => {
|
||||||
|
return receipts.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextStep = () => {
|
||||||
|
if (currentStep === 1 && !validateStep1()) {
|
||||||
|
toast.error('Please fill in all required fields');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (currentStep === 2 && !validateStep2()) {
|
||||||
|
toast.error('Please upload at least one receipt');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCurrentStep(prev => Math.min(prev + 1, 3));
|
||||||
|
};
|
||||||
|
|
||||||
if (formData.quantity < 1) {
|
const prevStep = () => {
|
||||||
toast.error('Quantity must be at least 1');
|
setCurrentStep(prev => Math.max(prev - 1, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!validateStep1() || !validateStep2()) {
|
||||||
|
toast.error('Please complete all steps');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('accessToken');
|
// Create FormData for file upload
|
||||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/resellers/products/sold`, {
|
const submitData = new FormData();
|
||||||
method: 'POST',
|
submitData.append('productId', formData.productId);
|
||||||
headers: {
|
submitData.append('customerId', formData.customerId === 'new' ? 'new' : formData.customerId);
|
||||||
'Authorization': `Bearer ${token}`,
|
submitData.append('quantity', formData.quantity.toString());
|
||||||
'Content-Type': 'application/json'
|
submitData.append('saleDate', formData.saleDetails.saleDate);
|
||||||
},
|
submitData.append('paymentMethod', formData.saleDetails.paymentMethod);
|
||||||
body: JSON.stringify({
|
submitData.append('paymentReference', formData.saleDetails.paymentReference);
|
||||||
productId: formData.productId,
|
submitData.append('notes', formData.saleDetails.notes);
|
||||||
customerId: formData.customerId,
|
|
||||||
quantity: formData.quantity,
|
// Add customer details if new customer
|
||||||
customerDetails: formData.customerDetails
|
if (formData.customerId === 'new') {
|
||||||
})
|
submitData.append('customerName', formData.customerDetails.name);
|
||||||
|
submitData.append('customerEmail', formData.customerDetails.email);
|
||||||
|
submitData.append('customerCompany', formData.customerDetails.company);
|
||||||
|
submitData.append('customerPhone', formData.customerDetails.phone);
|
||||||
|
submitData.append('customerAddress', formData.customerDetails.address);
|
||||||
|
submitData.append('customerGstNumber', formData.customerDetails.gstNumber);
|
||||||
|
submitData.append('customerPanNumber', formData.customerDetails.panNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add receipt files
|
||||||
|
receipts.forEach((receipt, index) => {
|
||||||
|
submitData.append(`receipts`, receipt.file);
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
// Here you would make the API call
|
||||||
|
// const response = await createSale(submitData);
|
||||||
if (data.success) {
|
|
||||||
toast.success('Product marked as sold successfully!');
|
toast.success('Sale created successfully!');
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
onClose();
|
onClose();
|
||||||
// Reset form
|
} catch (error) {
|
||||||
setFormData({
|
console.error('Error creating sale:', error);
|
||||||
productId: '',
|
toast.error('Failed to create sale. Please try again.');
|
||||||
customerId: '',
|
|
||||||
quantity: 1,
|
|
||||||
customerDetails: {
|
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
company: '',
|
|
||||||
phone: ''
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast.error(data.message || 'Failed to mark product as sold');
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error marking product as sold:', error);
|
|
||||||
toast.error('Failed to mark product as sold. Please try again.');
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@ -137,17 +217,16 @@ const MarkProductSoldForm: React.FC<MarkProductSoldFormProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden">
|
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-slate-200 dark:border-slate-700">
|
<div className="flex items-center justify-between p-6 border-b border-slate-200 dark:border-slate-700">
|
||||||
<div className="flex items-center space-x-3">
|
<div>
|
||||||
<div className="w-10 h-10 bg-green-100 dark:bg-green-900/50 rounded-lg flex items-center justify-center">
|
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
|
||||||
<CheckCircle className="w-5 h-5 text-green-600 dark:text-green-400" />
|
Mark Product as Sold
|
||||||
</div>
|
</h2>
|
||||||
<div>
|
<p className="text-sm text-slate-600 dark:text-slate-400 mt-1">
|
||||||
<h3 className="text-xl font-bold text-slate-900 dark:text-white">Mark Product as Sold</h3>
|
Step {currentStep} of 3
|
||||||
<p className="text-sm text-slate-600 dark:text-slate-400">Record a successful sale and track commission</p>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@ -157,139 +236,438 @@ const MarkProductSoldForm: React.FC<MarkProductSoldFormProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form */}
|
{/* Progress Steps */}
|
||||||
<div className="overflow-y-auto max-h-[calc(90vh-120px)]">
|
<div className="px-6 py-4 border-b border-slate-200 dark:border-slate-700">
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
<div className="flex items-center justify-between">
|
||||||
{/* Product Selection */}
|
<div className={`flex items-center ${currentStep >= 1 ? 'text-blue-600' : 'text-slate-400'}`}>
|
||||||
<div>
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center border-2 ${
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
currentStep >= 1 ? 'border-blue-600 bg-blue-600 text-white' : 'border-slate-300'
|
||||||
Product *
|
}`}>
|
||||||
</label>
|
1
|
||||||
<select
|
|
||||||
value={formData.productId}
|
|
||||||
onChange={(e) => handleInputChange('productId', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-green-500"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">Select a product</option>
|
|
||||||
{products.map(product => (
|
|
||||||
<option key={product.id} value={product.id}>
|
|
||||||
{product.name} - ${product.price} ({product.commissionRate}% commission)
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Customer Selection */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
|
||||||
Customer *
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={formData.customerId}
|
|
||||||
onChange={(e) => handleInputChange('customerId', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-green-500"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">Select a customer</option>
|
|
||||||
{customers.map(customer => (
|
|
||||||
<option key={customer.id} value={customer.id}>
|
|
||||||
{customer.name} - {customer.email}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quantity */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
|
||||||
Quantity *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
value={formData.quantity}
|
|
||||||
onChange={(e) => handleInputChange('quantity', parseInt(e.target.value) || 1)}
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-green-500"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sale Summary */}
|
|
||||||
{selectedProduct && (
|
|
||||||
<div className="bg-slate-50 dark:bg-slate-700/50 rounded-lg p-4 space-y-3">
|
|
||||||
<h4 className="font-semibold text-slate-900 dark:text-white">Sale Summary</h4>
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-600 dark:text-slate-400">Product:</span>
|
|
||||||
<p className="font-medium text-slate-900 dark:text-white">{selectedProduct.name}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-600 dark:text-slate-400">Unit Price:</span>
|
|
||||||
<p className="font-medium text-slate-900 dark:text-white">${selectedProduct.price}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-600 dark:text-slate-400">Total Amount:</span>
|
|
||||||
<p className="font-medium text-green-600 dark:text-green-400">${calculateTotalAmount()}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-600 dark:text-slate-400">Commission ({selectedProduct.commissionRate}%):</span>
|
|
||||||
<p className="font-medium text-blue-600 dark:text-blue-400">${calculateCommission()}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<span className="ml-2 text-sm font-medium">Product & Customer</span>
|
||||||
|
</div>
|
||||||
{/* Customer Details */}
|
<div className={`flex-1 h-0.5 mx-4 ${currentStep >= 2 ? 'bg-blue-600' : 'bg-slate-300'}`}></div>
|
||||||
{selectedCustomer && (
|
<div className={`flex items-center ${currentStep >= 2 ? 'text-blue-600' : 'text-slate-400'}`}>
|
||||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center border-2 ${
|
||||||
<h4 className="font-semibold text-slate-900 dark:text-white mb-3 flex items-center">
|
currentStep >= 2 ? 'border-blue-600 bg-blue-600 text-white' : 'border-slate-300'
|
||||||
<User className="w-4 h-4 mr-2 text-blue-600" />
|
}`}>
|
||||||
Customer Information
|
2
|
||||||
</h4>
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-600 dark:text-slate-400">Name:</span>
|
|
||||||
<p className="font-medium text-slate-900 dark:text-white">{selectedCustomer.name}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-600 dark:text-slate-400">Email:</span>
|
|
||||||
<p className="font-medium text-slate-900 dark:text-white">{selectedCustomer.email}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-600 dark:text-slate-400">Company:</span>
|
|
||||||
<p className="font-medium text-slate-900 dark:text-white">{selectedCustomer.company || 'N/A'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-600 dark:text-slate-400">Phone:</span>
|
|
||||||
<p className="font-medium text-slate-900 dark:text-white">{selectedCustomer.phone || 'N/A'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<span className="ml-2 text-sm font-medium">Receipts</span>
|
||||||
|
</div>
|
||||||
|
<div className={`flex-1 h-0.5 mx-4 ${currentStep >= 3 ? 'bg-blue-600' : 'bg-slate-300'}`}></div>
|
||||||
|
<div className={`flex items-center ${currentStep >= 3 ? 'text-blue-600' : 'text-slate-400'}`}>
|
||||||
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center border-2 ${
|
||||||
|
currentStep >= 3 ? 'border-blue-600 bg-blue-600 text-white' : 'border-slate-300'
|
||||||
|
}`}>
|
||||||
|
3
|
||||||
|
</div>
|
||||||
|
<span className="ml-2 text-sm font-medium">Review</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Form Actions */}
|
{/* Form Content */}
|
||||||
<div className="flex justify-end space-x-3 pt-4 border-t border-slate-200 dark:border-slate-700">
|
<div className="p-6">
|
||||||
|
{currentStep === 1 && renderStep1()}
|
||||||
|
{currentStep === 2 && renderStep2()}
|
||||||
|
{currentStep === 3 && renderStep3()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-t border-slate-200 dark:border-slate-700">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={prevStep}
|
||||||
|
disabled={currentStep === 1}
|
||||||
|
className={`flex items-center px-4 py-2 rounded-lg border ${
|
||||||
|
currentStep === 1
|
||||||
|
? 'border-slate-200 text-slate-400 cursor-not-allowed'
|
||||||
|
: 'border-slate-300 text-slate-700 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
{currentStep < 3 ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={nextStep}
|
||||||
className="px-4 py-2 text-slate-600 dark:text-slate-400 border border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
|
className="flex items-center px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Next
|
||||||
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="flex items-center px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
{isSubmitting ? 'Recording Sale...' : 'Mark as Sold'}
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
Creating Sale...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="w-4 h-4 mr-2" />
|
||||||
|
Create Sale
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function renderStep1() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Product Selection */}
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border-2 border-blue-200 dark:border-blue-700">
|
||||||
|
<h4 className="font-semibold text-slate-900 dark:text-white mb-3 flex items-center">
|
||||||
|
<Package className="w-4 h-4 mr-2 text-blue-600" />
|
||||||
|
Select Product *
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={formData.productId}
|
||||||
|
onChange={(e) => handleInputChange('productId', e.target.value)}
|
||||||
|
className="w-full p-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Choose a product...</option>
|
||||||
|
{products.map((product) => (
|
||||||
|
<option key={product.id} value={product.id}>
|
||||||
|
{product.name} - ${product.price}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{selectedProduct && (
|
||||||
|
<div className="mt-3 p-3 bg-white dark:bg-slate-700 rounded-lg border border-slate-200 dark:border-slate-600">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">Price:</span>
|
||||||
|
<span className="font-semibold text-green-600">${selectedProduct.price}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center mt-1">
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">Quantity:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={formData.quantity}
|
||||||
|
onChange={(e) => handleInputChange('quantity', parseInt(e.target.value))}
|
||||||
|
className="w-20 p-1 border border-slate-300 dark:border-slate-600 rounded text-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center mt-1">
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">Total:</span>
|
||||||
|
<span className="font-semibold text-green-600">${calculateTotalAmount()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Customer Selection */}
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/20 rounded-lg p-4 border-2 border-green-200 dark:border-green-700">
|
||||||
|
<h4 className="font-semibold text-slate-900 dark:text-white mb-3 flex items-center">
|
||||||
|
<User className="w-4 h-4 mr-2 text-green-600" />
|
||||||
|
Customer Information *
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={formData.customerId}
|
||||||
|
onChange={(e) => handleInputChange('customerId', e.target.value)}
|
||||||
|
className="w-full p-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white mb-3"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Choose customer or add new...</option>
|
||||||
|
<option value="new">+ Add New Customer</option>
|
||||||
|
{customers.map((customer) => (
|
||||||
|
<option key={customer.id} value={customer.id}>
|
||||||
|
{customer.name} - {customer.email}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{isNewCustomer && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Customer Name *"
|
||||||
|
value={formData.customerDetails.name}
|
||||||
|
onChange={(e) => handleInputChange('customerDetails.name', e.target.value)}
|
||||||
|
className="p-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email *"
|
||||||
|
value={formData.customerDetails.email}
|
||||||
|
onChange={(e) => handleInputChange('customerDetails.email', e.target.value)}
|
||||||
|
className="p-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Company"
|
||||||
|
value={formData.customerDetails.company}
|
||||||
|
onChange={(e) => handleInputChange('customerDetails.company', e.target.value)}
|
||||||
|
className="p-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
placeholder="Phone"
|
||||||
|
value={formData.customerDetails.phone}
|
||||||
|
onChange={(e) => handleInputChange('customerDetails.phone', e.target.value)}
|
||||||
|
className="p-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Address"
|
||||||
|
value={formData.customerDetails.address}
|
||||||
|
onChange={(e) => handleInputChange('customerDetails.address', e.target.value)}
|
||||||
|
className="p-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="GST Number"
|
||||||
|
value={formData.customerDetails.gstNumber}
|
||||||
|
onChange={(e) => handleInputChange('customerDetails.gstNumber', e.target.value)}
|
||||||
|
className="p-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sale Details */}
|
||||||
|
<div className="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4 border-2 border-purple-200 dark:border-purple-700">
|
||||||
|
<h4 className="font-semibold text-slate-900 dark:text-white mb-3 flex items-center">
|
||||||
|
<CheckCircle className="w-4 h-4 mr-2 text-purple-600" />
|
||||||
|
Sale Details
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={formData.saleDetails.saleDate}
|
||||||
|
onChange={(e) => handleInputChange('saleDetails.saleDate', e.target.value)}
|
||||||
|
className="p-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={formData.saleDetails.paymentMethod}
|
||||||
|
onChange={(e) => handleInputChange('saleDetails.paymentMethod', e.target.value)}
|
||||||
|
className="p-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="">Payment Method</option>
|
||||||
|
<option value="cash">Cash</option>
|
||||||
|
<option value="card">Card</option>
|
||||||
|
<option value="bank_transfer">Bank Transfer</option>
|
||||||
|
<option value="upi">UPI</option>
|
||||||
|
<option value="cheque">Cheque</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Payment Reference"
|
||||||
|
value={formData.saleDetails.paymentReference}
|
||||||
|
onChange={(e) => handleInputChange('saleDetails.paymentReference', e.target.value)}
|
||||||
|
className="p-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
placeholder="Additional Notes"
|
||||||
|
value={formData.saleDetails.notes}
|
||||||
|
onChange={(e) => handleInputChange('saleDetails.notes', e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="p-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white md:col-span-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStep2() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-6 border-2 border-amber-200 dark:border-amber-700">
|
||||||
|
<h4 className="font-semibold text-slate-900 dark:text-white mb-4 flex items-center">
|
||||||
|
<FileText className="w-5 h-5 mr-2 text-amber-600" />
|
||||||
|
Receipts & Documents *
|
||||||
|
<span className="ml-2 text-xs text-amber-600 bg-amber-100 dark:bg-amber-800 px-2 py-1 rounded-full">
|
||||||
|
Required
|
||||||
|
</span>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<p className="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
Please upload payment receipts, invoices, or any supporting documents to complete the sale.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-2 border-dashed border-amber-300 dark:border-amber-600 rounded-lg p-8 text-center">
|
||||||
|
<Upload className="w-12 h-12 text-amber-500 mx-auto mb-4" />
|
||||||
|
<p className="text-lg font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
Upload Receipts & Documents
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
Supported formats: PDF, JPG, PNG, DOC, DOCX
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="hidden"
|
||||||
|
id="receipt-upload"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="receipt-upload"
|
||||||
|
className="inline-flex items-center px-6 py-3 bg-amber-600 text-white rounded-lg hover:bg-amber-700 cursor-pointer transition-colors text-lg font-medium"
|
||||||
|
>
|
||||||
|
<Upload className="w-5 h-5 mr-2" />
|
||||||
|
Choose Files
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{receipts.length > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h5 className="font-medium text-slate-900 dark:text-white mb-3">
|
||||||
|
Uploaded Files: {receipts.length}
|
||||||
|
</h5>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{receipts.map((receipt) => (
|
||||||
|
<div key={receipt.id} className="flex items-center justify-between bg-white dark:bg-slate-700 rounded-lg p-4 border border-slate-200 dark:border-slate-600">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<FileText className="w-5 h-5 text-slate-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-900 dark:text-white">{receipt.name}</p>
|
||||||
|
<p className="text-sm text-slate-500">{(receipt.size / 1024 / 1024).toFixed(2)} MB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeReceipt(receipt.id)}
|
||||||
|
className="text-red-500 hover:text-red-700 p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{receipts.length === 0 && (
|
||||||
|
<div className="mt-6 p-4 bg-amber-100 dark:bg-amber-800/30 rounded-lg border border-amber-200 dark:border-amber-600">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<AlertCircle className="w-5 h-5 text-amber-600 mr-2" />
|
||||||
|
<p className="text-amber-800 dark:text-amber-200">
|
||||||
|
<strong>Important:</strong> You must upload at least one receipt or document to proceed with the sale.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStep3() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/20 rounded-lg p-6 border border-green-200 dark:border-green-700">
|
||||||
|
<h4 className="font-semibold text-slate-900 dark:text-white mb-4 flex items-center">
|
||||||
|
<CheckCircle className="w-5 h-5 mr-2 text-green-600" />
|
||||||
|
Ready to Submit Sale
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<h5 className="font-medium text-slate-900 dark:text-white mb-3">Sale Information</h5>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-600 dark:text-slate-400">Product:</span>
|
||||||
|
<span className="font-medium">{selectedProduct?.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-600 dark:text-slate-400">Quantity:</span>
|
||||||
|
<span className="font-medium">{formData.quantity}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-600 dark:text-slate-400">Total Amount:</span>
|
||||||
|
<span className="font-medium text-green-600">${calculateTotalAmount()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-600 dark:text-slate-400">Sale Date:</span>
|
||||||
|
<span className="font-medium">{formData.saleDetails.saleDate}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-600 dark:text-slate-400">Payment Method:</span>
|
||||||
|
<span className="font-medium">{formData.saleDetails.paymentMethod || 'Not specified'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h5 className="font-medium text-slate-900 dark:text-white mb-3">Customer Information</h5>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-600 dark:text-slate-400">Name:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{isNewCustomer ? formData.customerDetails.name : selectedCustomer?.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-600 dark:text-slate-400">Email:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{isNewCustomer ? formData.customerDetails.email : selectedCustomer?.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-600 dark:text-slate-400">Company:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{isNewCustomer ? formData.customerDetails.company : selectedCustomer?.company || 'Not specified'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 p-4 bg-green-100 dark:bg-green-800/30 rounded-lg border border-green-200 dark:border-green-600">
|
||||||
|
<h5 className="font-medium text-slate-900 dark:text-white mb-3">Receipts & Documents</h5>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-600 dark:text-slate-400">Files Uploaded:</span>
|
||||||
|
<span className="font-medium text-green-600">{receipts.length} file(s)</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
{receipts.map((receipt, index) => (
|
||||||
|
<div key={receipt.id} className="flex justify-between">
|
||||||
|
<span>{receipt.name}</span>
|
||||||
|
<span>{(receipt.size / 1024 / 1024).toFixed(2)} MB</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.saleDetails.notes && (
|
||||||
|
<div className="mt-4 p-3 bg-slate-50 dark:bg-slate-700 rounded-lg">
|
||||||
|
<h6 className="font-medium text-slate-900 dark:text-white mb-2">Additional Notes:</h6>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">{formData.saleDetails.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MarkProductSoldForm;
|
export default MarkProductSoldForm;
|
||||||
@ -233,8 +233,8 @@ const ProductForm: React.FC<ProductFormProps> = ({ product, onClose, onSuccess }
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
<div className="fixed top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-[9999]" style={{ backdropFilter: 'blur(4px)' }}>
|
||||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden">
|
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-hidden mx-4 my-8">
|
||||||
<div className="p-6 border-b border-slate-200 dark:border-slate-700">
|
<div className="p-6 border-b border-slate-200 dark:border-slate-700">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
|
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
|
||||||
|
|||||||
@ -48,8 +48,10 @@ const ResellerSidebar: React.FC = () => {
|
|||||||
// Show success message
|
// Show success message
|
||||||
toast.success('Logged out successfully');
|
toast.success('Logged out successfully');
|
||||||
|
|
||||||
// Navigate to login
|
// Wait a bit for Redux state to update, then navigate
|
||||||
|
setTimeout(() => {
|
||||||
navigate('/reseller/login');
|
navigate('/reseller/login');
|
||||||
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
@ -118,7 +120,9 @@ const ResellerSidebar: React.FC = () => {
|
|||||||
<User className="w-5 h-5 text-white" />
|
<User className="w-5 h-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-white truncate">{user ? `${user.firstName} ${user.lastName}` : 'John Reseller'}</p>
|
<p className="text-sm font-medium text-white truncate">
|
||||||
|
{user?.firstName && user?.lastName ? `${user.firstName} ${user.lastName}` : user?.email || 'User'}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-gray-400 truncate">{user?.email || 'reseller@example.com'}</p>
|
<p className="text-xs text-gray-400 truncate">{user?.email || 'reseller@example.com'}</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="p-1 rounded-lg bg-gray-800/50 hover:bg-gray-700/50 text-gray-400 hover:text-white transition-colors duration-200">
|
<button className="p-1 rounded-lg bg-gray-800/50 hover:bg-gray-700/50 text-gray-400 hover:text-white transition-colors duration-200">
|
||||||
|
|||||||
1
src/controllers/adminController.js
Normal file
1
src/controllers/adminController.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
234
src/index.css
234
src/index.css
@ -3,6 +3,127 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* Global Modal Styles - Ensure modals cover entire viewport */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
width: 100vw !important;
|
||||||
|
height: 100vh !important;
|
||||||
|
z-index: 9999 !important;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5) !important;
|
||||||
|
backdrop-filter: blur(4px) !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
padding: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white !important;
|
||||||
|
border-radius: 0.5rem !important;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25) !important;
|
||||||
|
max-width: 90vw !important;
|
||||||
|
max-height: 90vh !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support */
|
||||||
|
.dark .modal-content {
|
||||||
|
background: #1e293b !important;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure modals are above all other content */
|
||||||
|
.modal-overlay,
|
||||||
|
.modal-overlay * {
|
||||||
|
z-index: 9999 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix for any potential CSS conflicts */
|
||||||
|
body.modal-open {
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure proper stacking context */
|
||||||
|
.modal-container {
|
||||||
|
position: relative !important;
|
||||||
|
z-index: 9999 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive modal improvements */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.modal-overlay {
|
||||||
|
padding: 0.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
max-width: 95vw !important;
|
||||||
|
max-height: 95vh !important;
|
||||||
|
margin: 0.5rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.modal-overlay {
|
||||||
|
padding: 0.25rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
max-width: 98vw !important;
|
||||||
|
max-height: 98vh !important;
|
||||||
|
margin: 0.25rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure modals work on mobile devices */
|
||||||
|
.modal-overlay {
|
||||||
|
-webkit-overflow-scrolling: touch !important;
|
||||||
|
overscroll-behavior: contain !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix for iOS Safari modal issues */
|
||||||
|
@supports (-webkit-touch-callout: none) {
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
width: 100vw !important;
|
||||||
|
height: 100vh !important;
|
||||||
|
height: -webkit-fill-available !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Additional modal fixes */
|
||||||
|
.modal-overlay {
|
||||||
|
/* Ensure modal is above all content */
|
||||||
|
isolation: isolate !important;
|
||||||
|
/* Prevent background scrolling */
|
||||||
|
overscroll-behavior: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix for any potential CSS conflicts with other frameworks */
|
||||||
|
.modal-overlay * {
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure modal backdrop covers entire viewport */
|
||||||
|
.modal-overlay::before {
|
||||||
|
content: '' !important;
|
||||||
|
position: absolute !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
background: rgba(0, 0, 0, 0.5) !important;
|
||||||
|
z-index: -1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Dark mode input text color fix */
|
/* Dark mode input text color fix */
|
||||||
.dark input[type="text"],
|
.dark input[type="text"],
|
||||||
.dark input[type="email"],
|
.dark input[type="email"],
|
||||||
@ -292,3 +413,116 @@
|
|||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== SCROLLBAR HIDING FOR ALL MODALS AND POPUPS ===== */
|
||||||
|
|
||||||
|
/* Hide scrollbars for all modals and popups */
|
||||||
|
.modal-content,
|
||||||
|
.modal-content *,
|
||||||
|
[class*="fixed inset-0"] > div,
|
||||||
|
[class*="fixed inset-0"] > div * {
|
||||||
|
scrollbar-width: none !important; /* Firefox */
|
||||||
|
-ms-overflow-style: none !important; /* Internet Explorer 10+ */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Webkit browsers (Chrome, Safari, Edge) */
|
||||||
|
.modal-content::-webkit-scrollbar,
|
||||||
|
.modal-content *::-webkit-scrollbar,
|
||||||
|
[class*="fixed inset-0"] > div::-webkit-scrollbar,
|
||||||
|
[class*="fixed inset-0"] > div *::-webkit-scrollbar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specific scrollbar hiding for common modal classes */
|
||||||
|
.fixed.inset-0 > div,
|
||||||
|
.fixed.inset-0 > div *,
|
||||||
|
.bg-black.bg-opacity-50 > div,
|
||||||
|
.bg-black.bg-opacity-75 > div,
|
||||||
|
.bg-black.bg-opacity-50 > div *,
|
||||||
|
.bg-black.bg-opacity-75 > div * {
|
||||||
|
scrollbar-width: none !important;
|
||||||
|
-ms-overflow-style: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed.inset-0 > div::-webkit-scrollbar,
|
||||||
|
.fixed.inset-0 > div *::-webkit-scrollbar,
|
||||||
|
.bg-black.bg-opacity-50 > div::-webkit-scrollbar,
|
||||||
|
.bg-black.bg-opacity-75 > div::-webkit-scrollbar,
|
||||||
|
.bg-black.bg-opacity-50 > div *::-webkit-scrollbar,
|
||||||
|
.bg-black.bg-opacity-75 > div *::-webkit-scrollbar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Additional scrollbar hiding for specific modal patterns */
|
||||||
|
[class*="bg-black"][class*="bg-opacity"] > div,
|
||||||
|
[class*="bg-black"][class*="bg-opacity"] > div * {
|
||||||
|
scrollbar-width: none !important;
|
||||||
|
-ms-overflow-style: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*="bg-black"][class*="bg-opacity"] > div::-webkit-scrollbar,
|
||||||
|
[class*="bg-black"][class*="bg-opacity"] > div *::-webkit-scrollbar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure all popup content has hidden scrollbars */
|
||||||
|
.popup-content,
|
||||||
|
.modal-body,
|
||||||
|
.modal-scrollable,
|
||||||
|
.overflow-y-auto,
|
||||||
|
.overflow-y-scroll {
|
||||||
|
scrollbar-width: none !important;
|
||||||
|
-ms-overflow-style: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-content::-webkit-scrollbar,
|
||||||
|
.modal-body::-webkit-scrollbar,
|
||||||
|
.modal-scrollable::-webkit-scrollbar,
|
||||||
|
.overflow-y-auto::-webkit-scrollbar,
|
||||||
|
.overflow-y-scroll::-webkit-scrollbar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbars for any element with overflow */
|
||||||
|
.overflow-y-auto,
|
||||||
|
.overflow-y-scroll,
|
||||||
|
.overflow-auto,
|
||||||
|
.overflow-scroll {
|
||||||
|
scrollbar-width: none !important;
|
||||||
|
-ms-overflow-style: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-y-auto::-webkit-scrollbar,
|
||||||
|
.overflow-y-scroll::-webkit-scrollbar,
|
||||||
|
.overflow-auto::-webkit-scrollbar,
|
||||||
|
.overflow-scroll::-webkit-scrollbar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specific modal patterns used in the app */
|
||||||
|
.fixed.inset-0.bg-black.bg-opacity-50 > div,
|
||||||
|
.fixed.inset-0.bg-black.bg-opacity-75 > div,
|
||||||
|
.fixed.inset-0.bg-black.bg-opacity-50 > div *,
|
||||||
|
.fixed.inset-0.bg-black.bg-opacity-75 > div * {
|
||||||
|
scrollbar-width: none !important;
|
||||||
|
-ms-overflow-style: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed.inset-0.bg-black.bg-opacity-50 > div::-webkit-scrollbar,
|
||||||
|
.fixed.inset-0.bg-black.bg-opacity-75 > div::-webkit-scrollbar,
|
||||||
|
.fixed.inset-0.bg-black.bg-opacity-50 > div *::-webkit-scrollbar,
|
||||||
|
.fixed.inset-0.bg-black.bg-opacity-75 > div *::-webkit-scrollbar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbars for any element inside a modal context */
|
||||||
|
.modal-overlay *,
|
||||||
|
.modal-overlay * * {
|
||||||
|
scrollbar-width: none !important;
|
||||||
|
-ms-overflow-style: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay *::-webkit-scrollbar,
|
||||||
|
.modal-overlay * *::-webkit-scrollbar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,13 +1,11 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { formatCurrency, formatNumber, formatDate } from '../../utils/format';
|
import { formatCurrency, formatNumber, formatDate } from '../../utils/format';
|
||||||
import Modal from '../../components/Modal';
|
import Modal from '../../components/Modal';
|
||||||
import AddPartnershipForm from '../../components/forms/AddPartnershipForm';
|
|
||||||
import DetailView from '../../components/DetailView';
|
import DetailView from '../../components/DetailView';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
Filter,
|
Filter,
|
||||||
Plus,
|
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Eye,
|
Eye,
|
||||||
Edit,
|
Edit,
|
||||||
@ -24,7 +22,8 @@ import {
|
|||||||
Mail,
|
Mail,
|
||||||
MapPin,
|
MapPin,
|
||||||
Building2,
|
Building2,
|
||||||
User
|
User,
|
||||||
|
Phone
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '../../utils/cn';
|
import { cn } from '../../utils/cn';
|
||||||
|
|
||||||
@ -50,7 +49,6 @@ const ApprovedResellersPage: React.FC = () => {
|
|||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('active');
|
const [statusFilter, setStatusFilter] = useState('active');
|
||||||
const [tierFilter, setTierFilter] = useState('all');
|
const [tierFilter, setTierFilter] = useState('all');
|
||||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
|
||||||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
||||||
const [selectedReseller, setSelectedReseller] = useState<Reseller | null>(null);
|
const [selectedReseller, setSelectedReseller] = useState<Reseller | null>(null);
|
||||||
const [resellers, setResellers] = useState<Reseller[]>([]);
|
const [resellers, setResellers] = useState<Reseller[]>([]);
|
||||||
@ -59,7 +57,7 @@ const ApprovedResellersPage: React.FC = () => {
|
|||||||
|
|
||||||
// Set page title
|
// Set page title
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = 'Approved Resellers - Cloudtopiaa';
|
document.title = 'All Resellers - Cloudtopiaa';
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Fetch approved resellers from API
|
// Fetch approved resellers from API
|
||||||
@ -78,7 +76,8 @@ const ApprovedResellersPage: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/vendors/resellers?status=active`, {
|
// Fetch all resellers (not just active ones)
|
||||||
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/vendors/resellers`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@ -86,36 +85,31 @@ const ApprovedResellersPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch approved resellers');
|
throw new Error('Failed to fetch resellers');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setResellers(data.data.resellers || []);
|
// Filter out pending resellers - only show non-pending ones
|
||||||
if (data.data.resellers && data.data.resellers.length > 0) {
|
const nonPendingResellers = (data.data.resellers || []).filter(
|
||||||
toast.success(`Loaded ${data.data.resellers.length} approved resellers`);
|
(reseller: Reseller) => reseller.status !== 'pending'
|
||||||
} else {
|
);
|
||||||
toast('No approved resellers found');
|
setResellers(nonPendingResellers);
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const errorMsg = data.message || 'Failed to fetch approved resellers';
|
const errorMsg = data.message || 'Failed to fetch resellers';
|
||||||
setError(errorMsg);
|
setError(errorMsg);
|
||||||
toast.error(errorMsg);
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error fetching approved resellers:', error);
|
console.error('Error fetching approved resellers:', error);
|
||||||
const errorMsg = error.message || 'Failed to fetch approved resellers';
|
const errorMsg = error.message || 'Failed to fetch approved resellers';
|
||||||
setError(errorMsg);
|
setError(errorMsg);
|
||||||
toast.error(errorMsg);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter only approved (active) resellers
|
// Filter resellers (all non-pending ones)
|
||||||
const approvedResellers = resellers.filter(reseller => reseller.status === 'active');
|
const filteredResellers = resellers.filter(reseller => {
|
||||||
|
|
||||||
const filteredResellers = approvedResellers.filter(reseller => {
|
|
||||||
const fullName = `${reseller.firstName} ${reseller.lastName}`.toLowerCase();
|
const fullName = `${reseller.firstName} ${reseller.lastName}`.toLowerCase();
|
||||||
const matchesSearch = fullName.includes(searchTerm.toLowerCase()) ||
|
const matchesSearch = fullName.includes(searchTerm.toLowerCase()) ||
|
||||||
reseller.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
reseller.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
@ -152,13 +146,6 @@ const ApprovedResellersPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddReseller = (data: any) => {
|
|
||||||
// Here you would typically make an API call to add the reseller
|
|
||||||
// For now, we'll just close the modal
|
|
||||||
setIsAddModalOpen(false);
|
|
||||||
toast.success('Reseller partnership request submitted successfully!');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewReseller = (reseller: Reseller) => {
|
const handleViewReseller = (reseller: Reseller) => {
|
||||||
setSelectedReseller(reseller);
|
setSelectedReseller(reseller);
|
||||||
setIsDetailModalOpen(true);
|
setIsDetailModalOpen(true);
|
||||||
@ -193,328 +180,312 @@ const ApprovedResellersPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div className="text-center py-8 text-red-500">{error}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resellers.length === 0) {
|
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-8">
|
<div className="p-6 space-y-6">
|
||||||
<p className="text-lg text-secondary-600 dark:text-secondary-400">No approved resellers found.</p>
|
<div className="text-center py-8">
|
||||||
<p className="text-sm text-secondary-500 dark:text-secondary-400">
|
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||||
Please ensure you have active partnerships with resellers.
|
<p className="text-lg text-red-600 dark:text-red-400">{error}</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="p-6 space-y-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="mb-8">
|
||||||
<div>
|
<h1 className="text-3xl font-bold text-secondary-900 dark:text-white mb-3">
|
||||||
<h1 className="text-3xl font-bold text-secondary-900 dark:text-white">
|
All Resellers
|
||||||
Approved Resellers
|
</h1>
|
||||||
</h1>
|
<p className="text-secondary-600 dark:text-secondary-400 text-lg">
|
||||||
<p className="text-secondary-600 dark:text-secondary-400 mt-1">
|
Manage all reseller partnerships (active, inactive, rejected, etc.)
|
||||||
Manage approved reseller partnerships and their performance
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
<div className="flex space-x-3">
|
<div className="flex space-x-3">
|
||||||
<button className="inline-flex items-center px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200 transform hover:scale-105">
|
<button className="inline-flex items-center px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200">
|
||||||
<Download className="w-4 h-4 mr-2" />
|
<Download className="w-4 h-4 mr-2" />
|
||||||
Export
|
Export
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => setIsAddModalOpen(true)}
|
|
||||||
className="inline-flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white text-sm font-medium rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200 transform hover:scale-105 shadow-lg hover:shadow-xl"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
Add Reseller
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||||
<div className="card p-6 bg-white dark:bg-gray-800 hover:shadow-lg">
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-secondary-600 dark:text-secondary-400">
|
<p className="text-sm font-medium text-secondary-600 dark:text-secondary-400 mb-2">
|
||||||
Total Approved Resellers
|
Total Resellers
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
<p className="text-3xl font-bold text-secondary-900 dark:text-white">
|
||||||
{approvedResellers.length}
|
{resellers.length}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center">
|
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
<Building2 className="w-6 h-6 text-primary-600 dark:text-primary-400" />
|
<Building2 className="w-6 h-6 text-primary-600 dark:text-primary-400" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card p-6 bg-white dark:bg-gray-800 hover:shadow-lg">
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-secondary-600 dark:text-secondary-400">
|
<p className="text-sm font-medium text-secondary-600 dark:text-secondary-400 mb-2">
|
||||||
Platinum Tier
|
Platinum Tier
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
<p className="text-3xl font-bold text-secondary-900 dark:text-white">
|
||||||
{approvedResellers.filter(r => r.tier === 'platinum').length}
|
{resellers.filter(r => r.tier === 'platinum').length}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-12 h-12 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center">
|
<div className="w-12 h-12 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
<TrendingUp className="w-6 h-6 text-yellow-600 dark:text-yellow-400" />
|
<TrendingUp className="w-6 h-6 text-yellow-600 dark:text-yellow-400" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card p-6 bg-white dark:bg-gray-800 hover:shadow-lg">
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-secondary-600 dark:text-secondary-400">
|
<p className="text-sm font-medium text-secondary-600 dark:text-secondary-400 mb-2">
|
||||||
Total Customers
|
Total Customers
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
<p className="text-3xl font-bold text-secondary-900 dark:text-white">
|
||||||
{approvedResellers.reduce((sum, r) => sum + (r.customers || 0), 0)}
|
{resellers.reduce((sum, r) => sum + (r.customers || 0), 0)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-12 h-12 bg-success-100 dark:bg-success-900 rounded-full flex items-center justify-center">
|
<div className="w-12 h-12 bg-success-100 dark:bg-success-900 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
<Users className="w-6 h-6 text-success-600 dark:text-success-400" />
|
<Users className="w-6 h-6 text-success-600 dark:text-success-400" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card p-6 bg-white dark:bg-gray-800 hover:shadow-lg">
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-secondary-600 dark:text-secondary-400">
|
<p className="text-sm font-medium text-secondary-600 dark:text-secondary-400 mb-2">
|
||||||
Total Revenue
|
Total Revenue
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
<p className="text-3xl font-bold text-secondary-900 dark:text-white">
|
||||||
{formatCurrency(approvedResellers.reduce((sum, r) => sum + (r.totalRevenue || 0), 0))}
|
{formatCurrency(resellers.reduce((sum, r) => sum + (r.totalRevenue || 0), 0))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-12 h-12 bg-success-100 dark:bg-success-900 rounded-full flex items-center justify-center">
|
<div className="w-12 h-12 bg-success-100 dark:bg-success-900 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
<DollarSign className="w-6 h-6 text-success-600 dark:text-success-400" />
|
<DollarSign className="w-6 h-6 text-success-600 dark:text-success-400" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters and Search */}
|
{/* Filters */}
|
||||||
<div className="card p-6">
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||||
<div className="flex flex-col lg:flex-row gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div className="flex-1">
|
{/* Search */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Search Resellers
|
||||||
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-secondary-400" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search resellers..."
|
placeholder="Search by name, email, or company..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="input pl-10 w-full focus:ring-2 focus:ring-primary-500 transition-all duration-200"
|
className="w-full pl-12 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
{/* Status Filter */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Filter by Status
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
className="input focus:ring-2 focus:ring-primary-500 transition-all duration-200"
|
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all"
|
||||||
>
|
>
|
||||||
<option value="all">All Status</option>
|
<option value="all">All Statuses</option>
|
||||||
<option value="active">Active</option>
|
<option value="active">Active</option>
|
||||||
<option value="pending">Pending</option>
|
<option value="inactive">Inactive</option>
|
||||||
|
<option value="rejected">Rejected</option>
|
||||||
<option value="suspended">Suspended</option>
|
<option value="suspended">Suspended</option>
|
||||||
</select>
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tier Filter Row */}
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Filter by Tier:
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={tierFilter}
|
value={tierFilter}
|
||||||
onChange={(e) => setTierFilter(e.target.value)}
|
onChange={(e) => setTierFilter(e.target.value)}
|
||||||
className="input focus:ring-2 focus:ring-primary-500 transition-all duration-200"
|
className="flex-1 max-w-xs px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all"
|
||||||
>
|
>
|
||||||
<option value="all">All Tiers</option>
|
<option value="all">All Tiers</option>
|
||||||
<option value="platinum">Platinum</option>
|
<option value="platinum">Platinum</option>
|
||||||
<option value="gold">Gold</option>
|
<option value="gold">Gold</option>
|
||||||
<option value="silver">Silver</option>
|
<option value="silver">Silver</option>
|
||||||
|
<option value="bronze">Bronze</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<button className="inline-flex items-center px-3 py-2 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200 transform hover:scale-105">
|
|
||||||
<Filter className="w-4 h-4 mr-2" />
|
|
||||||
Filters
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Resellers Table */}
|
{/* Content Area */}
|
||||||
<div className="card">
|
{loading ? (
|
||||||
<div className="p-6 border-b border-secondary-200 dark:border-secondary-700">
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 p-12">
|
||||||
<div className="flex items-center justify-between">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||||
All Approved Resellers
|
<p className="text-gray-600 dark:text-gray-400">Loading resellers...</p>
|
||||||
</h3>
|
</div>
|
||||||
<div className="flex space-x-2">
|
</div>
|
||||||
<button className="inline-flex items-center px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200 transform hover:scale-105">
|
) : resellers.length === 0 ? (
|
||||||
<Download className="w-4 h-4 mr-2" />
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 p-12">
|
||||||
Export
|
<div className="text-center">
|
||||||
</button>
|
<Users className="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">No resellers found</h3>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 mb-6">
|
||||||
|
You don't have any reseller partnerships yet.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
To get started:
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||||
|
<li>• Approve qualified reseller applications</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
<table className="w-full">
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<thead className="bg-secondary-50 dark:bg-secondary-800">
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
<tr>
|
Reseller List ({resellers.length})
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
</h2>
|
||||||
Reseller
|
</div>
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
{/* Resellers Table */}
|
||||||
Status
|
<div className="overflow-x-auto">
|
||||||
</th>
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||||
Tier
|
<tr>
|
||||||
</th>
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
Reseller
|
||||||
Revenue
|
</th>
|
||||||
</th>
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
Contact
|
||||||
Customers
|
</th>
|
||||||
</th>
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
Tier
|
||||||
Commission
|
</th>
|
||||||
</th>
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
Status
|
||||||
Last Active
|
</th>
|
||||||
</th>
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
Performance
|
||||||
Actions
|
</th>
|
||||||
</th>
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
</tr>
|
Actions
|
||||||
</thead>
|
</th>
|
||||||
<tbody className="bg-white dark:bg-secondary-900 divide-y divide-secondary-200 dark:divide-secondary-700">
|
</tr>
|
||||||
{filteredResellers.map((reseller) => (
|
</thead>
|
||||||
<tr key={reseller.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-800">
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
{resellers.map((reseller) => (
|
||||||
<div className="flex items-center">
|
<tr key={reseller.id} className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
<div className="relative mr-3">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
{reseller.avatar ? (
|
<div className="flex items-center">
|
||||||
<img
|
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
className="h-10 w-10 rounded-full object-cover"
|
{reseller.avatar ? (
|
||||||
src={reseller.avatar}
|
<img src={reseller.avatar} alt={`${reseller.firstName} ${reseller.lastName}`} className="w-12 h-12 rounded-full" />
|
||||||
alt={`${reseller.firstName} ${reseller.lastName}`}
|
|
||||||
onError={(e) => {
|
|
||||||
// Hide the image if it fails to load and show fallback
|
|
||||||
e.currentTarget.style.display = 'none';
|
|
||||||
e.currentTarget.nextElementSibling?.classList.remove('hidden');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<div className={cn(
|
|
||||||
"h-10 w-10 rounded-full flex items-center justify-center bg-gradient-to-r from-blue-500 to-blue-600 text-white font-semibold text-sm shadow-sm",
|
|
||||||
reseller.avatar ? 'hidden' : ''
|
|
||||||
)}>
|
|
||||||
{reseller.firstName && reseller.lastName ? (
|
|
||||||
`${reseller.firstName.charAt(0).toUpperCase()}${reseller.lastName.charAt(0).toUpperCase()}`
|
|
||||||
) : (
|
) : (
|
||||||
<User className="w-5 h-5" />
|
<User className="w-6 h-6 text-primary-600 dark:text-primary-400" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="ml-4 min-w-0">
|
||||||
<div>
|
<div className="text-sm font-semibold text-gray-900 dark:text-white truncate">
|
||||||
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
{reseller.firstName} {reseller.lastName}
|
||||||
{reseller.firstName || ''} {reseller.lastName || ''}
|
</div>
|
||||||
</div>
|
<div className="text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||||
<div className="text-sm text-secondary-500 dark:text-secondary-400">
|
{reseller.company || 'No company'}
|
||||||
{reseller.email || 'N/A'}
|
</div>
|
||||||
</div>
|
|
||||||
<div className="text-xs text-secondary-400 dark:text-secondary-500">
|
|
||||||
{reseller.phone || 'N/A'}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<div className="space-y-1">
|
||||||
<span className={cn(
|
<div className="flex items-center">
|
||||||
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium",
|
<Mail className="w-4 h-4 mr-2 text-gray-400 flex-shrink-0" />
|
||||||
getStatusColor(reseller.status || 'active')
|
<span className="text-sm text-gray-900 dark:text-white truncate max-w-48">
|
||||||
)}>
|
{reseller.email}
|
||||||
{reseller.status ? reseller.status.charAt(0).toUpperCase() + reseller.status.slice(1) : 'Active'}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</td>
|
{reseller.phone && (
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<div className="flex items-center">
|
||||||
<span className={cn(
|
<Phone className="w-4 h-4 mr-2 text-gray-400 flex-shrink-0" />
|
||||||
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium",
|
<span className="text-sm text-gray-900 dark:text-white">
|
||||||
getTierColor(reseller.tier || 'silver')
|
{reseller.phone}
|
||||||
)}>
|
</span>
|
||||||
{reseller.tier ? reseller.tier.charAt(0).toUpperCase() + reseller.tier.slice(1) : 'N/A'}
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className={`inline-flex px-3 py-1 text-xs font-semibold rounded-full ${getTierColor(reseller.tier || 'standard')}`}>
|
||||||
|
{reseller.tier ? reseller.tier.charAt(0).toUpperCase() + reseller.tier.slice(1) : 'Standard'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className={`inline-flex px-3 py-1 text-xs font-semibold rounded-full ${getStatusColor(reseller.status)}`}>
|
||||||
|
{reseller.status.charAt(0).toUpperCase() + reseller.status.slice(1)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
|
||||||
{formatCurrency(reseller.totalRevenue || 0)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
{formatNumber(reseller.customers || 0)}
|
<div className="text-sm text-gray-900 dark:text-white">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span>Customers:</span>
|
||||||
|
<span className="font-medium">{reseller.customers || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Revenue:</span>
|
||||||
|
<span className="font-medium">{formatCurrency(reseller.totalRevenue || 0)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
{reseller.commissionRate ? `${reseller.commissionRate}%` : 'N/A'}
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewReseller(reseller)}
|
||||||
|
className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors"
|
||||||
|
title="View Details"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditReseller(reseller)}
|
||||||
|
className="p-2 bg-amber-100 dark:bg-amber-900 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-800 transition-colors"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4 text-amber-600 dark:text-amber-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-400">
|
</tr>
|
||||||
{reseller.lastActive ? formatDate(reseller.lastActive) : 'N/A'}
|
))}
|
||||||
</td>
|
</tbody>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
</table>
|
||||||
<div className="flex items-center justify-end space-x-2">
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => handleViewReseller(reseller)}
|
|
||||||
className="p-2 rounded-md hover:bg-secondary-100 dark:hover:bg-secondary-800 transition-all duration-200 transform hover:scale-110"
|
|
||||||
title="View Details"
|
|
||||||
>
|
|
||||||
<Eye className="w-4 h-4 text-secondary-600 dark:text-secondary-400" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleEditReseller(reseller)}
|
|
||||||
className="p-2 rounded-md hover:bg-secondary-100 dark:hover:bg-secondary-800 transition-all duration-200 transform hover:scale-110"
|
|
||||||
title="Edit Reseller"
|
|
||||||
>
|
|
||||||
<Edit className="w-4 h-4 text-secondary-600 dark:text-secondary-400" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleMailReseller(reseller)}
|
|
||||||
className="p-2 rounded-md hover:bg-secondary-100 dark:hover:bg-secondary-800 transition-all duration-200 transform hover:scale-110"
|
|
||||||
title="Send Email"
|
|
||||||
>
|
|
||||||
<Mail className="w-4 h-4 text-secondary-600 dark:text-secondary-400" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleMoreOptions(reseller)}
|
|
||||||
className="p-2 rounded-md hover:bg-secondary-100 dark:hover:bg-secondary-800 transition-all duration-200 transform hover:scale-110"
|
|
||||||
title="More Options"
|
|
||||||
>
|
|
||||||
<MoreVertical className="w-4 h-4 text-secondary-600 dark:text-secondary-400" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Add Reseller Modal */}
|
|
||||||
<Modal
|
|
||||||
isOpen={isAddModalOpen}
|
|
||||||
onClose={() => setIsAddModalOpen(false)}
|
|
||||||
title="Add New Reseller"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<AddPartnershipForm
|
|
||||||
onSubmit={handleAddReseller}
|
|
||||||
onCancel={() => setIsAddModalOpen(false)}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Reseller Detail Modal */}
|
{/* Reseller Detail Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../store/hooks';
|
||||||
import { formatCurrency, formatNumber, formatDate, formatPercentage } from '../../utils/format';
|
import { formatCurrency, formatNumber, formatDate, formatPercentage } from '../../utils/format';
|
||||||
import CommissionTrendsChart from '../../components/charts/CommissionTrendsChart';
|
import CommissionTrendsChart from '../../components/charts/CommissionTrendsChart';
|
||||||
import DualCurrencyDisplay from '../../components/DualCurrencyDisplay';
|
import DualCurrencyDisplay from '../../components/DualCurrencyDisplay';
|
||||||
@ -17,86 +18,85 @@ import {
|
|||||||
Award,
|
Award,
|
||||||
Clock,
|
Clock,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
AlertCircle
|
AlertCircle,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '../../utils/cn';
|
import { cn } from '../../utils/cn';
|
||||||
|
import { fetchCommissions, setFilters, setPage, setItemsPerPage } from '../../store/slices/commissionSlice';
|
||||||
// Mock commission data
|
import toast from 'react-hot-toast';
|
||||||
const mockCommissions = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
reseller: 'TechCorp Solutions',
|
|
||||||
deal: 'Enterprise Cloud Migration',
|
|
||||||
amount: 300000,
|
|
||||||
commissionRate: 12,
|
|
||||||
commissionEarned: 36000,
|
|
||||||
status: 'paid',
|
|
||||||
paidDate: '2024-01-15T00:00:00Z',
|
|
||||||
dealValue: 2500000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
reseller: 'DataFlow Solutions',
|
|
||||||
deal: 'Data Center Expansion',
|
|
||||||
amount: 270000,
|
|
||||||
commissionRate: 15,
|
|
||||||
commissionEarned: 40500,
|
|
||||||
status: 'pending',
|
|
||||||
paidDate: null,
|
|
||||||
dealValue: 1800000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
reseller: 'CloudTech Ltd',
|
|
||||||
deal: 'SaaS Platform License',
|
|
||||||
amount: 67500,
|
|
||||||
commissionRate: 10,
|
|
||||||
commissionEarned: 6750,
|
|
||||||
status: 'processing',
|
|
||||||
paidDate: null,
|
|
||||||
dealValue: 450000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '4',
|
|
||||||
reseller: 'InnovateSoft Solutions',
|
|
||||||
deal: 'Security Infrastructure',
|
|
||||||
amount: 480000,
|
|
||||||
commissionRate: 12,
|
|
||||||
commissionEarned: 57600,
|
|
||||||
status: 'paid',
|
|
||||||
paidDate: '2024-01-10T00:00:00Z',
|
|
||||||
dealValue: 3200000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '5',
|
|
||||||
reseller: 'Digital Dynamics',
|
|
||||||
deal: 'DevOps Automation',
|
|
||||||
amount: 127500,
|
|
||||||
commissionRate: 15,
|
|
||||||
commissionEarned: 19125,
|
|
||||||
status: 'paid',
|
|
||||||
paidDate: '2024-01-12T00:00:00Z',
|
|
||||||
dealValue: 850000,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const CommissionsPage: React.FC = () => {
|
const CommissionsPage: React.FC = () => {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const dispatch = useAppDispatch();
|
||||||
const [statusFilter, setStatusFilter] = useState('all');
|
const { commissions, pagination, filters, isLoading, error } = useAppSelector((state) => state.commission);
|
||||||
const [dateFilter, setDateFilter] = useState('all');
|
|
||||||
|
const [searchTerm, setSearchTerm] = useState(filters.searchTerm);
|
||||||
|
const [statusFilter, setStatusFilter] = useState(filters.status);
|
||||||
|
const [dateFilter, setDateFilter] = useState(filters.dateRange);
|
||||||
|
|
||||||
const filteredCommissions = mockCommissions.filter(commission => {
|
// Fetch commissions on component mount and when filters change
|
||||||
const matchesSearch = commission.reseller.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
useEffect(() => {
|
||||||
commission.deal.toLowerCase().includes(searchTerm.toLowerCase());
|
const params: any = {
|
||||||
const matchesStatus = statusFilter === 'all' || commission.status === statusFilter;
|
page: pagination.currentPage,
|
||||||
|
limit: pagination.itemsPerPage
|
||||||
|
};
|
||||||
|
|
||||||
return matchesSearch && matchesStatus;
|
if (statusFilter !== 'all') params.status = statusFilter;
|
||||||
|
if (dateFilter !== 'all') params.dateRange = dateFilter;
|
||||||
|
|
||||||
|
dispatch(fetchCommissions(params));
|
||||||
|
}, [dispatch, pagination.currentPage, pagination.itemsPerPage, statusFilter, dateFilter]);
|
||||||
|
|
||||||
|
// Handle search with debouncing
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
dispatch(setFilters({ searchTerm }));
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchTerm, dispatch]);
|
||||||
|
|
||||||
|
// Handle filter changes
|
||||||
|
const handleStatusFilterChange = (value: string) => {
|
||||||
|
setStatusFilter(value);
|
||||||
|
dispatch(setFilters({ status: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateFilterChange = (value: string) => {
|
||||||
|
setDateFilter(value);
|
||||||
|
dispatch(setFilters({ dateRange: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle pagination
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
dispatch(setPage(page));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemsPerPageChange = (value: number) => {
|
||||||
|
dispatch(setItemsPerPage(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter commissions based on search term
|
||||||
|
const filteredCommissions = commissions.filter(commission => {
|
||||||
|
if (!searchTerm) return true;
|
||||||
|
return commission.reseller.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
commission.deal.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalCommissionEarned = mockCommissions.reduce((sum, c) => sum + c.commissionEarned, 0);
|
// Calculate statistics
|
||||||
const totalPaid = mockCommissions.filter(c => c.status === 'paid').reduce((sum, c) => sum + c.commissionEarned, 0);
|
const totalCommissionEarned = commissions.reduce((sum, c) => sum + c.commissionEarned, 0);
|
||||||
const totalPending = mockCommissions.filter(c => c.status === 'pending').reduce((sum, c) => sum + c.commissionEarned, 0);
|
const totalPaid = commissions.filter(c => c.status === 'paid').reduce((sum, c) => sum + c.commissionEarned, 0);
|
||||||
const avgCommissionRate = mockCommissions.reduce((sum, c) => sum + c.commissionRate, 0) / mockCommissions.length;
|
const totalPending = commissions.filter(c => c.status === 'pending').reduce((sum, c) => sum + c.commissionEarned, 0);
|
||||||
|
const avgCommissionRate = commissions.length > 0
|
||||||
|
? commissions.reduce((sum, c) => sum + c.commissionRate, 0) / commissions.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Show error toast if there's an error
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
toast.error(error);
|
||||||
|
}
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@ -133,7 +133,24 @@ const CommissionsPage: React.FC = () => {
|
|||||||
Commissions
|
Commissions
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-secondary-600 dark:text-secondary-400 mt-1">
|
<p className="text-secondary-600 dark:text-secondary-400 mt-1">
|
||||||
Track your commission earnings and payments
|
{(() => {
|
||||||
|
if (isLoading) return 'Loading commission data...';
|
||||||
|
if (commissions.length === 0) return 'No commission data available yet';
|
||||||
|
|
||||||
|
const totalCommissions = commissions.length;
|
||||||
|
const paidCommissions = commissions.filter(c => c.status === 'paid').length;
|
||||||
|
const pendingCommissions = commissions.filter(c => c.status === 'pending').length;
|
||||||
|
|
||||||
|
if (totalCommissions === 0) {
|
||||||
|
return 'Track your commission earnings and payments';
|
||||||
|
} else if (pendingCommissions > 0) {
|
||||||
|
return `Track ${totalCommissions} commission${totalCommissions > 1 ? 's' : ''} with ${pendingCommissions} pending payment${pendingCommissions > 1 ? 's' : ''}`;
|
||||||
|
} else if (paidCommissions > 0) {
|
||||||
|
return `Track ${totalCommissions} commission${totalCommissions > 1 ? 's' : ''} with ${paidCommissions} paid`;
|
||||||
|
} else {
|
||||||
|
return `Track ${totalCommissions} commission${totalCommissions > 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
|
})()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-3">
|
<div className="flex space-x-3">
|
||||||
@ -244,7 +261,7 @@ const CommissionsPage: React.FC = () => {
|
|||||||
Top Earners
|
Top Earners
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{mockCommissions
|
{commissions
|
||||||
.sort((a, b) => b.commissionEarned - a.commissionEarned)
|
.sort((a, b) => b.commissionEarned - a.commissionEarned)
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map((commission, index) => (
|
.map((commission, index) => (
|
||||||
@ -303,7 +320,7 @@ const CommissionsPage: React.FC = () => {
|
|||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
onChange={(e) => handleStatusFilterChange(e.target.value)}
|
||||||
className="input"
|
className="input"
|
||||||
>
|
>
|
||||||
<option value="all">All Status</option>
|
<option value="all">All Status</option>
|
||||||
@ -314,7 +331,7 @@ const CommissionsPage: React.FC = () => {
|
|||||||
|
|
||||||
<select
|
<select
|
||||||
value={dateFilter}
|
value={dateFilter}
|
||||||
onChange={(e) => setDateFilter(e.target.value)}
|
onChange={(e) => handleDateFilterChange(e.target.value)}
|
||||||
className="input"
|
className="input"
|
||||||
>
|
>
|
||||||
<option value="all">All Time</option>
|
<option value="all">All Time</option>
|
||||||
@ -347,90 +364,182 @@ const CommissionsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
{isLoading ? (
|
||||||
<table className="w-full">
|
<div className="p-6 text-center">
|
||||||
<thead className="bg-secondary-50 dark:bg-secondary-800">
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||||
<tr>
|
<p className="mt-2 text-secondary-600 dark:text-secondary-400">Loading commissions...</p>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
</div>
|
||||||
Reseller
|
) : filteredCommissions.length === 0 ? (
|
||||||
</th>
|
<div className="p-6 text-center">
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
<div className="w-16 h-16 bg-secondary-100 dark:bg-secondary-700 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
Deal
|
<Award className="w-8 h-8 text-secondary-400" />
|
||||||
</th>
|
</div>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">No Commissions Found</h3>
|
||||||
Deal Value
|
<p className="text-secondary-600 dark:text-secondary-400">
|
||||||
</th>
|
{searchTerm || statusFilter !== 'all' || dateFilter !== 'all'
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
? 'Try adjusting your filters or search terms.'
|
||||||
Commission Rate
|
: 'No commission data available yet.'
|
||||||
</th>
|
}
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
</p>
|
||||||
Commission Earned
|
</div>
|
||||||
</th>
|
) : (
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
<>
|
||||||
Status
|
<div className="overflow-x-auto">
|
||||||
</th>
|
<table className="w-full">
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
<thead className="bg-secondary-50 dark:bg-secondary-800">
|
||||||
Paid Date
|
<tr>
|
||||||
</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
Reseller
|
||||||
Actions
|
</th>
|
||||||
</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
||||||
</tr>
|
Deal
|
||||||
</thead>
|
</th>
|
||||||
<tbody className="bg-white dark:bg-secondary-900 divide-y divide-secondary-200 dark:divide-secondary-700">
|
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
||||||
{filteredCommissions.map((commission) => (
|
Deal Value
|
||||||
<tr key={commission.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-800 transition-colors">
|
</th>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
||||||
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
Commission Rate
|
||||||
{commission.reseller}
|
</th>
|
||||||
</div>
|
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
||||||
</td>
|
Commission Earned
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
</th>
|
||||||
<div className="text-sm text-secondary-900 dark:text-white">
|
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
||||||
{commission.deal}
|
Status
|
||||||
</div>
|
</th>
|
||||||
</td>
|
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
Paid Date
|
||||||
<DualCurrencyDisplay
|
</th>
|
||||||
amount={commission.dealValue}
|
<th className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
||||||
currency="INR"
|
Actions
|
||||||
className="text-sm"
|
</th>
|
||||||
/>
|
</tr>
|
||||||
</td>
|
</thead>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
<tbody className="bg-white dark:bg-secondary-900 divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||||
{commission.commissionRate}%
|
{filteredCommissions.map((commission) => (
|
||||||
</td>
|
<tr key={commission.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-800 transition-colors">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<DualCurrencyDisplay
|
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||||
amount={commission.commissionEarned}
|
{commission.reseller}
|
||||||
currency="INR"
|
</div>
|
||||||
className="text-sm font-medium"
|
</td>
|
||||||
/>
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
</td>
|
<div className="text-sm text-secondary-900 dark:text-white">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
{commission.deal}
|
||||||
<span className={cn(
|
</div>
|
||||||
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium",
|
</td>
|
||||||
getStatusColor(commission.status)
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
)}>
|
<DualCurrencyDisplay
|
||||||
{getStatusIcon(commission.status)}
|
amount={commission.dealValue}
|
||||||
<span className="ml-1">
|
currency="INR"
|
||||||
{commission.status.charAt(0).toUpperCase() + commission.status.slice(1)}
|
className="text-sm"
|
||||||
</span>
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||||
|
{commission.commissionRate}%
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<DualCurrencyDisplay
|
||||||
|
amount={commission.commissionEarned}
|
||||||
|
currency="INR"
|
||||||
|
className="text-sm font-medium"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium",
|
||||||
|
getStatusColor(commission.status)
|
||||||
|
)}>
|
||||||
|
{getStatusIcon(commission.status)}
|
||||||
|
<span className="ml-1">
|
||||||
|
{commission.status.charAt(0).toUpperCase() + commission.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
{commission.paidDate ? formatDate(commission.paidDate) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<button className="btn btn-outline btn-sm">
|
||||||
|
View Details
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{pagination.totalPages > 1 && (
|
||||||
|
<div className="p-6 border-t border-secondary-200 dark:border-secondary-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||||
|
Show {((pagination.currentPage - 1) * pagination.itemsPerPage) + 1} to {Math.min(pagination.currentPage * pagination.itemsPerPage, pagination.totalItems)} of {pagination.totalItems} commissions
|
||||||
</span>
|
</span>
|
||||||
</td>
|
<select
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-400">
|
value={pagination.itemsPerPage}
|
||||||
{commission.paidDate ? formatDate(commission.paidDate) : '-'}
|
onChange={(e) => handleItemsPerPageChange(Number(e.target.value))}
|
||||||
</td>
|
className="px-2 py-1 border border-secondary-300 dark:border-secondary-600 rounded bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white text-sm"
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
>
|
||||||
<button className="btn btn-outline btn-sm">
|
<option value={10}>10 per page</option>
|
||||||
View Details
|
<option value={20}>20 per page</option>
|
||||||
|
<option value={50}>50 per page</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handlePageChange(pagination.currentPage - 1)}
|
||||||
|
disabled={pagination.currentPage === 1}
|
||||||
|
className="p-2 border border-secondary-300 dark:border-secondary-600 rounded bg-white dark:bg-secondary-700 text-secondary-600 dark:text-secondary-400 hover:bg-secondary-50 dark:hover:bg-secondary-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
|
||||||
</tr>
|
<div className="flex items-center space-x-1">
|
||||||
))}
|
{Array.from({ length: pagination.totalPages }, (_, i) => i + 1)
|
||||||
</tbody>
|
.filter(page => {
|
||||||
</table>
|
if (pagination.totalPages <= 7) return true;
|
||||||
</div>
|
if (page === 1 || page === pagination.totalPages) return true;
|
||||||
|
if (page >= pagination.currentPage - 1 && page <= pagination.currentPage + 1) return true;
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
.map((page, index, array) => {
|
||||||
|
if (index > 0 && array[index - 1] !== page - 1) {
|
||||||
|
return (
|
||||||
|
<span key={`ellipsis-${page}`} className="px-2 text-secondary-400">...</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={page}
|
||||||
|
onClick={() => handlePageChange(page)}
|
||||||
|
className={`px-3 py-1 rounded text-sm font-medium ${
|
||||||
|
pagination.currentPage === page
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'text-secondary-600 dark:text-secondary-400 hover:bg-secondary-100 dark:hover:bg-secondary-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handlePageChange(pagination.currentPage + 1)}
|
||||||
|
disabled={pagination.currentPage === pagination.totalPages}
|
||||||
|
className="p-2 border border-secondary-300 dark:border-secondary-600 rounded bg-white dark:bg-secondary-700 text-secondary-600 dark:text-secondary-400 hover:bg-secondary-50 dark:hover:bg-secondary-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useAppSelector, useAppDispatch } from '../store/hooks';
|
import { useAppSelector, useAppDispatch } from '../store/hooks';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { setStats, setRecentActivities, setQuickActions } from '../store/slices/dashboardSlice';
|
import { setStats, setRecentActivities, setQuickActions } from '../store/slices/dashboardSlice';
|
||||||
import { loginSuccess } from '../store/slices/authSlice';
|
import { mockDashboardStats, mockRecentActivities, mockQuickActions } from '../data/mockData';
|
||||||
import { mockDashboardStats, mockRecentActivities, mockQuickActions, mockUser } from '../data/mockData';
|
import { formatNumber, formatRelativeTime, formatPercentage } from '../utils/format';
|
||||||
import { formatCurrency, formatCurrencyDual, formatNumber, formatRelativeTime, formatPercentage } from '../utils/format';
|
import dashboardService from '../services/dashboardService';
|
||||||
import RevenueChart from '../components/charts/RevenueChart';
|
import RevenueChart from '../components/charts/RevenueChart';
|
||||||
import ResellerPerformanceChart from '../components/charts/ResellerPerformanceChart';
|
import ResellerPerformanceChart from '../components/charts/ResellerPerformanceChart';
|
||||||
import DualCurrencyDisplay from '../components/DualCurrencyDisplay';
|
import DualCurrencyDisplay from '../components/DualCurrencyDisplay';
|
||||||
@ -28,6 +28,7 @@ import {
|
|||||||
MessageCircle
|
MessageCircle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '../utils/cn';
|
import { cn } from '../utils/cn';
|
||||||
|
import VendorSalesDashboard from '../components/VendorSalesDashboard';
|
||||||
|
|
||||||
const Dashboard: React.FC = () => {
|
const Dashboard: React.FC = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@ -37,11 +38,97 @@ const Dashboard: React.FC = () => {
|
|||||||
const [showFeedback, setShowFeedback] = React.useState(false);
|
const [showFeedback, setShowFeedback] = React.useState(false);
|
||||||
const [feedbackKey, setFeedbackKey] = React.useState(0);
|
const [feedbackKey, setFeedbackKey] = React.useState(0);
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [apiErrors, setApiErrors] = useState<{[key: string]: string}>({});
|
||||||
|
|
||||||
|
// Default stats to prevent undefined errors
|
||||||
|
const safeStats = {
|
||||||
|
...(stats || {}),
|
||||||
|
totalRevenue: stats?.totalRevenue ?? 0,
|
||||||
|
totalResellers: stats?.totalResellers ?? 0,
|
||||||
|
activePartnerships: stats?.activePartnerships ?? 0,
|
||||||
|
pendingApprovals: stats?.pendingApprovals ?? 0,
|
||||||
|
monthlyGrowth: stats?.monthlyGrowth ?? 0,
|
||||||
|
commissionEarned: stats?.commissionEarned ?? 0,
|
||||||
|
averageDealSize: stats?.averageDealSize ?? 0,
|
||||||
|
conversionRate: stats?.conversionRate ?? 0,
|
||||||
|
currency: stats?.currency ?? 'USD',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Safe arrays to prevent undefined errors
|
||||||
|
const safeRecentActivities = Array.isArray(recentActivities) ? recentActivities : [];
|
||||||
|
const safeQuickActions = Array.isArray(quickActions) ? quickActions : [];
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
console.log('quickActions from Redux:', quickActions);
|
||||||
|
console.log('recentActivities from Redux:', recentActivities);
|
||||||
|
console.log('safeQuickActions:', safeQuickActions);
|
||||||
|
console.log('safeRecentActivities:', safeRecentActivities);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Initialize dashboard data
|
const loadDashboardData = async () => {
|
||||||
dispatch(setStats(mockDashboardStats));
|
try {
|
||||||
dispatch(setRecentActivities(mockRecentActivities));
|
setIsLoading(true);
|
||||||
dispatch(setQuickActions(mockQuickActions));
|
setApiErrors({}); // Clear previous errors
|
||||||
|
|
||||||
|
// Check dashboard health first
|
||||||
|
const health = await dashboardService.getDashboardHealth();
|
||||||
|
console.log('Dashboard health check:', health);
|
||||||
|
|
||||||
|
// Try to fetch real data first
|
||||||
|
const [stats, activities, actions] = await Promise.all([
|
||||||
|
dashboardService.getDashboardStats(),
|
||||||
|
dashboardService.getRecentActivities(),
|
||||||
|
dashboardService.getQuickActions()
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log('API Response - stats:', stats);
|
||||||
|
console.log('API Response - activities:', activities);
|
||||||
|
console.log('API Response - actions:', actions);
|
||||||
|
|
||||||
|
// Validate data before dispatching to Redux
|
||||||
|
const validStats = stats && typeof stats === 'object' ? stats : mockDashboardStats;
|
||||||
|
const validActivities = Array.isArray(activities) ? activities : mockRecentActivities;
|
||||||
|
const validActions = Array.isArray(actions) ? actions : mockQuickActions;
|
||||||
|
|
||||||
|
// Update Redux store with validated data
|
||||||
|
dispatch(setStats(validStats));
|
||||||
|
dispatch(setRecentActivities(validActivities));
|
||||||
|
dispatch(setQuickActions(validActions));
|
||||||
|
|
||||||
|
// Check if any endpoints returned 404 and show appropriate messages
|
||||||
|
const errors: {[key: string]: string} = {};
|
||||||
|
if (!health.stats) {
|
||||||
|
errors['dashboardStats'] = 'Dashboard stats endpoint not available - using sample data';
|
||||||
|
}
|
||||||
|
if (!health.activities) {
|
||||||
|
errors['recentActivities'] = 'Recent activities endpoint not available - using sample data';
|
||||||
|
}
|
||||||
|
if (!health.quickActions) {
|
||||||
|
errors['quickActions'] = 'Quick actions endpoint not available - using sample data';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
setApiErrors(errors);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading dashboard data:', error);
|
||||||
|
setApiErrors({
|
||||||
|
dashboardStats: 'Failed to load dashboard data - using sample data',
|
||||||
|
recentActivities: 'Failed to load dashboard data - using sample data',
|
||||||
|
quickActions: 'Failed to load dashboard data - using sample data'
|
||||||
|
});
|
||||||
|
// Fallback to mock data
|
||||||
|
console.log('Falling back to mock data');
|
||||||
|
dispatch(setStats(mockDashboardStats));
|
||||||
|
dispatch(setRecentActivities(mockRecentActivities));
|
||||||
|
dispatch(setQuickActions(mockQuickActions));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadDashboardData();
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
const getActivityIcon = (type: string) => {
|
const getActivityIcon = (type: string) => {
|
||||||
@ -106,6 +193,22 @@ const Dashboard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-64 space-y-4">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-secondary-600 dark:text-secondary-400 text-sm">
|
||||||
|
Loading dashboard data...
|
||||||
|
</p>
|
||||||
|
<p className="text-secondary-500 dark:text-secondary-500 text-xs mt-1">
|
||||||
|
Checking API endpoints and loading your data
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -123,8 +226,8 @@ const Dashboard: React.FC = () => {
|
|||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">Commission Earned</p>
|
<p className="text-sm text-secondary-600 dark:text-secondary-400">Commission Earned</p>
|
||||||
<DualCurrencyDisplay
|
<DualCurrencyDisplay
|
||||||
amount={stats.commissionEarned}
|
amount={safeStats.commissionEarned}
|
||||||
currency={stats.currency}
|
currency={safeStats.currency}
|
||||||
className="text-xl sm:text-2xl"
|
className="text-xl sm:text-2xl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -134,8 +237,57 @@ const Dashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* API Status Indicators */}
|
||||||
|
{Object.keys(apiErrors).length > 0 && (
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="w-5 h-5 text-amber-600 dark:text-amber-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||||
|
Some dashboard features are using sample data
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2 text-sm text-amber-700 dark:text-amber-300">
|
||||||
|
{Object.entries(apiErrors).map(([key, message]) => (
|
||||||
|
<div key={key} className="flex items-center space-x-2">
|
||||||
|
<span className="w-2 h-2 bg-amber-400 rounded-full"></span>
|
||||||
|
<span>{message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success Indicator when all endpoints are working */}
|
||||||
|
{Object.keys(apiErrors).length === 0 && !isLoading && (
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<svg className="w-4 h-4 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||||
|
Dashboard connected - using real-time data
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6">
|
||||||
|
{apiErrors.dashboardStats && (
|
||||||
|
<div className="col-span-full mb-2">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200">
|
||||||
|
📊 Dashboard stats using sample data
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="card p-4 sm:p-6">
|
<div className="card p-4 sm:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@ -146,8 +298,8 @@ const Dashboard: React.FC = () => {
|
|||||||
Total sales from all resellers
|
Total sales from all resellers
|
||||||
</p>
|
</p>
|
||||||
<DualCurrencyDisplay
|
<DualCurrencyDisplay
|
||||||
amount={stats.totalRevenue}
|
amount={safeStats.totalRevenue}
|
||||||
currency={stats.currency}
|
currency={safeStats.currency}
|
||||||
className="text-lg sm:text-2xl truncate"
|
className="text-lg sm:text-2xl truncate"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -158,7 +310,7 @@ const Dashboard: React.FC = () => {
|
|||||||
<div className="flex items-center mt-3 sm:mt-4">
|
<div className="flex items-center mt-3 sm:mt-4">
|
||||||
<TrendingUp className="w-3 h-3 sm:w-4 sm:h-4 text-success-600 flex-shrink-0" />
|
<TrendingUp className="w-3 h-3 sm:w-4 sm:h-4 text-success-600 flex-shrink-0" />
|
||||||
<span className="text-xs sm:text-sm text-success-600 ml-1 truncate">
|
<span className="text-xs sm:text-sm text-success-600 ml-1 truncate">
|
||||||
+{stats.monthlyGrowth}% from last month
|
+{safeStats.monthlyGrowth}% from last month
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -173,7 +325,7 @@ const Dashboard: React.FC = () => {
|
|||||||
Currently active partner companies
|
Currently active partner companies
|
||||||
</p>
|
</p>
|
||||||
<p className="text-lg sm:text-2xl font-bold text-secondary-900 dark:text-white truncate">
|
<p className="text-lg sm:text-2xl font-bold text-secondary-900 dark:text-white truncate">
|
||||||
{formatNumber(stats.totalResellers)}
|
{formatNumber(safeStats.totalResellers)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center flex-shrink-0 ml-2">
|
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center flex-shrink-0 ml-2">
|
||||||
@ -198,7 +350,7 @@ const Dashboard: React.FC = () => {
|
|||||||
Approved business agreements
|
Approved business agreements
|
||||||
</p>
|
</p>
|
||||||
<p className="text-lg sm:text-2xl font-bold text-secondary-900 dark:text-white truncate">
|
<p className="text-lg sm:text-2xl font-bold text-secondary-900 dark:text-white truncate">
|
||||||
{formatNumber(stats.activePartnerships)}
|
{formatNumber(safeStats.activePartnerships)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-warning-100 dark:bg-warning-900 rounded-full flex items-center justify-center flex-shrink-0 ml-2">
|
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-warning-100 dark:bg-warning-900 rounded-full flex items-center justify-center flex-shrink-0 ml-2">
|
||||||
@ -223,7 +375,7 @@ const Dashboard: React.FC = () => {
|
|||||||
Lead to deal success rate
|
Lead to deal success rate
|
||||||
</p>
|
</p>
|
||||||
<p className="text-lg sm:text-2xl font-bold text-secondary-900 dark:text-white truncate">
|
<p className="text-lg sm:text-2xl font-bold text-secondary-900 dark:text-white truncate">
|
||||||
{formatPercentage(stats.conversionRate)}
|
{formatPercentage(safeStats.conversionRate)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-danger-100 dark:bg-danger-900 rounded-full flex items-center justify-center flex-shrink-0 ml-2">
|
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-danger-100 dark:bg-danger-900 rounded-full flex items-center justify-center flex-shrink-0 ml-2">
|
||||||
@ -241,6 +393,15 @@ const Dashboard: React.FC = () => {
|
|||||||
|
|
||||||
{/* Additional Stats */}
|
{/* Additional Stats */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||||
|
{apiErrors.dashboardStats && (
|
||||||
|
<div className="col-span-full mb-2">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200">
|
||||||
|
📈 Additional stats using sample data
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="card p-4 sm:p-6">
|
<div className="card p-4 sm:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@ -251,8 +412,8 @@ const Dashboard: React.FC = () => {
|
|||||||
Mean value per closed deal
|
Mean value per closed deal
|
||||||
</p>
|
</p>
|
||||||
<DualCurrencyDisplay
|
<DualCurrencyDisplay
|
||||||
amount={stats.averageDealSize}
|
amount={safeStats.averageDealSize}
|
||||||
currency={stats.currency}
|
currency={safeStats.currency}
|
||||||
className="text-lg sm:text-xl truncate"
|
className="text-lg sm:text-xl truncate"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -272,7 +433,7 @@ const Dashboard: React.FC = () => {
|
|||||||
Awaiting admin review
|
Awaiting admin review
|
||||||
</p>
|
</p>
|
||||||
<p className="text-lg sm:text-xl font-bold text-secondary-900 dark:text-white truncate">
|
<p className="text-lg sm:text-xl font-bold text-secondary-900 dark:text-white truncate">
|
||||||
{formatNumber(stats.pendingApprovals)}
|
{formatNumber(safeStats.pendingApprovals)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-10 h-10 bg-warning-100 dark:bg-warning-900 rounded-full flex items-center justify-center flex-shrink-0 ml-2">
|
<div className="w-10 h-10 bg-warning-100 dark:bg-warning-900 rounded-full flex items-center justify-center flex-shrink-0 ml-2">
|
||||||
@ -280,25 +441,6 @@ const Dashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card p-4 sm:p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-xs sm:text-sm font-medium text-secondary-600 dark:text-secondary-400 truncate">
|
|
||||||
Commission Rate
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-secondary-500 dark:text-secondary-500 mb-1">
|
|
||||||
Standard partner commission
|
|
||||||
</p>
|
|
||||||
<p className="text-lg sm:text-xl font-bold text-secondary-900 dark:text-white truncate">
|
|
||||||
{formatPercentage(15)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="w-10 h-10 bg-success-100 dark:bg-success-900 rounded-full flex items-center justify-center flex-shrink-0 ml-2">
|
|
||||||
<DollarSign className="w-5 h-5 text-success-600 dark:text-success-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions & Recent Activities */}
|
{/* Quick Actions & Recent Activities */}
|
||||||
@ -306,34 +448,47 @@ const Dashboard: React.FC = () => {
|
|||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div className="lg:col-span-1">
|
<div className="lg:col-span-1">
|
||||||
<div className="card p-4 sm:p-6">
|
<div className="card p-4 sm:p-6">
|
||||||
<h3 className="text-base sm:text-lg font-semibold text-secondary-900 dark:text-white mb-3 sm:mb-4">
|
<div className="flex items-center justify-between mb-3 sm:mb-4">
|
||||||
Quick Actions
|
<h3 className="text-base sm:text-lg font-semibold text-secondary-900 dark:text-white">
|
||||||
</h3>
|
Quick Actions
|
||||||
|
</h3>
|
||||||
|
{apiErrors.quickActions && (
|
||||||
|
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200">
|
||||||
|
Sample Data
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="space-y-2 sm:space-y-3">
|
<div className="space-y-2 sm:space-y-3">
|
||||||
{quickActions.map((action) => (
|
{safeQuickActions.length > 0 ? (
|
||||||
<button
|
safeQuickActions.map((action) => (
|
||||||
key={action.id}
|
<button
|
||||||
onClick={() => handleQuickAction(action)}
|
key={action.id}
|
||||||
className={cn(
|
onClick={() => handleQuickAction(action)}
|
||||||
"w-full flex items-center p-2 sm:p-3 rounded-lg transition-colors cursor-pointer",
|
className={cn(
|
||||||
getQuickActionColor(action.color)
|
"w-full flex items-center p-2 sm:p-3 rounded-lg transition-colors cursor-pointer",
|
||||||
)}
|
getQuickActionColor(action.color)
|
||||||
>
|
)}
|
||||||
<div className="w-6 h-6 sm:w-8 sm:h-8 rounded-full bg-white/20 flex items-center justify-center mr-2 sm:mr-3 flex-shrink-0">
|
>
|
||||||
{action.icon === 'UserPlus' && <UserPlus className="w-3 h-3 sm:w-4 sm:h-4" />}
|
<div className="w-6 h-6 sm:w-8 sm:h-8 rounded-full bg-white/20 flex items-center justify-center mr-2 sm:mr-3 flex-shrink-0">
|
||||||
{action.icon === 'Package' && <Package className="w-3 h-3 sm:w-4 sm:h-4" />}
|
{action.icon === 'UserPlus' && <UserPlus className="w-3 h-3 sm:w-4 sm:h-4" />}
|
||||||
{action.icon === 'CheckCircle' && <CheckCircle className="w-3 h-3 sm:w-4 sm:h-4" />}
|
{action.icon === 'Package' && <Package className="w-3 h-3 sm:w-4 sm:h-4" />}
|
||||||
{action.icon === 'Briefcase' && <Briefcase className="w-3 h-3 sm:w-4 sm:h-4" />}
|
{action.icon === 'CheckCircle' && <CheckCircle className="w-3 h-3 sm:w-4 sm:h-4" />}
|
||||||
{action.icon === 'GraduationCap' && <GraduationCap className="w-3 h-3 sm:w-4 sm:h-4" />}
|
{action.icon === 'Briefcase' && <Briefcase className="w-3 h-3 sm:w-4 sm:h-4" />}
|
||||||
{action.icon === 'FileText' && <FileTextIcon className="w-3 h-3 sm:w-4 sm:h-4" />}
|
{action.icon === 'GraduationCap' && <GraduationCap className="w-3 h-3 sm:w-4 sm:h-4" />}
|
||||||
{action.icon === 'BarChart3' && <BarChart3 className="w-3 h-3 sm:w-4 sm:h-4" />}
|
{action.icon === 'FileText' && <FileTextIcon className="w-3 h-3 sm:w-4 sm:h-4" />}
|
||||||
</div>
|
{action.icon === 'BarChart3' && <BarChart3 className="w-3 h-3 sm:w-4 sm:h-4" />}
|
||||||
<div className="text-left min-w-0 flex-1">
|
</div>
|
||||||
<p className="font-medium text-sm sm:text-base truncate">{action.title}</p>
|
<div className="text-left min-w-0 flex-1">
|
||||||
<p className="text-xs sm:text-sm opacity-80 truncate">{action.description}</p>
|
<p className="font-medium text-sm sm:text-base truncate">{action.title}</p>
|
||||||
</div>
|
<p className="text-xs sm:text-sm opacity-80 truncate">{action.description}</p>
|
||||||
</button>
|
</div>
|
||||||
))}
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-4 text-secondary-500 dark:text-secondary-400">
|
||||||
|
<p className="text-sm">No quick actions available</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -341,37 +496,50 @@ const Dashboard: React.FC = () => {
|
|||||||
{/* Recent Activities */}
|
{/* Recent Activities */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<div className="card p-4 sm:p-6">
|
<div className="card p-4 sm:p-6">
|
||||||
<h3 className="text-base sm:text-lg font-semibold text-secondary-900 dark:text-white mb-3 sm:mb-4">
|
<div className="flex items-center justify-between mb-3 sm:mb-4">
|
||||||
Recent Activities
|
<h3 className="text-base sm:text-lg font-semibold text-secondary-900 dark:text-white">
|
||||||
</h3>
|
Recent Activities
|
||||||
|
</h3>
|
||||||
|
{apiErrors.recentActivities && (
|
||||||
|
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200">
|
||||||
|
Sample Data
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
{recentActivities.map((activity) => (
|
{safeRecentActivities.length > 0 ? (
|
||||||
<div key={activity.id} className="flex items-start space-x-2 sm:space-x-3">
|
safeRecentActivities.map((activity) => (
|
||||||
<div className="flex-shrink-0 mt-1">
|
<div key={activity.id} className="flex items-start space-x-2 sm:space-x-3">
|
||||||
{getActivityIcon(activity.type)}
|
<div className="flex-shrink-0 mt-1">
|
||||||
</div>
|
{getActivityIcon(activity.type)}
|
||||||
<div className="flex-1 min-w-0">
|
</div>
|
||||||
<p className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
<div className="flex-1 min-w-0">
|
||||||
{activity.title}
|
<p className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
||||||
</p>
|
{activity.title}
|
||||||
<p className="text-xs sm:text-sm text-secondary-600 dark:text-secondary-400 line-clamp-2">
|
</p>
|
||||||
{activity.description}
|
<p className="text-xs sm:text-sm text-secondary-600 dark:text-secondary-400 line-clamp-2">
|
||||||
</p>
|
{activity.description}
|
||||||
{activity.amount && (
|
</p>
|
||||||
<div className="mt-1">
|
{activity.amount && (
|
||||||
<DualCurrencyDisplay
|
<div className="mt-1">
|
||||||
amount={activity.amount}
|
<DualCurrencyDisplay
|
||||||
currency={activity.currency}
|
amount={activity.amount}
|
||||||
className="text-xs sm:text-sm font-medium text-success-600"
|
currency={activity.currency}
|
||||||
/>
|
className="text-xs sm:text-sm font-medium text-success-600"
|
||||||
</div>
|
/>
|
||||||
)}
|
</div>
|
||||||
<p className="text-xs text-secondary-500 dark:text-secondary-500 mt-1">
|
)}
|
||||||
{formatRelativeTime(activity.timestamp)}
|
<p className="text-xs text-secondary-500 dark:text-secondary-500 mt-1">
|
||||||
</p>
|
{formatRelativeTime(activity.timestamp)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-4 text-secondary-500 dark:text-secondary-400">
|
||||||
|
<p className="text-sm">No recent activities available</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 sm:mt-6 pt-3 sm:pt-4 border-t border-secondary-200 dark:border-secondary-700">
|
<div className="mt-4 sm:mt-6 pt-3 sm:pt-4 border-t border-secondary-200 dark:border-secondary-700">
|
||||||
<button className="text-xs sm:text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 font-medium">
|
<button className="text-xs sm:text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 font-medium">
|
||||||
@ -403,6 +571,14 @@ const Dashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sales Management Section */}
|
||||||
|
<div className="card p-4 sm:p-6">
|
||||||
|
<h3 className="text-base sm:text-lg font-semibold text-secondary-900 dark:text-white mb-3 sm:mb-4">
|
||||||
|
Reseller Sales Management
|
||||||
|
</h3>
|
||||||
|
<VendorSalesDashboard />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Draggable Feedback Component */}
|
{/* Draggable Feedback Component */}
|
||||||
{showFeedback && (
|
{showFeedback && (
|
||||||
<DraggableFeedback
|
<DraggableFeedback
|
||||||
|
|||||||
507
src/pages/KnowledgeBase.tsx
Normal file
507
src/pages/KnowledgeBase.tsx
Normal file
@ -0,0 +1,507 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||||
|
import {
|
||||||
|
fetchArticles,
|
||||||
|
fetchCategories,
|
||||||
|
fetchArticleBySlug,
|
||||||
|
submitFeedback
|
||||||
|
} from '../store/slices/knowledgeSlice';
|
||||||
|
import { KnowledgeArticle, KnowledgeCategory } from '../types/knowledge';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Eye,
|
||||||
|
Star,
|
||||||
|
Tag,
|
||||||
|
Folder,
|
||||||
|
User,
|
||||||
|
Clock,
|
||||||
|
BookOpen,
|
||||||
|
X,
|
||||||
|
ThumbsUp,
|
||||||
|
ThumbsDown,
|
||||||
|
ChevronRight,
|
||||||
|
Filter,
|
||||||
|
FileText,
|
||||||
|
Globe,
|
||||||
|
Users,
|
||||||
|
FolderOpen
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
|
import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
|
|
||||||
|
const KnowledgeBase: React.FC = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { articles, categories, loading, error } = useAppSelector((state) => state.knowledge);
|
||||||
|
const { user } = useAppSelector((state) => state.auth);
|
||||||
|
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState('');
|
||||||
|
const [selectedArticle, setSelectedArticle] = useState<KnowledgeArticle | null>(null);
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
status: 'published' as 'draft' | 'published' | 'archived',
|
||||||
|
visibility: 'all' as 'all' | 'public' | 'vendors' | 'resellers' | 'admin',
|
||||||
|
featured: undefined as boolean | undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchArticles({}));
|
||||||
|
dispatch(fetchCategories({}));
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const handleArticleClick = async (article: KnowledgeArticle) => {
|
||||||
|
try {
|
||||||
|
await dispatch(fetchArticleBySlug(article.slug)).unwrap();
|
||||||
|
setSelectedArticle(article);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching article:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFeedback = async (articleId: number, feedbackType: 'helpful' | 'not_helpful') => {
|
||||||
|
try {
|
||||||
|
await dispatch(submitFeedback({ articleId, data: { feedbackType } })).unwrap();
|
||||||
|
// Refresh articles to get updated counts
|
||||||
|
dispatch(fetchArticles({}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting feedback:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredArticles = (articles || []).filter(article => {
|
||||||
|
const matchesSearch = article.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
article.excerpt?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
article.content.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
const matchesCategory = !selectedCategory || article.category === selectedCategory;
|
||||||
|
const matchesStatus = article.status === filters.status;
|
||||||
|
|
||||||
|
// Handle visibility filtering
|
||||||
|
let matchesVisibility = true;
|
||||||
|
if (filters.visibility !== 'all') {
|
||||||
|
if (filters.visibility === 'public') {
|
||||||
|
matchesVisibility = article.visibility === 'public';
|
||||||
|
} else if (filters.visibility === 'vendors') {
|
||||||
|
matchesVisibility = article.visibility === 'public' || article.visibility === 'vendors';
|
||||||
|
} else if (filters.visibility === 'resellers') {
|
||||||
|
matchesVisibility = article.visibility === 'public' || article.visibility === 'resellers';
|
||||||
|
} else if (filters.visibility === 'admin') {
|
||||||
|
matchesVisibility = article.visibility === 'public' || article.visibility === 'admin';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Show all articles that are visible to the current user
|
||||||
|
const userRole = user?.roles?.[0]?.name || user?.role;
|
||||||
|
if (userRole?.startsWith('channel_partner_')) {
|
||||||
|
// Channel partners can see public and vendor articles
|
||||||
|
matchesVisibility = article.visibility === 'public' || article.visibility === 'vendors';
|
||||||
|
} else if (userRole?.startsWith('reseller_')) {
|
||||||
|
// Resellers can see public and reseller articles
|
||||||
|
matchesVisibility = article.visibility === 'public' || article.visibility === 'resellers';
|
||||||
|
} else if (userRole === 'system_admin') {
|
||||||
|
// Admins can see all articles
|
||||||
|
matchesVisibility = true;
|
||||||
|
} else {
|
||||||
|
// Public users can only see public articles
|
||||||
|
matchesVisibility = article.visibility === 'public';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchesFeatured = filters.featured === undefined || article.featured === filters.featured;
|
||||||
|
|
||||||
|
return matchesSearch && matchesCategory && matchesStatus && matchesVisibility && matchesFeatured;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'published': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||||
|
case 'draft': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
|
||||||
|
case 'archived': return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
|
||||||
|
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVisibilityColor = (visibility: string) => {
|
||||||
|
switch (visibility) {
|
||||||
|
case 'public': return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
|
||||||
|
case 'vendors': return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200';
|
||||||
|
case 'resellers': return 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200';
|
||||||
|
case 'admin': return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||||
|
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading || !articles) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900">
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900">
|
||||||
|
<div className="p-6 space-y-8 max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Knowledge Base
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
{(() => {
|
||||||
|
const totalArticles = articles.length;
|
||||||
|
const publicArticles = articles.filter(article => article.visibility === 'public').length;
|
||||||
|
const resellerArticles = articles.filter(article => article.visibility === 'resellers').length;
|
||||||
|
const categories = Array.from(new Set(articles.map(article => article.category)));
|
||||||
|
|
||||||
|
if (totalArticles === 0) {
|
||||||
|
return 'No articles available yet. Check back later for helpful resources and documentation.';
|
||||||
|
} else if (publicArticles === totalArticles) {
|
||||||
|
return `Browse ${totalArticles} helpful article${totalArticles > 1 ? 's' : ''} across ${categories.length} categor${categories.length > 1 ? 'ies' : 'y'}.`;
|
||||||
|
} else if (resellerArticles > 0) {
|
||||||
|
return `Browse ${totalArticles} helpful article${totalArticles > 1 ? 's' : ''} (${publicArticles} public, ${resellerArticles} reseller-only) across ${categories.length} categor${categories.length > 1 ? 'ies' : 'y'}.`;
|
||||||
|
} else {
|
||||||
|
return `Browse ${totalArticles} helpful article${totalArticles > 1 ? 's' : ''} across ${categories.length} categor${categories.length > 1 ? 'ies' : 'y'}.`;
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Dynamic Summary */}
|
||||||
|
{articles.length > 0 && (
|
||||||
|
<div className="mt-4 flex flex-wrap gap-4 text-sm">
|
||||||
|
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400">
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
<span>{articles.length} total article{articles.length > 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
{articles.filter(article => article.visibility === 'public').length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||||
|
<Globe className="w-4 h-4" />
|
||||||
|
<span>{articles.filter(article => article.visibility === 'public').length} public</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{articles.filter(article => article.visibility === 'resellers').length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-purple-600 dark:text-purple-400">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
<span>{articles.filter(article => article.visibility === 'resellers').length} reseller-only</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Array.from(new Set(articles.map(article => article.category))).length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-orange-600 dark:text-orange-400">
|
||||||
|
<FolderOpen className="w-4 h-4" />
|
||||||
|
<span>{Array.from(new Set(articles.map(article => article.category))).length} categor{Array.from(new Set(articles.map(article => article.category))).length > 1 ? 'ies' : 'y'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Filters */}
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-lg border border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="flex flex-col lg:flex-row gap-6">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400 w-6 h-6" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search articles, guides, and documentation..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-12 pr-4 py-4 text-lg border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="lg:w-48">
|
||||||
|
<select
|
||||||
|
value={selectedCategory}
|
||||||
|
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||||
|
className="w-full px-4 py-4 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
|
>
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
{(categories || []).map((category) => (
|
||||||
|
<option key={category.id} value={category.name}>
|
||||||
|
{category.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
|
className="flex items-center gap-2 px-4 py-4 bg-slate-100 hover:bg-slate-200 dark:bg-slate-700 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-300 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<Filter className="w-5 h-5" />
|
||||||
|
Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Filters */}
|
||||||
|
{showFilters && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filters.status}
|
||||||
|
onChange={(e) => setFilters({ ...filters, status: e.target.value as any })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="published">Published</option>
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="archived">Archived</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
Visibility
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filters.visibility}
|
||||||
|
onChange={(e) => setFilters({ ...filters, visibility: e.target.value as any })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="all">All Visibility</option>
|
||||||
|
<option value="public">Public</option>
|
||||||
|
<option value="vendors">Vendors</option>
|
||||||
|
<option value="resellers">Resellers</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
Featured
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filters.featured === undefined ? '' : filters.featured.toString()}
|
||||||
|
onChange={(e) => setFilters({ ...filters, featured: e.target.value === '' ? undefined : e.target.value === 'true' })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="">All Articles</option>
|
||||||
|
<option value="true">Featured Only</option>
|
||||||
|
<option value="false">Non-Featured</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results Count */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-slate-600 dark:text-slate-400">
|
||||||
|
{filteredArticles.length} article{filteredArticles.length !== 1 ? 's' : ''} found
|
||||||
|
</p>
|
||||||
|
{searchTerm && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchTerm('')}
|
||||||
|
className="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 text-sm"
|
||||||
|
>
|
||||||
|
Clear search
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Articles Grid */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||||
|
{filteredArticles.map((article) => (
|
||||||
|
<div
|
||||||
|
key={article.id}
|
||||||
|
className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-lg border border-slate-200 dark:border-slate-700 hover:shadow-xl transition-all duration-300 hover:-translate-y-1 cursor-pointer group"
|
||||||
|
onClick={() => handleArticleClick(article)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2 line-clamp-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
|
||||||
|
{article.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-slate-600 dark:text-slate-400 text-sm mb-3 line-clamp-3">
|
||||||
|
{article.excerpt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{article.featured && (
|
||||||
|
<Star className="w-5 h-5 text-yellow-500 flex-shrink-0 ml-2" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(article.status)}`}>
|
||||||
|
{article.status}
|
||||||
|
</span>
|
||||||
|
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getVisibilityColor(article.visibility)}`}>
|
||||||
|
{article.visibility}
|
||||||
|
</span>
|
||||||
|
<span className="px-2 py-1 text-xs font-medium rounded-full bg-slate-100 text-slate-800 dark:bg-slate-700 dark:text-slate-200">
|
||||||
|
{article.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
<span>
|
||||||
|
{article.author?.firstName && article.author?.lastName
|
||||||
|
? `${article.author.firstName} ${article.author.lastName}`
|
||||||
|
: article.author?.email || `Author ID: ${article.authorId}`
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>{new Date(article.createdAt).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
<span>{article.viewCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<ThumbsUp className="w-4 h-4" />
|
||||||
|
<span>{article.helpfulCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-5 h-5 text-slate-400 group-hover:text-blue-500 transition-colors" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredArticles.length === 0 && (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<BookOpen className="w-20 h-20 text-slate-400 mx-auto mb-6" />
|
||||||
|
<h3 className="text-2xl font-semibold text-slate-900 dark:text-white mb-3">No articles found</h3>
|
||||||
|
<p className="text-slate-600 dark:text-slate-400 mb-6">
|
||||||
|
{searchTerm ? `No articles match "${searchTerm}"` : 'Try adjusting your search or filters'}
|
||||||
|
</p>
|
||||||
|
{searchTerm && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchTerm('')}
|
||||||
|
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Clear Search
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Article Detail Modal */}
|
||||||
|
{selectedArticle && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl max-w-6xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="p-6 border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">
|
||||||
|
{selectedArticle.title}
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(selectedArticle.status)}`}>
|
||||||
|
{selectedArticle.status}
|
||||||
|
</span>
|
||||||
|
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getVisibilityColor(selectedArticle.visibility)}`}>
|
||||||
|
{selectedArticle.visibility}
|
||||||
|
</span>
|
||||||
|
<span className="px-2 py-1 text-xs font-medium rounded-full bg-slate-100 text-slate-800 dark:bg-slate-700 dark:text-slate-200">
|
||||||
|
{selectedArticle.category}
|
||||||
|
</span>
|
||||||
|
{selectedArticle.featured && (
|
||||||
|
<span className="px-2 py-1 text-xs font-medium rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
|
||||||
|
Featured
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedArticle(null)}
|
||||||
|
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 ml-4"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{selectedArticle.excerpt && (
|
||||||
|
<div className="mb-8 p-6 bg-slate-50 dark:bg-slate-700 rounded-xl">
|
||||||
|
<p className="text-lg text-slate-700 dark:text-slate-300 italic leading-relaxed">
|
||||||
|
{selectedArticle.excerpt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="prose prose-slate dark:prose-invert max-w-none prose-lg">
|
||||||
|
<div
|
||||||
|
className="markdown-content"
|
||||||
|
dangerouslySetInnerHTML={{ __html: selectedArticle.content }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Article Meta */}
|
||||||
|
<div className="mt-8 pt-6 border-t border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="flex items-center justify-between text-sm text-slate-500 dark:text-slate-400 mb-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
<span>Author ID: {selectedArticle.authorId}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>Published {new Date(selectedArticle.createdAt).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
<span>{selectedArticle.viewCount} views</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feedback Section */}
|
||||||
|
<div className="bg-slate-50 dark:bg-slate-700 rounded-xl p-4">
|
||||||
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">
|
||||||
|
Was this article helpful?
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleFeedback(selectedArticle.id, 'helpful')}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-green-100 text-green-700 rounded-lg hover:bg-green-200 transition-colors dark:bg-green-900 dark:text-green-200 dark:hover:bg-green-800"
|
||||||
|
>
|
||||||
|
<ThumbsUp />
|
||||||
|
Yes ({selectedArticle.helpfulCount})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleFeedback(selectedArticle.id, 'not_helpful')}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors dark:bg-red-900 dark:text-red-200 dark:hover:bg-red-800"
|
||||||
|
>
|
||||||
|
<ThumbsDown />
|
||||||
|
No ({selectedArticle.notHelpfulCount})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Display */}
|
||||||
|
{error && (
|
||||||
|
<div className="fixed bottom-6 right-6 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-lg shadow-lg dark:bg-red-900 dark:border-red-700 dark:text-red-200">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default KnowledgeBase;
|
||||||
@ -84,8 +84,10 @@ const Login: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate to appropriate dashboard
|
// Wait a bit for Redux state to update, then navigate
|
||||||
navigate(redirectPath, { replace: true });
|
setTimeout(() => {
|
||||||
|
navigate(redirectPath, { replace: true });
|
||||||
|
}, 100);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.message || 'Invalid email or password. Please try again.';
|
const errorMessage = err.message || 'Invalid email or password. Please try again.';
|
||||||
|
|
||||||
|
|||||||
@ -102,72 +102,78 @@ const ProductManagement: React.FC = () => {
|
|||||||
setShowProductForm(true);
|
setShowProductForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleViewProduct = (product: Product) => {
|
||||||
|
// For now, just show the product form in view mode
|
||||||
|
setSelectedProduct(product);
|
||||||
|
setShowProductForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateProduct = () => {
|
const handleCreateProduct = () => {
|
||||||
setSelectedProduct(null);
|
setSelectedProduct(null);
|
||||||
setShowProductForm(true);
|
setShowProductForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProductFormSuccess = () => {
|
const getCategoryColor = (category: string) => {
|
||||||
dispatch(fetchProducts(filters));
|
switch (category) {
|
||||||
|
case 'cloud_storage':
|
||||||
|
return 'bg-red-100 dark:bg-red-900 text-red-600 dark:text-red-400';
|
||||||
|
case 'cloud_computing':
|
||||||
|
return 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400';
|
||||||
|
case 'cybersecurity':
|
||||||
|
return 'bg-orange-100 dark:bg-orange-900 text-orange-600 dark:text-orange-400';
|
||||||
|
case 'data_analytics':
|
||||||
|
return 'bg-purple-100 dark:bg-purple-900 text-purple-600 dark:text-purple-400';
|
||||||
|
case 'ai_ml':
|
||||||
|
return 'bg-indigo-100 dark:bg-indigo-900 text-indigo-600 dark:text-indigo-400';
|
||||||
|
case 'iot':
|
||||||
|
return 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400';
|
||||||
|
case 'blockchain':
|
||||||
|
return 'bg-yellow-100 dark:bg-yellow-900 text-yellow-600 dark:text-yellow-400';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 dark:bg-gray-900 text-gray-600 dark:text-gray-400';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCategoryIcon = (category: string) => {
|
const getCategoryIcon = (category: string) => {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
case 'cloud_storage':
|
case 'cloud_storage':
|
||||||
return <Database className="h-5 w-5" />;
|
return <Database className="w-6 h-6" />;
|
||||||
case 'cloud_computing':
|
case 'cloud_computing':
|
||||||
return <Cpu className="h-5 w-5" />;
|
return <Cpu className="w-6 h-6" />;
|
||||||
case 'cybersecurity':
|
case 'cybersecurity':
|
||||||
return <Shield className="h-5 w-5" />;
|
return <Shield className="w-6 h-6" />;
|
||||||
case 'data_analytics':
|
case 'data_analytics':
|
||||||
return <BarChart3 className="h-5 w-5" />;
|
return <BarChart3 className="w-6 h-6" />;
|
||||||
case 'ai_ml':
|
case 'ai_ml':
|
||||||
return <Brain className="h-5 w-5" />;
|
return <Brain className="w-6 h-6" />;
|
||||||
case 'iot':
|
case 'iot':
|
||||||
return <Wifi className="h-5 w-5" />;
|
return <Wifi className="w-6 h-6" />;
|
||||||
case 'blockchain':
|
case 'blockchain':
|
||||||
return <Link className="h-5 w-5" />;
|
return <Link className="w-6 h-6" />;
|
||||||
default:
|
default:
|
||||||
return <Package className="h-5 w-5" />;
|
return <Package className="w-6 h-6" />;
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCategoryColor = (category: string) => {
|
|
||||||
switch (category) {
|
|
||||||
case 'cloud_storage':
|
|
||||||
return 'text-blue-600 bg-blue-100 dark:bg-blue-900';
|
|
||||||
case 'cloud_computing':
|
|
||||||
return 'text-purple-600 bg-purple-100 dark:bg-purple-900';
|
|
||||||
case 'cybersecurity':
|
|
||||||
return 'text-red-600 bg-red-100 dark:bg-red-900';
|
|
||||||
case 'data_analytics':
|
|
||||||
return 'text-green-600 bg-green-100 dark:bg-green-900';
|
|
||||||
case 'ai_ml':
|
|
||||||
return 'text-orange-600 bg-orange-100 dark:bg-orange-900';
|
|
||||||
case 'iot':
|
|
||||||
return 'text-indigo-600 bg-indigo-100 dark:bg-indigo-900';
|
|
||||||
case 'blockchain':
|
|
||||||
return 'text-yellow-600 bg-yellow-100 dark:bg-yellow-900';
|
|
||||||
default:
|
|
||||||
return 'text-gray-600 bg-gray-100 dark:bg-gray-900';
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'active':
|
case 'active':
|
||||||
return 'text-green-600 bg-green-100 dark:bg-green-900';
|
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||||
case 'inactive':
|
case 'inactive':
|
||||||
return 'text-red-600 bg-red-100 dark:bg-red-900';
|
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||||
case 'draft':
|
case 'draft':
|
||||||
return 'text-yellow-600 bg-yellow-100 dark:bg-yellow-900';
|
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
|
||||||
case 'discontinued':
|
case 'discontinued':
|
||||||
return 'text-gray-600 bg-gray-100 dark:bg-gray-900';
|
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
|
||||||
default:
|
default:
|
||||||
return 'text-gray-600 bg-gray-100 dark:bg-gray-900';
|
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleProductFormSuccess = () => {
|
||||||
|
dispatch(fetchProducts(filters));
|
||||||
|
};
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
@ -181,47 +187,103 @@ const ProductManagement: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 p-6">
|
||||||
<div>
|
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4">
|
||||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">Product Management</h1>
|
<div>
|
||||||
<p className="text-slate-600 dark:text-slate-400">Manage your product catalog and pricing</p>
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">Product & Pricing Management</h1>
|
||||||
</div>
|
<p className="text-slate-600 dark:text-slate-400 mt-1">Manage your product catalog and customize pricing strategies</p>
|
||||||
<div className="flex items-center gap-3">
|
</div>
|
||||||
{/* View Mode Toggle */}
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-1">
|
<div className="flex items-center gap-2 p-2 bg-slate-100 dark:bg-slate-700 rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
className={`p-2 rounded-md transition-colors ${
|
||||||
|
viewMode === 'grid'
|
||||||
|
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
|
||||||
|
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Grid3X3 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
className={`p-2 rounded-md transition-colors ${
|
||||||
|
viewMode === 'list'
|
||||||
|
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
|
||||||
|
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('grid')}
|
onClick={handleCreateProduct}
|
||||||
className={`p-2 rounded-md transition-colors ${
|
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
|
||||||
viewMode === 'grid'
|
|
||||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
|
|
||||||
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Grid3X3 className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
</button>
|
+ Add Product
|
||||||
<button
|
|
||||||
onClick={() => setViewMode('list')}
|
|
||||||
className={`p-2 rounded-md transition-colors ${
|
|
||||||
viewMode === 'list'
|
|
||||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
|
|
||||||
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<List className="h-4 w-4" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={handleCreateProduct}
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Add Product
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters and Search */}
|
{/* Summary Cards */}
|
||||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 p-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Total Products</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 dark:text-white">{products.length}</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">Available in catalog</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
|
||||||
|
<Package className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Avg. Margin</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 dark:text-white">42%</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">Average profit margin</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-green-100 dark:bg-green-900 rounded-lg flex items-center justify-center">
|
||||||
|
<DollarSign className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Active Products</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 dark:text-white">{products.filter(p => p.status === 'active').length}</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">Currently available</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
|
||||||
|
<BarChart3 className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Categories</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 dark:text-white">4</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">Product categories</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-orange-100 dark:bg-orange-900 rounded-lg flex items-center justify-center">
|
||||||
|
<Tag className="w-6 h-6 text-orange-600 dark:text-orange-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Filter */}
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 p-4 mb-6">
|
||||||
<div className="flex flex-col lg:flex-row gap-4">
|
<div className="flex flex-col lg:flex-row gap-4">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@ -229,7 +291,7 @@ const ProductManagement: React.FC = () => {
|
|||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-slate-400" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search products..."
|
placeholder="Search products by name or description..."
|
||||||
value={filters.search}
|
value={filters.search}
|
||||||
onChange={(e) => handleFilterChange('search', e.target.value)}
|
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
@ -256,18 +318,17 @@ const ProductManagement: React.FC = () => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Filter */}
|
{/* Sort Filter */}
|
||||||
<div className="lg:w-48">
|
<div className="lg:w-48">
|
||||||
<select
|
<select
|
||||||
value={filters.status}
|
value={filters.sortBy}
|
||||||
onChange={(e) => handleFilterChange('status', e.target.value)}
|
onChange={(e) => handleSort(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
<option value="">All Status</option>
|
<option value="name">Name A-Z</option>
|
||||||
<option value="active">Active</option>
|
<option value="price">Price Low-High</option>
|
||||||
<option value="inactive">Inactive</option>
|
<option value="createdAt">Newest First</option>
|
||||||
<option value="draft">Draft</option>
|
<option value="status">Status</option>
|
||||||
<option value="discontinued">Discontinued</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -289,63 +350,85 @@ const ProductManagement: React.FC = () => {
|
|||||||
) : viewMode === 'grid' ? (
|
) : viewMode === 'grid' ? (
|
||||||
/* Grid View */
|
/* Grid View */
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{products.map((product) => (
|
{products.map((product) => (
|
||||||
<div key={product.id} className="bg-white dark:bg-slate-700 rounded-lg border border-slate-200 dark:border-slate-600 p-4 hover:shadow-lg transition-shadow">
|
<div key={product.id} className="bg-white dark:bg-slate-700 rounded-lg border border-slate-200 dark:border-slate-600 p-6 hover:shadow-lg transition-shadow relative">
|
||||||
{/* Product Header */}
|
{/* Active Status Badge */}
|
||||||
<div className="flex items-start justify-between mb-3">
|
{product.status === 'active' && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="absolute top-4 right-4">
|
||||||
<div className={`p-2 rounded-lg ${getCategoryColor(product.category)}`}>
|
<span className="inline-flex px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 rounded-full">
|
||||||
{getCategoryIcon(product.category)}
|
active
|
||||||
</div>
|
</span>
|
||||||
<div>
|
|
||||||
<h3 className="font-medium text-slate-900 dark:text-white line-clamp-1">{product.name}</h3>
|
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400">{product.sku}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
)}
|
||||||
<button
|
|
||||||
onClick={() => handleEditProduct(product)}
|
{/* Product Header */}
|
||||||
className="p-1 text-slate-400 hover:text-blue-600 dark:hover:text-blue-400"
|
<div className="flex items-start gap-4 mb-4">
|
||||||
>
|
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${getCategoryColor(product.category)}`}>
|
||||||
<Edit className="h-4 w-4" />
|
{getCategoryIcon(product.category)}
|
||||||
</button>
|
</div>
|
||||||
<button
|
<div className="flex-1 min-w-0">
|
||||||
onClick={() => handleDeleteProduct(product.id)}
|
<h3 className="font-semibold text-slate-900 dark:text-white text-lg mb-1">{product.name}</h3>
|
||||||
className="p-1 text-slate-400 hover:text-red-600 dark:hover:text-red-400"
|
<p className="text-sm text-slate-600 dark:text-slate-400 capitalize">{product.category?.replace('_', ' ')}</p>
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Product Details */}
|
{/* Product Description */}
|
||||||
<div className="space-y-2">
|
<p className="text-sm text-slate-600 dark:text-slate-400 mb-4 line-clamp-2">
|
||||||
|
{product.description || 'No description available'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Pricing Information */}
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-slate-600 dark:text-slate-400">Price:</span>
|
<span className="text-sm text-slate-600 dark:text-slate-400">Base Price:</span>
|
||||||
<span className="font-medium text-slate-900 dark:text-white">
|
<div className="text-right">
|
||||||
${(Number(product.price) || 0).toFixed(2)}
|
<span className="font-medium text-slate-900 dark:text-white">${(Number(product.price) || 0).toFixed(2)}</span>
|
||||||
</span>
|
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{((Number(product.price) || 0) * 83).toFixed(0)} rs
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-slate-600 dark:text-slate-400">Commission:</span>
|
<span className="text-sm text-slate-600 dark:text-slate-400">Your Price:</span>
|
||||||
<span className="font-medium text-slate-900 dark:text-white">
|
<div className="text-right">
|
||||||
{product.commissionRate}%
|
<span className="font-semibold text-blue-600 dark:text-blue-400 text-lg">
|
||||||
</span>
|
${((Number(product.price) || 0) * 1.3).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{(((Number(product.price) || 0) * 1.3) * 83).toFixed(0)} rs
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-slate-600 dark:text-slate-400">Stock:</span>
|
<span className="text-sm text-slate-600 dark:text-slate-400">Profit Margin:</span>
|
||||||
|
<span className="font-medium text-green-600 dark:text-green-400">30%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">Available Stock:</span>
|
||||||
<span className="font-medium text-slate-900 dark:text-white">
|
<span className="font-medium text-slate-900 dark:text-white">
|
||||||
{product.stockQuantity === -1 ? 'Unlimited' : product.stockQuantity}
|
{product.stockQuantity === -1 ? 'Unlimited' : product.stockQuantity}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Badge */}
|
{/* Action Buttons */}
|
||||||
<div className="mt-3 pt-3 border-t border-slate-200 dark:border-slate-600">
|
<div className="flex gap-2 pt-4 border-t border-slate-200 dark:border-slate-600">
|
||||||
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(product.status)}`}>
|
<button
|
||||||
{product.status}
|
onClick={() => handleEditProduct(product)}
|
||||||
</span>
|
className="flex-1 px-3 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 border border-blue-600 dark:border-blue-400 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
|
||||||
|
>
|
||||||
|
Edit Pricing
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewProduct(product)}
|
||||||
|
className="flex-1 px-3 py-2 text-sm font-medium text-slate-600 dark:text-slate-400 border border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
|
||||||
|
>
|
||||||
|
View Details
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -367,7 +450,7 @@ const ProductManagement: React.FC = () => {
|
|||||||
Price
|
Price
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||||
Commission
|
Profit Margin
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||||
Status
|
Status
|
||||||
@ -409,13 +492,16 @@ const ProductManagement: React.FC = () => {
|
|||||||
${(Number(product.price) || 0).toFixed(2)}
|
${(Number(product.price) || 0).toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
{product.currency}
|
{((Number(product.price) || 0) * 83).toFixed(0)} rs
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm text-slate-900 dark:text-white">
|
<div className="text-sm text-slate-900 dark:text-white">
|
||||||
{product.commissionRate}%
|
{product.commissionRate}%
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Profit Margin
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(product.status)}`}>
|
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(product.status)}`}>
|
||||||
|
|||||||
@ -2,22 +2,14 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { useAppSelector } from '../../store/hooks';
|
import { useAppSelector } from '../../store/hooks';
|
||||||
import { formatCurrency, formatNumber, formatDate } from '../../utils/format';
|
import { formatCurrency, formatNumber, formatDate } from '../../utils/format';
|
||||||
import Modal from '../../components/Modal';
|
import Modal from '../../components/Modal';
|
||||||
import AddResellerForm from '../../components/forms/AddResellerForm';
|
|
||||||
import EditResellerForm from '../../components/forms/EditResellerForm';
|
|
||||||
import MailComposeForm from '../../components/forms/MailComposeForm';
|
|
||||||
import MoreOptionsDropdown from '../../components/MoreOptionsDropdown';
|
|
||||||
import DetailView from '../../components/DetailView';
|
import DetailView from '../../components/DetailView';
|
||||||
import DraggableFeedback from '../../components/DraggableFeedback';
|
import DraggableFeedback from '../../components/DraggableFeedback';
|
||||||
import apiService from '../../services/api';
|
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
Filter,
|
Filter,
|
||||||
Plus,
|
Plus,
|
||||||
MoreVertical,
|
|
||||||
Eye,
|
Eye,
|
||||||
Edit,
|
|
||||||
Download,
|
Download,
|
||||||
Mail,
|
|
||||||
MapPin,
|
MapPin,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Users,
|
Users,
|
||||||
@ -27,7 +19,6 @@ import {
|
|||||||
List,
|
List,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
XCircle,
|
XCircle,
|
||||||
Clock,
|
|
||||||
MessageCircle
|
MessageCircle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '../../utils/cn';
|
import { cn } from '../../utils/cn';
|
||||||
@ -63,33 +54,17 @@ interface Reseller {
|
|||||||
emailVerified?: boolean;
|
emailVerified?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VendorDashboardStats {
|
|
||||||
totalResellers: number;
|
|
||||||
activeResellers: number;
|
|
||||||
pendingResellers: number;
|
|
||||||
totalRevenue: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ResellerRequestsPage: React.FC = () => {
|
const ResellerRequestsPage: React.FC = () => {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('all');
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
const [tierFilter, setTierFilter] = useState('all');
|
const [tierFilter, setTierFilter] = useState('all');
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
|
||||||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
||||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
|
||||||
const [isMailModalOpen, setIsMailModalOpen] = useState(false);
|
|
||||||
const [isSuccessModalOpen, setIsSuccessModalOpen] = useState(false);
|
|
||||||
const [newResellerData, setNewResellerData] = useState<any>(null);
|
|
||||||
const [selectedReseller, setSelectedReseller] = useState<Reseller | null>(null);
|
const [selectedReseller, setSelectedReseller] = useState<Reseller | null>(null);
|
||||||
const [showMoreOptions, setShowMoreOptions] = useState<string | null>(null);
|
|
||||||
const [resellers, setResellers] = useState<Reseller[]>([]);
|
const [resellers, setResellers] = useState<Reseller[]>([]);
|
||||||
const [stats, setStats] = useState<VendorDashboardStats>({
|
|
||||||
totalResellers: 0,
|
|
||||||
activeResellers: 0,
|
|
||||||
pendingResellers: 0,
|
|
||||||
totalRevenue: 0
|
|
||||||
});
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [pagination, setPagination] = useState({
|
const [pagination, setPagination] = useState({
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
@ -103,7 +78,6 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
const { user } = useAppSelector((state) => state.auth);
|
const { user } = useAppSelector((state) => state.auth);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDashboardStats();
|
|
||||||
fetchResellers();
|
fetchResellers();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -111,23 +85,7 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
document.title = 'Reseller Requests - Cloudtopiaa';
|
document.title = 'Reseller Requests - Cloudtopiaa';
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchDashboardStats = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/dashboard/stats`, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
setStats(data.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching dashboard stats:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchResellers = async (page = 1) => {
|
const fetchResellers = async (page = 1) => {
|
||||||
try {
|
try {
|
||||||
@ -139,17 +97,22 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
...(searchTerm && { search: searchTerm })
|
...(searchTerm && { search: searchTerm })
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/resellers?${params}`, {
|
const endpoint = `${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/vendors/resellers?${params}`;
|
||||||
|
console.log('Fetching resellers from:', endpoint);
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
console.log('API Response:', data);
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
// Map the API response to match our interface
|
// Map the API response to match our interface
|
||||||
const mappedResellers = data.data.map((reseller: any) => ({
|
const mappedResellers = (data.data?.resellers || data.resellers || []).map((reseller: any) => ({
|
||||||
id: reseller.id,
|
id: reseller.id,
|
||||||
firstName: reseller.firstName || '',
|
firstName: reseller.firstName || '',
|
||||||
lastName: reseller.lastName || '',
|
lastName: reseller.lastName || '',
|
||||||
@ -178,7 +141,10 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
resellerId: reseller.resellerId,
|
resellerId: reseller.resellerId,
|
||||||
emailVerified: reseller.emailVerified
|
emailVerified: reseller.emailVerified
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
console.log('Mapped resellers:', mappedResellers);
|
||||||
setResellers(mappedResellers);
|
setResellers(mappedResellers);
|
||||||
|
|
||||||
// Set default pagination since backend doesn't return pagination data
|
// Set default pagination since backend doesn't return pagination data
|
||||||
setPagination({
|
setPagination({
|
||||||
currentPage: page,
|
currentPage: page,
|
||||||
@ -186,9 +152,12 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
totalItems: mappedResellers.length,
|
totalItems: mappedResellers.length,
|
||||||
itemsPerPage: 10
|
itemsPerPage: 10
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
console.error('API returned success: false:', data);
|
||||||
|
toast.error(data.message || 'Failed to fetch resellers');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching reseller applications:', error);
|
console.error('Error fetching reseller applications:', error);
|
||||||
toast.error('Failed to fetch reseller applications');
|
toast.error('Failed to fetch reseller applications');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -197,8 +166,8 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleApproveReseller = async (resellerId: string) => {
|
const handleApproveReseller = async (resellerId: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/resellers/${resellerId}/approve`, {
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/vendors/resellers/${resellerId}/approve`, {
|
||||||
method: 'POST',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@ -208,7 +177,6 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
toast.success('Application approved successfully');
|
toast.success('Application approved successfully');
|
||||||
fetchResellers();
|
fetchResellers();
|
||||||
fetchDashboardStats();
|
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
toast.error(errorData.message || 'Failed to approve application');
|
toast.error(errorData.message || 'Failed to approve application');
|
||||||
@ -221,8 +189,8 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleRejectReseller = async (resellerId: string, reason: string) => {
|
const handleRejectReseller = async (resellerId: string, reason: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/resellers/${resellerId}/reject`, {
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/vendors/resellers/${resellerId}/reject`, {
|
||||||
method: 'POST',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@ -233,7 +201,6 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
toast.success('Application rejected successfully');
|
toast.success('Application rejected successfully');
|
||||||
fetchResellers();
|
fetchResellers();
|
||||||
fetchDashboardStats();
|
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
toast.error(errorData.message || 'Failed to reject application');
|
toast.error(errorData.message || 'Failed to reject application');
|
||||||
@ -245,11 +212,12 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const filteredResellers = resellers.filter(reseller => {
|
const filteredResellers = resellers.filter(reseller => {
|
||||||
// Only show pending and rejected applications, not approved ones
|
// Only show pending resellers on this page (Reseller Requests)
|
||||||
if (reseller.status === 'active' || reseller.status === 'approved') {
|
if (reseller.status !== 'pending') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply search and filter criteria
|
||||||
const matchesSearch = reseller.firstName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const matchesSearch = reseller.firstName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
reseller.lastName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
reseller.lastName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
reseller.email?.toLowerCase().includes(searchTerm.toLowerCase());
|
reseller.email?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
@ -287,74 +255,15 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddReseller = async (data: any) => {
|
|
||||||
try {
|
|
||||||
const response = await apiService.createReseller(data);
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
// Store the new reseller data and show success modal
|
|
||||||
setNewResellerData(response.data);
|
|
||||||
setIsSuccessModalOpen(true);
|
|
||||||
setIsAddModalOpen(false);
|
|
||||||
|
|
||||||
// Show success toast
|
|
||||||
toast.success('Reseller application added successfully! Check the details below.');
|
|
||||||
|
|
||||||
// Refresh the reseller applications list and stats
|
|
||||||
fetchResellers();
|
|
||||||
fetchDashboardStats();
|
|
||||||
} else {
|
|
||||||
toast.error(response.message || 'Failed to add reseller');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error adding reseller:', error);
|
|
||||||
toast.error('Failed to add reseller. Please try again.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewReseller = (reseller: Reseller) => {
|
const handleViewReseller = (reseller: Reseller) => {
|
||||||
setSelectedReseller(reseller);
|
setSelectedReseller(reseller);
|
||||||
setIsDetailModalOpen(true);
|
setIsDetailModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditReseller = (reseller: Reseller) => {
|
|
||||||
setSelectedReseller(reseller);
|
|
||||||
setIsEditModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMailReseller = (reseller: Reseller) => {
|
|
||||||
setSelectedReseller(reseller);
|
|
||||||
setIsMailModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMoreOptions = (reseller: Reseller) => {
|
|
||||||
setShowMoreOptions(showMoreOptions === reseller.id ? null : reseller.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewPerformance = (reseller: Reseller) => {
|
|
||||||
console.log('View performance for:', reseller);
|
|
||||||
setShowMoreOptions(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownloadReport = (reseller: Reseller) => {
|
|
||||||
console.log('Download report for:', reseller);
|
|
||||||
setShowMoreOptions(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSendNotification = (reseller: Reseller) => {
|
|
||||||
console.log('Send notification to:', reseller);
|
|
||||||
setShowMoreOptions(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChangeTier = (reseller: Reseller) => {
|
|
||||||
console.log('Change tier for:', reseller);
|
|
||||||
setShowMoreOptions(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeactivate = async (reseller: Reseller) => {
|
const handleDeactivate = async (reseller: Reseller) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/resellers/${reseller.id}/deactivate`, {
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/vendors/resellers/${reseller.id}/deactivate`, {
|
||||||
method: 'POST',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@ -364,7 +273,6 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
toast.success('Reseller deactivated successfully');
|
toast.success('Reseller deactivated successfully');
|
||||||
fetchResellers();
|
fetchResellers();
|
||||||
fetchDashboardStats();
|
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
toast.error(errorData.message || 'Failed to deactivate reseller');
|
toast.error(errorData.message || 'Failed to deactivate reseller');
|
||||||
@ -373,25 +281,9 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
console.error('Error deactivating reseller:', error);
|
console.error('Error deactivating reseller:', error);
|
||||||
toast.error('Failed to deactivate reseller');
|
toast.error('Failed to deactivate reseller');
|
||||||
}
|
}
|
||||||
setShowMoreOptions(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (reseller: Reseller) => {
|
|
||||||
console.log('Delete:', reseller);
|
|
||||||
setShowMoreOptions(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSendMail = (mailData: any) => {
|
|
||||||
console.log('Send mail:', mailData);
|
|
||||||
setIsMailModalOpen(false);
|
|
||||||
toast.success('Email sent successfully');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateReseller = (updatedData: any) => {
|
|
||||||
console.log('Updating reseller:', updatedData);
|
|
||||||
setIsEditModalOpen(false);
|
|
||||||
toast.success('Reseller updated successfully');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
fetchResellers(1);
|
fetchResellers(1);
|
||||||
@ -401,6 +293,36 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
fetchResellers(page);
|
fetchResellers(page);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// CSV Export functions
|
||||||
|
const generateCSV = (data: Reseller[]) => {
|
||||||
|
const headers = ['Name', 'Email', 'Company', 'Status', 'Tier', 'Phone', 'Created Date'];
|
||||||
|
const rows = data.map(reseller => [
|
||||||
|
`${reseller.firstName} ${reseller.lastName}`,
|
||||||
|
reseller.email,
|
||||||
|
reseller.company || 'N/A',
|
||||||
|
reseller.status,
|
||||||
|
reseller.tier || 'N/A',
|
||||||
|
reseller.phone || 'N/A',
|
||||||
|
reseller.createdAt ? new Date(reseller.createdAt).toLocaleDateString() : 'N/A'
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [headers, ...rows]
|
||||||
|
.map(row => row.map(cell => `"${cell}"`).join(','))
|
||||||
|
.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadCSV = (content: string, filename: string) => {
|
||||||
|
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
link.setAttribute('href', url);
|
||||||
|
link.setAttribute('download', filename);
|
||||||
|
link.style.visibility = 'hidden';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -410,7 +332,7 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
Reseller Requests
|
Reseller Requests
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400 text-lg">
|
<p className="text-gray-600 dark:text-gray-400 text-lg">
|
||||||
Review and manage pending and rejected reseller applications
|
Review and manage pending reseller applications
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@ -437,13 +359,7 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
<List className="h-4 w-4" />
|
<List className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => setIsAddModalOpen(true)}
|
|
||||||
className="btn btn-primary btn-lg shadow-lg hover:shadow-xl transform hover:scale-105 transition-all duration-300"
|
|
||||||
>
|
|
||||||
<Plus className="w-5 h-5 mr-2" />
|
|
||||||
Add Reseller Request
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@ -457,14 +373,14 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
Pending Requests
|
Pending Requests
|
||||||
</p>
|
</p>
|
||||||
<p className="text-3xl font-bold text-gray-900 dark:text-white">
|
<p className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
{stats.pendingResellers}
|
{resellers.filter(r => r.status === 'pending').length}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
<p className="text-xs text-gray-500 dark:text-gray-500">
|
||||||
Awaiting approval
|
Awaiting approval
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-14 h-14 bg-gradient-to-br from-warning-500 to-warning-600 rounded-xl flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform duration-300">
|
<div className="w-14 h-14 bg-gradient-to-br from-warning-500 to-warning-600 rounded-xl flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform duration-300">
|
||||||
<Clock className="w-7 h-7 text-white" />
|
<div className="w-7 h-7 text-white text-2xl font-bold">⏳</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -473,13 +389,13 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
<p className="text-sm font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
||||||
Total Requests
|
Total Resellers
|
||||||
</p>
|
</p>
|
||||||
<p className="text-3xl font-bold text-gray-900 dark:text-white">
|
<p className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
{stats.pendingResellers + (stats.totalResellers - stats.activeResellers - stats.pendingResellers)}
|
{resellers.length}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
<p className="text-xs text-gray-500 dark:text-gray-500">
|
||||||
Pending + Rejected applications
|
All resellers
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-14 h-14 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform duration-300">
|
<div className="w-14 h-14 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform duration-300">
|
||||||
@ -495,7 +411,7 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
Rejected Applications
|
Rejected Applications
|
||||||
</p>
|
</p>
|
||||||
<p className="text-3xl font-bold text-gray-900 dark:text-white">
|
<p className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
{stats.totalResellers - stats.activeResellers - stats.pendingResellers}
|
{resellers.filter(r => r.status === 'rejected').length}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
<p className="text-xs text-gray-500 dark:text-gray-500">
|
||||||
Declined applications
|
Declined applications
|
||||||
@ -511,13 +427,13 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
<p className="text-sm font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
||||||
Approved This Month
|
Active Resellers
|
||||||
</p>
|
</p>
|
||||||
<p className="text-3xl font-bold text-gray-900 dark:text-white">
|
<p className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
{stats.activeResellers}
|
{resellers.filter(r => r.status === 'active' || r.status === 'approved').length}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
<p className="text-xs text-gray-500 dark:text-gray-500">
|
||||||
Recently approved partners
|
Approved partners
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-14 h-14 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform duration-300">
|
<div className="w-14 h-14 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform duration-300">
|
||||||
@ -535,11 +451,11 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search applications by name or email..."
|
placeholder="Search resellers by name or email..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
className="input pl-10 w-full"
|
className="w-full pl-12 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -550,10 +466,8 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
className="input min-w-[140px]"
|
className="input min-w-[140px]"
|
||||||
>
|
>
|
||||||
<option value="all">All Status</option>
|
<option value="all">All Pending</option>
|
||||||
<option value="pending">Pending</option>
|
<option value="pending">Pending</option>
|
||||||
<option value="rejected">Rejected</option>
|
|
||||||
<option value="inactive">Inactive</option>
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
@ -575,30 +489,34 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reseller Applications Display */}
|
{/* Resellers List */}
|
||||||
<div className="card overflow-hidden">
|
<div className="card">
|
||||||
<div className="p-6 border-b border-gray-200/60 dark:border-gray-700/60 bg-gray-50/30 dark:bg-gray-800/30">
|
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<div className="flex items-center justify-between">
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">
|
Pending Requests ({filteredResellers.length})
|
||||||
Pending & Rejected Applications
|
</h2>
|
||||||
</h3>
|
<button
|
||||||
<button className="btn btn-outline btn-sm">
|
onClick={() => {
|
||||||
<Download className="w-4 h-4 mr-2" />
|
const csvContent = generateCSV(filteredResellers);
|
||||||
Export
|
downloadCSV(csvContent, 'pending-resellers.csv');
|
||||||
</button>
|
}}
|
||||||
</div>
|
className="btn btn-secondary btn-sm"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||||
<p className="mt-2 text-gray-600 dark:text-gray-400">Loading applications...</p>
|
<p className="mt-2 text-gray-600 dark:text-gray-400">Loading pending requests...</p>
|
||||||
</div>
|
</div>
|
||||||
) : filteredResellers.length === 0 ? (
|
) : filteredResellers.length === 0 ? (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
<Users className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
<Users className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">No applications found</h3>
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">No pending requests</h3>
|
||||||
<p className="text-gray-600 dark:text-gray-400">Get started by adding your first reseller application.</p>
|
<p className="text-gray-600 dark:text-gray-400">All reseller applications have been processed.</p>
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === 'grid' ? (
|
) : viewMode === 'grid' ? (
|
||||||
/* Grid View */
|
/* Grid View */
|
||||||
@ -620,7 +538,7 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{reseller.status === 'pending' && (
|
{(reseller.status === 'pending' || reseller.status === 'inactive') && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleApproveReseller(reseller.id)}
|
onClick={() => handleApproveReseller(reseller.id)}
|
||||||
@ -638,24 +556,7 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{reseller.status === 'active' && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => handleRejectReseller(reseller.id, 'Deactivated by vendor')}
|
|
||||||
className="p-1.5 bg-orange-100 hover:bg-orange-200 dark:bg-orange-900 dark:hover:bg-orange-800 text-orange-700 dark:text-orange-300 rounded-md transition-colors"
|
|
||||||
title="Deactivate Reseller"
|
|
||||||
>
|
|
||||||
<XCircle className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeactivate(reseller)}
|
|
||||||
className="p-1.5 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-md transition-colors"
|
|
||||||
title="Make Inactive"
|
|
||||||
>
|
|
||||||
<Clock className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleViewReseller(reseller)}
|
onClick={() => handleViewReseller(reseller)}
|
||||||
className="p-1.5 bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800 text-blue-700 dark:text-blue-300 rounded-md transition-colors"
|
className="p-1.5 bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800 text-blue-700 dark:text-blue-300 rounded-md transition-colors"
|
||||||
@ -702,18 +603,31 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-600">
|
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-600">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
{(reseller.status === 'pending' || reseller.status === 'inactive') && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleApproveReseller(reseller.id)}
|
||||||
|
className="px-3 py-1.5 bg-green-100 hover:bg-green-200 dark:bg-green-900 dark:hover:bg-green-800 text-green-700 dark:text-green-300 rounded-md transition-colors text-sm font-medium"
|
||||||
|
title="Approve Application"
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRejectReseller(reseller.id, 'Rejected by vendor')}
|
||||||
|
className="px-3 py-1.5 bg-red-100 hover:bg-red-200 dark:bg-red-900 dark:hover:bg-red-800 text-red-700 dark:text-red-300 rounded-md transition-colors text-sm font-medium"
|
||||||
|
title="Reject Application"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEditReseller(reseller)}
|
onClick={() => handleViewReseller(reseller)}
|
||||||
className="text-sm text-blue-600 hover:text-blue-800"
|
className="px-3 py-1.5 bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800 text-blue-700 dark:text-blue-300 rounded-md transition-colors text-sm font-medium"
|
||||||
|
title="View Details"
|
||||||
>
|
>
|
||||||
Edit
|
View
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleMailReseller(reseller)}
|
|
||||||
className="text-sm text-green-600 hover:text-green-800"
|
|
||||||
>
|
|
||||||
Email
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -793,7 +707,7 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="text-right">
|
<td className="text-right">
|
||||||
<div className="flex items-center justify-end space-x-2 relative">
|
<div className="flex items-center justify-end space-x-2 relative">
|
||||||
{reseller.status === 'pending' && (
|
{(reseller.status === 'pending' || reseller.status === 'inactive') && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleApproveReseller(reseller.id)}
|
onClick={() => handleApproveReseller(reseller.id)}
|
||||||
@ -811,24 +725,6 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{reseller.status === 'active' && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => handleRejectReseller(reseller.id, 'Deactivated by vendor')}
|
|
||||||
className="p-2 rounded-lg hover:bg-orange-100 dark:hover:bg-orange-900 transition-all duration-200 hover:scale-110"
|
|
||||||
title="Deactivate Reseller"
|
|
||||||
>
|
|
||||||
<XCircle className="w-4 h-4 text-orange-600" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeactivate(reseller)}
|
|
||||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 hover:scale-110"
|
|
||||||
title="Make Inactive"
|
|
||||||
>
|
|
||||||
<Clock className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleViewReseller(reseller)}
|
onClick={() => handleViewReseller(reseller)}
|
||||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 hover:scale-110"
|
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 hover:scale-110"
|
||||||
@ -836,45 +732,6 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Eye className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
<Eye className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => handleEditReseller(reseller)}
|
|
||||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 hover:scale-110"
|
|
||||||
title="Edit Reseller"
|
|
||||||
>
|
|
||||||
<Edit className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleMailReseller(reseller)}
|
|
||||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 hover:scale-110"
|
|
||||||
title="Send Email"
|
|
||||||
>
|
|
||||||
<Mail className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
|
||||||
</button>
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
onClick={() => handleMoreOptions(reseller)}
|
|
||||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 hover:scale-110"
|
|
||||||
title="More Options"
|
|
||||||
>
|
|
||||||
<MoreVertical className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
|
||||||
</button>
|
|
||||||
{showMoreOptions === reseller.id && (
|
|
||||||
<MoreOptionsDropdown
|
|
||||||
item={reseller}
|
|
||||||
itemType="reseller"
|
|
||||||
onViewPerformance={handleViewPerformance}
|
|
||||||
onDownloadReport={handleDownloadReport}
|
|
||||||
onSendNotification={handleSendNotification}
|
|
||||||
onChangeTier={handleChangeTier}
|
|
||||||
onDeactivate={handleDeactivate}
|
|
||||||
onEdit={handleEditReseller}
|
|
||||||
onMail={handleMailReseller}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
onViewDetails={handleViewReseller}
|
|
||||||
onClose={() => setShowMoreOptions(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -917,19 +774,6 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add Reseller Modal */}
|
|
||||||
<Modal
|
|
||||||
isOpen={isAddModalOpen}
|
|
||||||
onClose={() => setIsAddModalOpen(false)}
|
|
||||||
title="Add New Reseller"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<AddResellerForm
|
|
||||||
onSubmit={handleAddReseller}
|
|
||||||
onCancel={() => setIsAddModalOpen(false)}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Reseller Detail Modal */}
|
{/* Reseller Detail Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isDetailModalOpen}
|
isOpen={isDetailModalOpen}
|
||||||
@ -945,144 +789,12 @@ const ResellerRequestsPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Edit Reseller Modal */}
|
|
||||||
<Modal
|
|
||||||
isOpen={isEditModalOpen}
|
|
||||||
onClose={() => setIsEditModalOpen(false)}
|
|
||||||
title="Edit Reseller"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
{selectedReseller && (
|
|
||||||
<EditResellerForm
|
|
||||||
reseller={selectedReseller}
|
|
||||||
onSubmit={handleUpdateReseller}
|
|
||||||
onCancel={() => setIsEditModalOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Mail Compose Modal */}
|
|
||||||
<Modal
|
|
||||||
isOpen={isMailModalOpen}
|
|
||||||
onClose={() => setIsMailModalOpen(false)}
|
|
||||||
title="Compose Email"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
{selectedReseller && (
|
|
||||||
<MailComposeForm
|
|
||||||
recipient={selectedReseller}
|
|
||||||
onSend={handleSendMail}
|
|
||||||
onCancel={() => setIsMailModalOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Success Modal - Show Reseller Details */}
|
|
||||||
<Modal
|
|
||||||
isOpen={isSuccessModalOpen}
|
|
||||||
onClose={() => setIsSuccessModalOpen(false)}
|
|
||||||
title="Reseller Created Successfully!"
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
{newResellerData && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<svg className="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<h3 className="text-sm font-medium text-green-800 dark:text-green-200">
|
|
||||||
Account Created Successfully
|
|
||||||
</h3>
|
|
||||||
<div className="mt-2 text-sm text-green-700 dark:text-green-300">
|
|
||||||
<p>The reseller account has been created and is now active.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
|
||||||
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-3">Reseller Details</h4>
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 dark:text-gray-400">Name:</span>
|
|
||||||
<p className="font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{newResellerData.firstName} {newResellerData.lastName}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 dark:text-gray-400">Email:</span>
|
|
||||||
<p className="font-medium text-gray-900 dark:text-gray-100">{newResellerData.email}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 dark:text-gray-400">Phone:</span>
|
|
||||||
<p className="font-medium text-gray-900 dark:text-gray-100">{newResellerData.phone}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 dark:text-gray-400">User Type:</span>
|
|
||||||
<p className="font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{newResellerData.userType?.replace('_', ' ')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 dark:text-gray-400">Region:</span>
|
|
||||||
<p className="font-medium text-gray-900 dark:text-gray-100">{newResellerData.region}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 dark:text-gray-400">Business Type:</span>
|
|
||||||
<p className="font-medium text-gray-900 dark:text-gray-100">{newResellerData.businessType}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
|
||||||
<div className="flex items-start">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<h3 className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
|
||||||
Important: Temporary Password
|
|
||||||
</h3>
|
|
||||||
<div className="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
|
|
||||||
<p className="mb-2">The reseller will need this temporary password to log in for the first time:</p>
|
|
||||||
<div className="bg-white dark:bg-gray-700 border border-yellow-300 dark:border-yellow-600 rounded px-3 py-2 font-mono text-lg font-bold text-center">
|
|
||||||
{newResellerData.tempPassword}
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs">
|
|
||||||
Please share this password securely with the reseller. They should change it upon first login.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end space-x-3 pt-4">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsSuccessModalOpen(false)}
|
|
||||||
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
// Copy password to clipboard
|
|
||||||
navigator.clipboard.writeText(newResellerData.tempPassword);
|
|
||||||
toast.success('Password copied to clipboard!');
|
|
||||||
}}
|
|
||||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Copy Password
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Draggable Feedback Component */}
|
{/* Draggable Feedback Component */}
|
||||||
{showFeedback && (
|
{showFeedback && (
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
333
src/pages/VendorTraining.tsx
Normal file
333
src/pages/VendorTraining.tsx
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useAppSelector } from '../store/hooks';
|
||||||
|
import { Plus, Play, Users, Clock, CheckCircle } from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import VendorCertificates from '../components/VendorCertificates';
|
||||||
|
|
||||||
|
interface TrainingCourse {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
level: string;
|
||||||
|
category: string;
|
||||||
|
totalVideos: number;
|
||||||
|
totalDuration: number;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VendorTraining: React.FC = () => {
|
||||||
|
const { user } = useAppSelector((state) => state.auth);
|
||||||
|
const [courses, setCourses] = useState<TrainingCourse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [activeTab, setActiveTab] = useState<'courses' | 'certificates'>('courses');
|
||||||
|
const [isCreateCourseModalOpen, setIsCreateCourseModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const [courseForm, setCourseForm] = useState({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
level: 'Beginner',
|
||||||
|
category: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCourses();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchCourses = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/training-new/courses`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setCourses(data.data.courses || []);
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to fetch courses');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching courses:', error);
|
||||||
|
toast.error('Failed to fetch courses');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateCourse = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/training-new/courses`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(courseForm)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast.success('Course created successfully');
|
||||||
|
setIsCreateCourseModalOpen(false);
|
||||||
|
setCourseForm({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
level: 'Beginner',
|
||||||
|
category: ''
|
||||||
|
});
|
||||||
|
fetchCourses();
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
toast.error(errorData.message || 'Failed to create course');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating course:', error);
|
||||||
|
toast.error('Failed to create course');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLevelColor = (level: string) => {
|
||||||
|
switch (level) {
|
||||||
|
case 'Beginner': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||||
|
case 'Intermediate': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
|
||||||
|
case 'Advanced': return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||||
|
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-secondary-900 dark:text-white mb-3">
|
||||||
|
Training Management
|
||||||
|
</h1>
|
||||||
|
<p className="text-secondary-600 dark:text-secondary-400 text-lg">
|
||||||
|
Create and manage training courses for your resellers
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex space-x-1 bg-gray-100 dark:bg-gray-800 p-1 rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('courses')}
|
||||||
|
className={`px-4 py-2 rounded-md transition-colors ${
|
||||||
|
activeTab === 'courses'
|
||||||
|
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||||
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Courses
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('certificates')}
|
||||||
|
className={`px-4 py-2 rounded-md transition-colors ${
|
||||||
|
activeTab === 'certificates'
|
||||||
|
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||||
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Certificates
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Courses Tab */}
|
||||||
|
{activeTab === 'courses' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Action Bar */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{courses.length} course{courses.length !== 1 ? 's' : ''} created
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCreateCourseModalOpen(true)}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Create Course
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Courses Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{courses.map((course) => (
|
||||||
|
<div key={course.id} className="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
|
{course.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 mb-4">
|
||||||
|
{course.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Level:</span>
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getLevelColor(course.level)}`}>
|
||||||
|
{course.level}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Category:</span>
|
||||||
|
<span className="text-gray-900 dark:text-white font-medium">
|
||||||
|
{course.category || 'Uncategorized'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Videos:</span>
|
||||||
|
<span className="text-gray-900 dark:text-white font-medium">
|
||||||
|
{course.totalVideos}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Duration:</span>
|
||||||
|
<span className="text-gray-900 dark:text-white font-medium">
|
||||||
|
{Math.round(course.totalDuration / 60)} min
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="w-full btn btn-outline btn-sm">
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
Manage Course
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{courses.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Play className="w-8 h-8 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||||
|
No courses created yet
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 mb-6">
|
||||||
|
Create your first training course to get started
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCreateCourseModalOpen(true)}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Create Your First Course
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Certificates Tab */}
|
||||||
|
{activeTab === 'certificates' && (
|
||||||
|
<VendorCertificates />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Course Modal */}
|
||||||
|
{isCreateCourseModalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-md w-full">
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Create New Course
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleCreateCourse} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Course Title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={courseForm.title}
|
||||||
|
onChange={(e) => setCourseForm({ ...courseForm, title: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={courseForm.description}
|
||||||
|
onChange={(e) => setCourseForm({ ...courseForm, description: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Level
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={courseForm.level}
|
||||||
|
onChange={(e) => setCourseForm({ ...courseForm, level: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
<option value="Beginner">Beginner</option>
|
||||||
|
<option value="Intermediate">Intermediate</option>
|
||||||
|
<option value="Advanced">Advanced</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={courseForm.category}
|
||||||
|
onChange={(e) => setCourseForm({ ...courseForm, category: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="e.g., Sales, Technical"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsCreateCourseModalOpen(false)}
|
||||||
|
className="flex-1 btn btn-outline"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 btn btn-primary"
|
||||||
|
>
|
||||||
|
Create Course
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VendorTraining;
|
||||||
@ -9,52 +9,31 @@ import {
|
|||||||
DollarSign,
|
DollarSign,
|
||||||
Activity,
|
Activity,
|
||||||
Calendar,
|
Calendar,
|
||||||
Target
|
Target,
|
||||||
|
UserCheck,
|
||||||
|
UserX,
|
||||||
|
ShoppingCart,
|
||||||
|
Award,
|
||||||
|
Eye,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
Filter,
|
||||||
|
Search
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import analyticsService, { AnalyticsData, VendorData, ResellerData } from '../../services/analyticsService';
|
||||||
interface AnalyticsData {
|
|
||||||
userGrowth: {
|
|
||||||
total: number;
|
|
||||||
growth: number;
|
|
||||||
trend: 'up' | 'down';
|
|
||||||
};
|
|
||||||
revenue: {
|
|
||||||
total: number;
|
|
||||||
growth: number;
|
|
||||||
trend: 'up' | 'down';
|
|
||||||
};
|
|
||||||
vendorApprovals: {
|
|
||||||
total: number;
|
|
||||||
pending: number;
|
|
||||||
approved: number;
|
|
||||||
rejected: number;
|
|
||||||
};
|
|
||||||
productPerformance: {
|
|
||||||
totalProducts: number;
|
|
||||||
activeProducts: number;
|
|
||||||
topPerforming: Array<{
|
|
||||||
name: string;
|
|
||||||
revenue: number;
|
|
||||||
sales: number;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
systemMetrics: {
|
|
||||||
uptime: number;
|
|
||||||
responseTime: number;
|
|
||||||
errorRate: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const AdminAnalytics: React.FC = () => {
|
const AdminAnalytics: React.FC = () => {
|
||||||
|
const [activeTab, setActiveTab] = useState<'vendors' | 'resellers'>('vendors');
|
||||||
|
const [selectedVendor, setSelectedVendor] = useState<VendorData | null>(null);
|
||||||
const [analyticsData, setAnalyticsData] = useState<AnalyticsData>({
|
const [analyticsData, setAnalyticsData] = useState<AnalyticsData>({
|
||||||
userGrowth: { total: 0, growth: 0, trend: 'up' },
|
overview: { totalVendors: 0, totalResellers: 0, totalRevenue: 0, totalSales: 0, monthlyGrowth: 0 },
|
||||||
revenue: { total: 0, growth: 0, trend: 'up' },
|
vendors: [],
|
||||||
vendorApprovals: { total: 0, pending: 0, approved: 0, rejected: 0 },
|
topPerformers: { vendors: [], resellers: [] }
|
||||||
productPerformance: { totalProducts: 0, activeProducts: 0, topPerforming: [] },
|
|
||||||
systemMetrics: { uptime: 0, responseTime: 0, errorRate: 0 }
|
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [timeRange, setTimeRange] = useState('30d');
|
const [timeRange, setTimeRange] = useState('30d');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [expandedVendors, setExpandedVendors] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAnalyticsData();
|
fetchAnalyticsData();
|
||||||
@ -63,41 +42,16 @@ const AdminAnalytics: React.FC = () => {
|
|||||||
const fetchAnalyticsData = async () => {
|
const fetchAnalyticsData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
// Mock data for now - replace with actual API call
|
const data = await analyticsService.getVendorAnalytics(timeRange);
|
||||||
const mockData: AnalyticsData = {
|
|
||||||
userGrowth: {
|
|
||||||
total: 1247,
|
|
||||||
growth: 12.5,
|
|
||||||
trend: 'up'
|
|
||||||
},
|
|
||||||
revenue: {
|
|
||||||
total: 284750,
|
|
||||||
growth: 8.3,
|
|
||||||
trend: 'up'
|
|
||||||
},
|
|
||||||
vendorApprovals: {
|
|
||||||
total: 156,
|
|
||||||
pending: 23,
|
|
||||||
approved: 128,
|
|
||||||
rejected: 5
|
|
||||||
},
|
|
||||||
productPerformance: {
|
|
||||||
totalProducts: 89,
|
|
||||||
activeProducts: 67,
|
|
||||||
topPerforming: [
|
|
||||||
{ name: 'Cloud Storage Pro', revenue: 45000, sales: 156 },
|
|
||||||
{ name: 'Database Hosting', revenue: 38000, sales: 89 },
|
|
||||||
{ name: 'Load Balancer', revenue: 32000, sales: 67 }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
systemMetrics: {
|
|
||||||
uptime: 99.9,
|
|
||||||
responseTime: 245,
|
|
||||||
errorRate: 0.1
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
setAnalyticsData(mockData);
|
// Populate top performers
|
||||||
|
data.topPerformers.vendors = [...data.vendors].sort((a, b) => b.totalRevenue - a.totalRevenue).slice(0, 5);
|
||||||
|
data.topPerformers.resellers = data.vendors
|
||||||
|
.flatMap(v => v.resellers)
|
||||||
|
.sort((a, b) => b.totalRevenue - a.totalRevenue)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
setAnalyticsData(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching analytics data:', error);
|
console.error('Error fetching analytics data:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -112,14 +66,35 @@ const AdminAnalytics: React.FC = () => {
|
|||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTrendIcon = (trend: 'up' | 'down') => {
|
const formatNumber = (num: number) => {
|
||||||
return trend === 'up' ? (
|
return num.toLocaleString();
|
||||||
<TrendingUp className="h-4 w-4 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<TrendingDown className="h-4 w-4 text-red-600" />
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'active': return 'text-green-600 bg-green-100 dark:bg-green-900 dark:text-green-300';
|
||||||
|
case 'pending': return 'text-yellow-600 bg-yellow-100 dark:bg-yellow-900 dark:text-yellow-300';
|
||||||
|
case 'inactive': return 'text-red-600 bg-red-100 dark:bg-red-900 dark:text-red-300';
|
||||||
|
default: return 'text-gray-600 bg-gray-100 dark:bg-gray-900 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleVendorExpansion = (vendorId: number) => {
|
||||||
|
const newExpanded = new Set(expandedVendors);
|
||||||
|
if (newExpanded.has(vendorId)) {
|
||||||
|
newExpanded.delete(vendorId);
|
||||||
|
} else {
|
||||||
|
newExpanded.add(vendorId);
|
||||||
|
}
|
||||||
|
setExpandedVendors(newExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredVendors = analyticsData.vendors.filter(vendor =>
|
||||||
|
vendor.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
vendor.company.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
vendor.email.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@ -133,8 +108,8 @@ const AdminAnalytics: React.FC = () => {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center space-y-4 sm:space-y-0">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center space-y-4 sm:space-y-0">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Analytics Dashboard</h1>
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">User Analytics Dashboard</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400">System-wide performance and insights</p>
|
<p className="text-gray-600 dark:text-gray-400">Vendor and Reseller performance analysis</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<select
|
<select
|
||||||
@ -150,19 +125,27 @@ const AdminAnalytics: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Key Metrics */}
|
{/* Overview Metrics */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 lg:gap-6">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||||
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<h3 className="text-sm font-medium">Total Users</h3>
|
<h3 className="text-sm font-medium">Total Vendors</h3>
|
||||||
|
<Building className="h-4 w-4 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
<div className="pt-2">
|
||||||
|
<div className="text-2xl font-bold">{analyticsData.overview?.totalVendors || 0}</div>
|
||||||
|
<div className="text-xs text-gray-500">Active vendors</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<h3 className="text-sm font-medium">Total Resellers</h3>
|
||||||
<Users className="h-4 w-4 text-gray-500" />
|
<Users className="h-4 w-4 text-gray-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<div className="text-2xl font-bold">{(analyticsData.userGrowth.total || 0).toLocaleString()}</div>
|
<div className="text-2xl font-bold">{analyticsData.overview?.totalResellers || 0}</div>
|
||||||
<div className="flex items-center text-xs text-gray-500">
|
<div className="text-xs text-gray-500">Across all vendors</div>
|
||||||
{getTrendIcon(analyticsData.userGrowth.trend)}
|
|
||||||
<span className="ml-1">{analyticsData.userGrowth.growth}% from last month</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -172,114 +155,318 @@ const AdminAnalytics: React.FC = () => {
|
|||||||
<DollarSign className="h-4 w-4 text-gray-500" />
|
<DollarSign className="h-4 w-4 text-gray-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<div className="text-2xl font-bold">{formatCurrency(analyticsData.revenue.total)}</div>
|
<div className="text-2xl font-bold">{formatCurrency(analyticsData.overview?.totalRevenue || 0)}</div>
|
||||||
<div className="flex items-center text-xs text-gray-500">
|
<div className="text-xs text-gray-500">Combined revenue</div>
|
||||||
{getTrendIcon(analyticsData.revenue.trend)}
|
|
||||||
<span className="ml-1">{analyticsData.revenue.growth}% from last month</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||||
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<h3 className="text-sm font-medium">Active Products</h3>
|
<h3 className="text-sm font-medium">Total Sales</h3>
|
||||||
<Package className="h-4 w-4 text-gray-500" />
|
<ShoppingCart className="h-4 w-4 text-gray-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<div className="text-2xl font-bold">{analyticsData.productPerformance.activeProducts}</div>
|
<div className="text-2xl font-bold">{analyticsData.overview?.totalSales || 0}</div>
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">Combined sales</div>
|
||||||
of {analyticsData.productPerformance.totalProducts} total products
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||||
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<h3 className="text-sm font-medium">System Uptime</h3>
|
<h3 className="text-sm font-medium">Monthly Growth</h3>
|
||||||
<Activity className="h-4 w-4 text-gray-500" />
|
<TrendingUp className="h-4 w-4 text-gray-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<div className="text-2xl font-bold">{analyticsData.systemMetrics.uptime}%</div>
|
<div className="text-2xl font-bold text-green-600">+{analyticsData.overview?.monthlyGrowth || 0}%</div>
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">From last month</div>
|
||||||
Avg response: {analyticsData.systemMetrics.responseTime}ms
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Vendor Approvals */}
|
{/* Tab Navigation */}
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 lg:gap-6">
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||||
<div className="mb-4">
|
<nav className="flex space-x-8 px-6">
|
||||||
<h3 className="flex items-center text-lg font-semibold">
|
<button
|
||||||
<Building className="h-5 w-5 mr-2" />
|
onClick={() => setActiveTab('vendors')}
|
||||||
Vendor Approval Status
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||||
</h3>
|
activeTab === 'vendors'
|
||||||
</div>
|
? 'border-primary-500 text-primary-600'
|
||||||
<div className="space-y-4">
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
<div className="flex justify-between items-center">
|
}`}
|
||||||
<span className="text-sm">Total Requests</span>
|
>
|
||||||
<span className="font-semibold">{analyticsData.vendorApprovals.total}</span>
|
<Building className="inline-block w-4 h-4 mr-2" />
|
||||||
</div>
|
Vendors Analysis
|
||||||
<div className="flex justify-between items-center">
|
</button>
|
||||||
<span className="text-sm text-yellow-600">Pending</span>
|
<button
|
||||||
<span className="font-semibold">{analyticsData.vendorApprovals.pending}</span>
|
onClick={() => setActiveTab('resellers')}
|
||||||
</div>
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||||
<div className="flex justify-between items-center">
|
activeTab === 'resellers'
|
||||||
<span className="text-sm text-green-600">Approved</span>
|
? 'border-primary-500 text-primary-600'
|
||||||
<span className="font-semibold">{analyticsData.vendorApprovals.approved}</span>
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
</div>
|
}`}
|
||||||
<div className="flex justify-between items-center">
|
>
|
||||||
<span className="text-sm text-red-600">Rejected</span>
|
<Users className="inline-block w-4 h-4 mr-2" />
|
||||||
<span className="font-semibold">{analyticsData.vendorApprovals.rejected}</span>
|
Resellers per Vendor
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
<div className="p-6">
|
||||||
<div className="mb-4">
|
{/* Search and Filter */}
|
||||||
<h3 className="flex items-center text-lg font-semibold">
|
<div className="mb-6">
|
||||||
<BarChart3 className="h-5 w-5 mr-2" />
|
<div className="flex items-center space-x-4">
|
||||||
Top Performing Products
|
<div className="flex-1">
|
||||||
</h3>
|
<div className="relative">
|
||||||
</div>
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||||
<div className="space-y-4">
|
<input
|
||||||
{analyticsData.productPerformance.topPerforming.map((product, index) => (
|
type="text"
|
||||||
<div key={index} className="flex justify-between items-center">
|
placeholder="Search vendors, companies, or emails..."
|
||||||
<div>
|
value={searchTerm}
|
||||||
<div className="font-medium text-sm">{product.name}</div>
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
<div className="text-xs text-gray-500">{product.sales} sales</div>
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md bg-white dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
</div>
|
/>
|
||||||
<div className="text-right">
|
|
||||||
<div className="font-semibold">{formatCurrency(product.revenue)}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<Filter className="w-5 h-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* System Health */}
|
{/* Vendors Tab Content */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
{activeTab === 'vendors' && (
|
||||||
<div className="mb-4">
|
<div className="space-y-4">
|
||||||
<h3 className="flex items-center text-lg font-semibold">
|
{filteredVendors.length === 0 ? (
|
||||||
<Activity className="h-5 w-5 mr-2" />
|
<div className="text-center py-12">
|
||||||
System Health Metrics
|
<Building className="mx-auto h-12 w-12 text-gray-400" />
|
||||||
</h3>
|
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white">No vendors found</h3>
|
||||||
</div>
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 lg:gap-6">
|
{searchTerm ? 'Try adjusting your search terms.' : 'No vendors are currently registered in the system.'}
|
||||||
<div className="text-center">
|
</p>
|
||||||
<div className="text-2xl font-bold text-green-600">{analyticsData.systemMetrics.uptime}%</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500">Uptime</div>
|
) : (
|
||||||
</div>
|
filteredVendors.map((vendor) => (
|
||||||
<div className="text-center">
|
<div key={vendor.id} className="border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
<div className="text-2xl font-bold text-blue-600">{analyticsData.systemMetrics.responseTime}ms</div>
|
{/* Vendor Header */}
|
||||||
<div className="text-sm text-gray-500">Avg Response Time</div>
|
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-t-lg">
|
||||||
</div>
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-center">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="text-2xl font-bold text-orange-600">{analyticsData.systemMetrics.errorRate}%</div>
|
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center">
|
||||||
<div className="text-sm text-gray-500">Error Rate</div>
|
<span className="text-lg font-bold text-primary-600 dark:text-primary-300">
|
||||||
</div>
|
{vendor.name.charAt(0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{vendor.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{vendor.company} • {vendor.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(vendor.status)}`}>
|
||||||
|
{vendor.status.charAt(0).toUpperCase() + vendor.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleVendorExpansion(vendor.id)}
|
||||||
|
className="p-2 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md"
|
||||||
|
>
|
||||||
|
{expandedVendors.has(vendor.id) ? (
|
||||||
|
<ChevronDown className="w-5 h-5 text-gray-500" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-5 h-5 text-gray-500" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vendor Metrics */}
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-primary-600">
|
||||||
|
{formatCurrency(vendor.totalRevenue)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">Total Revenue</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-green-600">
|
||||||
|
{vendor.totalResellers}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">Total Resellers</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600">
|
||||||
|
{vendor.activeResellers}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">Active Resellers</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-purple-600">
|
||||||
|
+{vendor.monthlyGrowth}%
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">Monthly Growth</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Products */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Top Products</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{vendor.topProducts.map((product, index) => (
|
||||||
|
<div key={index} className="flex justify-between items-center text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">{product.name}</span>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<span className="text-gray-500">{product.sales} sales</span>
|
||||||
|
<span className="font-medium">{formatCurrency(product.revenue)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expandable Resellers Section */}
|
||||||
|
{expandedVendors.has(vendor.id) && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||||
|
Resellers ({vendor.resellers.length})
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{vendor.resellers.map((reseller) => (
|
||||||
|
<div key={reseller.id} className="bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-sm">{reseller.name}</div>
|
||||||
|
<div className="text-xs text-gray-500">{reseller.company}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-sm font-medium">{formatCurrency(reseller.totalRevenue)}</div>
|
||||||
|
<div className="text-xs text-gray-500">{reseller.totalSales} sales</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resellers Tab Content */}
|
||||||
|
{activeTab === 'resellers' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{analyticsData.vendors.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Users className="mx-auto h-12 w-12 text-gray-400" />
|
||||||
|
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white">No resellers found</h3>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No vendors or resellers are currently registered in the system.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
analyticsData.vendors.map((vendor) => (
|
||||||
|
<div key={vendor.id} className="border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-t-lg">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
|
{vendor.company} - Resellers Analysis
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{vendor.resellers.length} resellers • Total Revenue: {formatCurrency(vendor.totalRevenue)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{vendor.resellers.map((reseller) => (
|
||||||
|
<div key={reseller.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-10 h-10 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-sm font-bold text-green-600 dark:text-green-300">
|
||||||
|
{reseller.name.charAt(0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-gray-900 dark:text-white">{reseller.name}</h4>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">{reseller.company}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(reseller.status)}`}>
|
||||||
|
{reseller.status.charAt(0).toUpperCase() + reseller.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-bold text-primary-600">
|
||||||
|
{formatCurrency(reseller.totalRevenue)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">Revenue</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-bold text-green-600">
|
||||||
|
{reseller.totalSales}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">Sales</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-bold text-blue-600">
|
||||||
|
{formatCurrency(reseller.commissionEarned)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">Commission</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-bold text-purple-600">
|
||||||
|
+{reseller.monthlyGrowth}%
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">Growth</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">Conversion Rate</div>
|
||||||
|
<div className="text-lg font-bold text-green-600">{reseller.performance.conversionRate}%</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">Avg Deal Size</div>
|
||||||
|
<div className="text-lg font-bold text-blue-600">{formatCurrency(reseller.performance.averageDealSize)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">Satisfaction</div>
|
||||||
|
<div className="text-lg font-bold text-purple-600">{reseller.performance.customerSatisfaction}/5</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<h5 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Top Products</h5>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{reseller.topProducts.map((product, index) => (
|
||||||
|
<div key={index} className="flex justify-between items-center text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">{product.name}</span>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<span className="text-gray-500">{product.sales} sales</span>
|
||||||
|
<span className="font-medium">{formatCurrency(product.revenue)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -12,12 +12,14 @@ import {
|
|||||||
UserCheck,
|
UserCheck,
|
||||||
UserX,
|
UserX,
|
||||||
Settings,
|
Settings,
|
||||||
Bell
|
Bell,
|
||||||
|
AlertCircle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAppSelector } from '../../store/hooks';
|
import { useAppSelector } from '../../store/hooks';
|
||||||
import { VendorRequest } from '../../types/vendor';
|
import { VendorRequest } from '../../types/vendor';
|
||||||
import VendorDetailsModal from '../../components/VendorDetailsModal';
|
import VendorDetailsModal from '../../components/VendorDetailsModal';
|
||||||
import VendorRejectionModal from '../../components/VendorRejectionModal';
|
import VendorRejectionModal from '../../components/VendorRejectionModal';
|
||||||
|
import VendorSalesDashboard from '../../components/VendorSalesDashboard';
|
||||||
|
|
||||||
interface DashboardStats {
|
interface DashboardStats {
|
||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
@ -48,6 +50,24 @@ interface DashboardStats {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notification interface for recent activity
|
||||||
|
interface Notification {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
data?: any;
|
||||||
|
isRead: boolean;
|
||||||
|
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
createdAt: string;
|
||||||
|
sender?: {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Use the shared VendorRequest interface instead of PendingVendor
|
// Use the shared VendorRequest interface instead of PendingVendor
|
||||||
|
|
||||||
const AdminDashboard: React.FC = () => {
|
const AdminDashboard: React.FC = () => {
|
||||||
@ -73,7 +93,9 @@ const AdminDashboard: React.FC = () => {
|
|||||||
recentActivity: []
|
recentActivity: []
|
||||||
});
|
});
|
||||||
const [pendingVendors, setPendingVendors] = useState<VendorRequest[]>([]);
|
const [pendingVendors, setPendingVendors] = useState<VendorRequest[]>([]);
|
||||||
|
const [recentNotifications, setRecentNotifications] = useState<Notification[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [notificationsLoading, setNotificationsLoading] = useState(false);
|
||||||
const [selectedVendor, setSelectedVendor] = useState<VendorRequest | null>(null);
|
const [selectedVendor, setSelectedVendor] = useState<VendorRequest | null>(null);
|
||||||
const [showVendorModal, setShowVendorModal] = useState(false);
|
const [showVendorModal, setShowVendorModal] = useState(false);
|
||||||
const [rejectionReason, setRejectionReason] = useState('');
|
const [rejectionReason, setRejectionReason] = useState('');
|
||||||
@ -82,6 +104,7 @@ const AdminDashboard: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDashboardData();
|
fetchDashboardData();
|
||||||
|
fetchRecentNotifications();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchDashboardData = async () => {
|
const fetchDashboardData = async () => {
|
||||||
@ -117,6 +140,31 @@ const AdminDashboard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchRecentNotifications = async () => {
|
||||||
|
try {
|
||||||
|
setNotificationsLoading(true);
|
||||||
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/notifications?page=1&limit=5`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success && data.data.notifications) {
|
||||||
|
setRecentNotifications(data.data.notifications);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch notifications:', response.status);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching recent notifications:', error);
|
||||||
|
} finally {
|
||||||
|
setNotificationsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleApproveVendor = async (vendorId: string) => {
|
const handleApproveVendor = async (vendorId: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/vendors/${vendorId}/approve`, {
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/vendors/${vendorId}/approve`, {
|
||||||
@ -180,14 +228,14 @@ const AdminDashboard: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900">
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900">
|
||||||
<div className="p-6">
|
<div className="p-6 space-y-8 max-w-full">
|
||||||
{/* Header */}
|
{/* Header Section */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">
|
<h1 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">
|
||||||
Admin Dashboard
|
Dashboard Overview
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-slate-600 dark:text-slate-400">
|
<p className="text-slate-600 dark:text-slate-400">
|
||||||
Welcome back, {user?.firstName}! Here's what's happening today.
|
Monitor system performance and manage vendor requests
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -195,7 +243,7 @@ const AdminDashboard: React.FC = () => {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
{/* Total Users */}
|
{/* Total Users */}
|
||||||
<div className="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-lg border border-slate-200 dark:border-slate-700">
|
<div className="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-lg border border-slate-200 dark:border-slate-700">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-2">Total Users</p>
|
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-2">Total Users</p>
|
||||||
<p className="text-3xl font-bold text-slate-900 dark:text-white mb-4">{stats.totalUsers || 0}</p>
|
<p className="text-3xl font-bold text-slate-900 dark:text-white mb-4">{stats.totalUsers || 0}</p>
|
||||||
@ -218,7 +266,7 @@ const AdminDashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-blue-100 dark:bg-blue-900 rounded-lg ml-4">
|
<div className="p-3 bg-blue-100 dark:bg-blue-900 rounded-lg ml-4 flex-shrink-0">
|
||||||
<Users className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
<Users className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -227,24 +275,26 @@ const AdminDashboard: React.FC = () => {
|
|||||||
{/* Pending Vendors */}
|
{/* Pending Vendors */}
|
||||||
<div className="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-lg border border-slate-200 dark:border-slate-700">
|
<div className="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-lg border border-slate-200 dark:border-slate-700">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Pending Vendors</p>
|
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-2">Pending Vendors</p>
|
||||||
<p className="text-2xl font-bold text-amber-600 dark:text-amber-400">{stats.pendingVendors || 0}</p>
|
<p className="text-3xl font-bold text-amber-600 dark:text-amber-400 mb-2">{stats.pendingVendors || 0}</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">Awaiting approval</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-amber-100 dark:bg-amber-900 rounded-lg">
|
<div className="p-3 bg-amber-100 dark:bg-amber-900 rounded-lg flex-shrink-0">
|
||||||
<Clock className="w-6 h-6 text-amber-600 dark:text-amber-400" />
|
<Clock className="w-6 h-6 text-amber-600 dark:text-amber-400" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Channel Partners */}
|
{/* Registered Vendors */}
|
||||||
<div className="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-lg border border-slate-200 dark:border-slate-700">
|
<div className="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-lg border border-slate-200 dark:border-slate-700">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Registered Vendors</p>
|
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-2">Registered Vendors</p>
|
||||||
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">{stats.totalRegisteredVendors || 0}</p>
|
<p className="text-3xl font-bold text-emerald-600 dark:text-emerald-400 mb-2">{stats.totalRegisteredVendors || 0}</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">Active partners</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-emerald-100 dark:bg-emerald-900 rounded-lg">
|
<div className="p-3 bg-emerald-100 dark:bg-emerald-900 rounded-lg flex-shrink-0">
|
||||||
<Building className="w-6 h-6 text-emerald-600 dark:text-emerald-400" />
|
<Building className="w-6 h-6 text-emerald-600 dark:text-emerald-400" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -253,48 +303,55 @@ const AdminDashboard: React.FC = () => {
|
|||||||
{/* Revenue */}
|
{/* Revenue */}
|
||||||
<div className="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-lg border border-slate-200 dark:border-slate-700">
|
<div className="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-lg border border-slate-200 dark:border-slate-700">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Monthly Revenue</p>
|
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-2">Monthly Revenue</p>
|
||||||
<p className="text-2xl font-bold text-green-600 dark:text-green-400">${(stats.revenue || 0).toLocaleString()}</p>
|
<p className="text-3xl font-bold text-green-600 dark:text-green-400 mb-2">${(stats.revenue || 0).toLocaleString()}</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">This month</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-green-100 dark:bg-green-900 rounded-lg">
|
<div className="p-3 bg-green-100 dark:bg-green-900 rounded-lg flex-shrink-0">
|
||||||
<DollarSign className="w-6 h-6 text-green-600 dark:text-green-400" />
|
<DollarSign className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions Section */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||||
{/* Pending Vendor Requests */}
|
{/* Pending Vendor Requests */}
|
||||||
<div className="lg:col-span-2 bg-white dark:bg-slate-800 rounded-xl p-6 shadow-lg border border-slate-200 dark:border-slate-700">
|
<div className="lg:col-span-2 bg-white dark:bg-slate-800 rounded-xl p-6 shadow-lg border border-slate-200 dark:border-slate-700">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">Pending Vendor Requests</h2>
|
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">Pending Vendor Requests</h2>
|
||||||
<Bell className="w-5 h-5 text-amber-500" />
|
<div className="flex items-center space-x-2">
|
||||||
|
<Bell className="w-5 h-5 text-amber-500" />
|
||||||
|
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{pendingVendors.length} pending
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pendingVendors.length === 0 ? (
|
{pendingVendors.length === 0 ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-12">
|
||||||
<Clock className="w-12 h-12 text-slate-400 mx-auto mb-4" />
|
<Clock className="w-16 h-16 text-slate-300 dark:text-slate-600 mx-auto mb-4" />
|
||||||
<p className="text-slate-500 dark:text-slate-400">No pending vendor requests</p>
|
<p className="text-slate-500 dark:text-slate-400 text-lg">No pending vendor requests</p>
|
||||||
|
<p className="text-slate-400 dark:text-slate-500 text-sm">All requests have been processed</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{pendingVendors.map((vendor) => (
|
{pendingVendors.map((vendor) => (
|
||||||
<div key={vendor.id} className="flex items-center justify-between p-4 bg-slate-50 dark:bg-slate-700 rounded-lg">
|
<div key={vendor.id} className="flex items-center justify-between p-4 bg-slate-50 dark:bg-slate-700 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-600 transition-colors">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
<Users className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
<Users className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<p className="font-medium text-slate-900 dark:text-white">
|
<p className="font-medium text-slate-900 dark:text-white truncate">
|
||||||
{vendor.firstName} {vendor.lastName}
|
{vendor.firstName} {vendor.lastName}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-slate-600 dark:text-slate-400">{vendor.email}</p>
|
<p className="text-sm text-slate-600 dark:text-slate-400 truncate">{vendor.email}</p>
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-500">{vendor.company}</p>
|
<p className="text-xs text-slate-500 dark:text-slate-500 truncate">{vendor.company}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleApproveVendor(vendor.id)}
|
onClick={() => handleApproveVendor(vendor.id)}
|
||||||
className="p-2 bg-green-100 dark:bg-green-900 rounded-lg hover:bg-green-200 dark:hover:bg-green-800 transition-colors"
|
className="p-2 bg-green-100 dark:bg-green-900 rounded-lg hover:bg-green-200 dark:hover:bg-green-800 transition-colors"
|
||||||
@ -325,21 +382,48 @@ const AdminDashboard: React.FC = () => {
|
|||||||
|
|
||||||
{/* Recent Activity */}
|
{/* Recent Activity */}
|
||||||
<div className="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-lg border border-slate-200 dark:border-slate-700">
|
<div className="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-lg border border-slate-200 dark:border-slate-700">
|
||||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white mb-6">Recent Activity</h2>
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">Recent Activity</h2>
|
||||||
|
{recentNotifications.length > 0 && (
|
||||||
|
<span className="px-2 py-1 text-xs font-medium bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400 rounded-full">
|
||||||
|
{recentNotifications.length} recent
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchRecentNotifications}
|
||||||
|
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
|
||||||
|
title="Refresh notifications"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{stats.recentActivity && stats.recentActivity.length > 0 ? (
|
{notificationsLoading ? (
|
||||||
stats.recentActivity.slice(0, 5).map((activity) => {
|
<div className="text-center py-8">
|
||||||
|
<Activity className="w-12 h-12 text-slate-300 dark:text-slate-600 mx-auto mb-3" />
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 text-sm">Loading notifications...</p>
|
||||||
|
</div>
|
||||||
|
) : recentNotifications && recentNotifications.length > 0 ? (
|
||||||
|
recentNotifications.slice(0, 5).map((notification) => {
|
||||||
const getActivityIcon = (type: string) => {
|
const getActivityIcon = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'NEW_VENDOR_REQUEST':
|
case 'NEW_VENDOR_REQUEST':
|
||||||
return <Users className="w-4 h-4 text-blue-600 dark:text-blue-400" />;
|
return <Users className="w-4 h-4 text-blue-600 dark:text-blue-400" />;
|
||||||
case 'VENDOR_APPROVED':
|
|
||||||
return <UserCheck className="w-4 h-4 text-green-600 dark:text-green-400" />;
|
|
||||||
case 'VENDOR_REJECTED':
|
|
||||||
return <UserX className="w-4 h-4 text-red-600 dark:text-red-400" />;
|
|
||||||
case 'NEW_RESELLER_REQUEST':
|
case 'NEW_RESELLER_REQUEST':
|
||||||
return <Building className="w-4 h-4 text-purple-600 dark:text-purple-400" />;
|
return <Building className="w-4 h-4 text-purple-600 dark:text-purple-400" />;
|
||||||
|
case 'VENDOR_APPROVED':
|
||||||
|
case 'RESELLER_APPROVED':
|
||||||
|
return <UserCheck className="w-4 h-4 text-green-600 dark:text-green-400" />;
|
||||||
|
case 'VENDOR_REJECTED':
|
||||||
|
case 'RESELLER_REJECTED':
|
||||||
|
return <UserX className="w-4 h-4 text-red-600 dark:text-red-400" />;
|
||||||
|
case 'SYSTEM_ALERT':
|
||||||
|
return <AlertCircle className="w-4 h-4 text-amber-600 dark:text-amber-400" />;
|
||||||
default:
|
default:
|
||||||
return <Activity className="w-4 h-4 text-gray-600 dark:text-gray-400" />;
|
return <Activity className="w-4 h-4 text-gray-600 dark:text-gray-400" />;
|
||||||
}
|
}
|
||||||
@ -349,12 +433,16 @@ const AdminDashboard: React.FC = () => {
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case 'NEW_VENDOR_REQUEST':
|
case 'NEW_VENDOR_REQUEST':
|
||||||
return 'bg-blue-100 dark:bg-blue-900';
|
return 'bg-blue-100 dark:bg-blue-900';
|
||||||
case 'VENDOR_APPROVED':
|
|
||||||
return 'bg-green-100 dark:bg-green-900';
|
|
||||||
case 'VENDOR_REJECTED':
|
|
||||||
return 'bg-red-100 dark:bg-red-900';
|
|
||||||
case 'NEW_RESELLER_REQUEST':
|
case 'NEW_RESELLER_REQUEST':
|
||||||
return 'bg-purple-100 dark:bg-purple-900';
|
return 'bg-purple-100 dark:bg-purple-900';
|
||||||
|
case 'VENDOR_APPROVED':
|
||||||
|
case 'RESELLER_APPROVED':
|
||||||
|
return 'bg-green-100 dark:bg-green-900';
|
||||||
|
case 'VENDOR_REJECTED':
|
||||||
|
case 'RESELLER_REJECTED':
|
||||||
|
return 'bg-red-100 dark:bg-red-900';
|
||||||
|
case 'SYSTEM_ALERT':
|
||||||
|
return 'bg-amber-100 dark:bg-amber-900';
|
||||||
default:
|
default:
|
||||||
return 'bg-gray-100 dark:bg-gray-900';
|
return 'bg-gray-100 dark:bg-gray-900';
|
||||||
}
|
}
|
||||||
@ -372,65 +460,75 @@ const AdminDashboard: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={activity.id} className="flex items-center space-x-3">
|
<div key={notification.id} className="flex items-center space-x-3 p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors">
|
||||||
<div className={`w-8 h-8 ${getActivityColor(activity.type)} rounded-full flex items-center justify-center`}>
|
<div className={`w-8 h-8 ${getActivityColor(notification.type)} rounded-full flex items-center justify-center flex-shrink-0`}>
|
||||||
{getActivityIcon(activity.type)}
|
{getActivityIcon(notification.type)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-slate-900 dark:text-white truncate">
|
<p className="text-sm font-medium text-slate-900 dark:text-white truncate">
|
||||||
{activity.title}
|
{notification.title}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
<p className="text-xs text-slate-500 dark:text-slate-400 truncate">
|
||||||
{formatTimeAgo(activity.createdAt)}
|
{notification.message}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">
|
||||||
|
{formatTimeAgo(notification.createdAt)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{!notification.isRead && (
|
||||||
|
<div className="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0"></div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-4">
|
<div className="text-center py-8">
|
||||||
<Activity className="w-8 h-8 text-slate-400 mx-auto mb-2" />
|
<Activity className="w-12 h-12 text-slate-300 dark:text-slate-600 mx-auto mb-3" />
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">No recent activity</p>
|
<p className="text-slate-500 dark:text-slate-400 text-sm">No recent activity</p>
|
||||||
|
<p className="text-slate-400 dark:text-slate-500 text-xs">Activity and notifications will appear here</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Stats */}
|
{/* Quick Stats Section */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
<div className="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-lg border border-slate-200 dark:border-slate-700">
|
<div className="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-lg border border-slate-200 dark:border-slate-700">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="p-3 bg-green-100 dark:bg-green-900 rounded-lg">
|
<div className="p-3 bg-green-100 dark:bg-green-900 rounded-lg flex-shrink-0">
|
||||||
<CheckCircle className="w-6 h-6 text-green-600 dark:text-green-400" />
|
<CheckCircle className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Approved Today</p>
|
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-1">Approved Today</p>
|
||||||
<p className="text-2xl font-bold text-green-600 dark:text-green-400">{stats.todayStats?.approvedToday || 0}</p>
|
<p className="text-2xl font-bold text-green-600 dark:text-green-400">{stats.todayStats?.approvedToday || 0}</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">Vendors approved</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-lg border border-slate-200 dark:border-slate-700">
|
<div className="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-lg border border-slate-200 dark:border-slate-700">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="p-3 bg-red-100 dark:bg-red-900 rounded-lg">
|
<div className="p-3 bg-red-100 dark:bg-red-900 rounded-lg flex-shrink-0">
|
||||||
<XCircle className="w-6 h-6 text-red-600 dark:text-red-400" />
|
<XCircle className="w-6 h-6 text-red-600 dark:text-red-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Rejected Today</p>
|
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-1">Rejected Today</p>
|
||||||
<p className="text-2xl font-bold text-red-600 dark:text-red-400">{stats.todayStats?.rejectedToday || 0}</p>
|
<p className="text-2xl font-bold text-red-600 dark:text-red-400">{stats.todayStats?.rejectedToday || 0}</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">Vendors rejected</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-lg border border-slate-200 dark:border-slate-700">
|
<div className="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-lg border border-slate-200 dark:border-slate-700">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="p-3 bg-blue-100 dark:bg-blue-900 rounded-lg">
|
<div className="p-3 bg-blue-100 dark:bg-blue-900 rounded-lg flex-shrink-0">
|
||||||
<TrendingUp className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
<TrendingUp className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Recent Requests</p>
|
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-1">Recent Requests</p>
|
||||||
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400">{stats.recentRequests}</p>
|
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400">{stats.recentRequests || 0}</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">Last 24 hours</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
905
src/pages/admin/KnowledgeBase.tsx
Normal file
905
src/pages/admin/KnowledgeBase.tsx
Normal file
@ -0,0 +1,905 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../store/hooks';
|
||||||
|
import {
|
||||||
|
fetchArticles,
|
||||||
|
fetchCategories,
|
||||||
|
createArticle,
|
||||||
|
updateArticle,
|
||||||
|
deleteArticle,
|
||||||
|
publishArticle,
|
||||||
|
createCategory
|
||||||
|
} from '../../store/slices/knowledgeSlice';
|
||||||
|
import { KnowledgeArticle, KnowledgeCategory } from '../../types/knowledge';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
Edit,
|
||||||
|
Trash,
|
||||||
|
Eye,
|
||||||
|
Star,
|
||||||
|
Tag,
|
||||||
|
Folder,
|
||||||
|
User,
|
||||||
|
Clock,
|
||||||
|
BookOpen,
|
||||||
|
X,
|
||||||
|
Save,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
FileText,
|
||||||
|
Code,
|
||||||
|
Filter,
|
||||||
|
FolderOpen
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
|
import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
|
import RichTextEditor from '../../components/RichTextEditor';
|
||||||
|
|
||||||
|
const AdminKnowledgeBase: React.FC = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { articles, categories, loading, error } = useAppSelector((state) => state.knowledge);
|
||||||
|
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState('');
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
const [showCategoryForm, setShowCategoryForm] = useState(false);
|
||||||
|
const [editingArticle, setEditingArticle] = useState<KnowledgeArticle | null>(null);
|
||||||
|
const [selectedArticle, setSelectedArticle] = useState<KnowledgeArticle | null>(null);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
excerpt: '',
|
||||||
|
category: '',
|
||||||
|
subcategory: '',
|
||||||
|
tags: '',
|
||||||
|
contentType: 'markdown' as 'markdown' | 'html',
|
||||||
|
visibility: 'public' as 'public' | 'vendors' | 'resellers' | 'admin',
|
||||||
|
featured: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const [categoryFormData, setCategoryFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
icon: '',
|
||||||
|
color: '#3B82F6',
|
||||||
|
visibility: 'public' as 'public' | 'vendors' | 'resellers' | 'admin'
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchArticles({}));
|
||||||
|
dispatch(fetchCategories({}));
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const articleData = {
|
||||||
|
title: formData.title,
|
||||||
|
content: formData.content,
|
||||||
|
excerpt: formData.excerpt.trim() || undefined,
|
||||||
|
category: formData.category.trim() || 'General', // category is required, provide default
|
||||||
|
subcategory: formData.subcategory.trim() || undefined,
|
||||||
|
tags: formData.tags.trim() ? formData.tags.split(',').map(tag => tag.trim()).filter(tag => tag.length > 0) : [],
|
||||||
|
contentType: formData.contentType,
|
||||||
|
visibility: formData.visibility,
|
||||||
|
featured: formData.featured
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingArticle) {
|
||||||
|
await dispatch(updateArticle({ id: editingArticle.id, data: articleData })).unwrap();
|
||||||
|
setEditingArticle(null);
|
||||||
|
} else {
|
||||||
|
await dispatch(createArticle(articleData)).unwrap();
|
||||||
|
}
|
||||||
|
setFormData({
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
excerpt: '',
|
||||||
|
category: '',
|
||||||
|
subcategory: '',
|
||||||
|
tags: '',
|
||||||
|
contentType: 'markdown',
|
||||||
|
visibility: 'public',
|
||||||
|
featured: false
|
||||||
|
});
|
||||||
|
setShowCreateForm(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving article:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (article: KnowledgeArticle) => {
|
||||||
|
setEditingArticle(article);
|
||||||
|
setFormData({
|
||||||
|
title: article.title,
|
||||||
|
content: article.content,
|
||||||
|
excerpt: article.excerpt || '',
|
||||||
|
category: article.category || '',
|
||||||
|
subcategory: article.subcategory || '',
|
||||||
|
tags: Array.isArray(article.tags) ? article.tags.join(', ') : '',
|
||||||
|
contentType: article.contentType || 'markdown',
|
||||||
|
visibility: article.visibility || 'public',
|
||||||
|
featured: article.featured || false
|
||||||
|
});
|
||||||
|
setShowCreateForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (window.confirm('Are you sure you want to delete this article?')) {
|
||||||
|
try {
|
||||||
|
await dispatch(deleteArticle(id)).unwrap();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting article:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePublish = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await dispatch(publishArticle(id)).unwrap();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error publishing article:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredArticles = (articles || []).filter(article => {
|
||||||
|
const matchesSearch = article.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
article.excerpt?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
const matchesCategory = !selectedCategory || article.category === selectedCategory;
|
||||||
|
return matchesSearch && matchesCategory;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'published': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||||
|
case 'draft': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
|
||||||
|
case 'archived': return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
|
||||||
|
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVisibilityColor = (visibility: string) => {
|
||||||
|
switch (visibility) {
|
||||||
|
case 'public': return 'bg-blue-100 text-blue-800 dark:bg-green-900 dark:text-blue-200';
|
||||||
|
case 'vendors': return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200';
|
||||||
|
case 'resellers': return 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200';
|
||||||
|
case 'admin': return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||||
|
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show loading only if we're actually loading and have no data
|
||||||
|
if (loading && (!articles || articles.length === 0) && (!categories || categories.length === 0)) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-purple-50 to-indigo-100 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900">
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-purple-600"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-purple-50 to-indigo-100 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900">
|
||||||
|
<div className="p-6 space-y-8 max-w-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Knowledge Base Management
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
{(() => {
|
||||||
|
const totalArticles = articles.length;
|
||||||
|
const publishedArticles = articles.filter(article => article.status === 'published').length;
|
||||||
|
const draftArticles = articles.filter(article => article.status === 'draft').length;
|
||||||
|
const categories = Array.from(new Set(articles.map(article => article.category)));
|
||||||
|
|
||||||
|
if (totalArticles === 0) {
|
||||||
|
return 'No articles yet. Start building your knowledge base by creating the first article.';
|
||||||
|
} else if (publishedArticles === totalArticles) {
|
||||||
|
return `Manage your knowledge base with ${totalArticles} published article${totalArticles > 1 ? 's' : ''} across ${categories.length} categor${categories.length > 1 ? 'ies' : 'y'}.`;
|
||||||
|
} else if (draftArticles > 0) {
|
||||||
|
return `Manage your knowledge base with ${totalArticles} article${totalArticles > 1 ? 's' : ''} (${publishedArticles} published, ${draftArticles} draft${draftArticles > 1 ? 's' : ''}).`;
|
||||||
|
} else {
|
||||||
|
return `Manage your knowledge base with ${totalArticles} article${totalArticles > 1 ? 's' : ''} across ${categories.length} categor${categories.length > 1 ? 'ies' : 'y'}.`;
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Dynamic Summary */}
|
||||||
|
{articles.length > 0 && (
|
||||||
|
<div className="mt-4 flex flex-wrap gap-4 text-sm">
|
||||||
|
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400">
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
<span>{articles.length} total article{articles.length > 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
{articles.filter(article => article.status === 'published').length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
<span>{articles.filter(article => article.status === 'published').length} published</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{articles.filter(article => article.status === 'draft').length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>{articles.filter(article => article.status === 'draft').length} draft{articles.filter(article => article.status === 'draft').length > 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Array.from(new Set(articles.map(article => article.category))).length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-purple-600 dark:text-purple-400">
|
||||||
|
<FolderOpen className="w-4 h-4" />
|
||||||
|
<span>{Array.from(new Set(articles.map(article => article.category))).length} categor{Array.from(new Set(articles.map(article => article.category))).length > 1 ? 'ies' : 'y'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(true)}
|
||||||
|
className="px-6 py-3 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg transition-colors duration-200 flex items-center gap-2 shadow-lg hover:shadow-xl"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
Create New Article
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Type Tabs */}
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-lg border border-slate-200 dark:border-slate-700 mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
Content Management
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
<span className="px-3 py-1 bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 rounded-full">
|
||||||
|
{(articles || []).filter(a => a.contentType === 'markdown').length} Articles
|
||||||
|
</span>
|
||||||
|
<span className="px-3 py-1 bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 rounded-full">
|
||||||
|
{(articles || []).filter(a => a.contentType === 'html').length} Documentation
|
||||||
|
</span>
|
||||||
|
<span className="px-3 py-1 bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 rounded-full">
|
||||||
|
{(categories || []).length} Categories
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<div className="flex space-x-1 bg-slate-100 dark:bg-slate-700 p-1 rounded-lg">
|
||||||
|
<button
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-md font-medium transition-all ${
|
||||||
|
selectedCategory === '' ? 'bg-white dark:bg-slate-600 text-slate-900 dark:text-white shadow-sm' : 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedCategory('')}
|
||||||
|
>
|
||||||
|
<BookOpen className="w-4 h-4" />
|
||||||
|
All Content
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-md font-medium transition-all ${
|
||||||
|
selectedCategory === 'markdown' ? 'bg-white dark:bg-slate-600 text-slate-900 dark:text-white shadow-sm' : 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedCategory('markdown')}
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
Articles
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-md font-medium transition-all ${
|
||||||
|
selectedCategory === 'html' ? 'bg-white dark:bg-slate-600 text-slate-900 dark:text-white shadow-sm' : 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedCategory('html')}
|
||||||
|
>
|
||||||
|
<Code className="w-4 h-4" />
|
||||||
|
Documentation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons - Enhanced CRUD Controls */}
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-lg border border-slate-200 dark:border-slate-700 mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
Quick Actions
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
{filteredArticles.length} items found
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(true)}
|
||||||
|
className="flex items-center gap-2 px-6 py-3 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors font-medium shadow-lg hover:shadow-xl"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
Create New Article
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCategoryForm(true)}
|
||||||
|
className="flex items-center gap-2 px-6 py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors font-medium shadow-lg hover:shadow-xl"
|
||||||
|
>
|
||||||
|
<Folder className="w-5 h-5" />
|
||||||
|
Create Category
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(true)}
|
||||||
|
className="flex items-center gap-2 px-6 py-3 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors font-medium shadow-lg hover:shadow-xl"
|
||||||
|
>
|
||||||
|
<FileText className="w-5 h-5" />
|
||||||
|
Add Documentation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Filter Section */}
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-lg border border-slate-200 dark:border-slate-700 mb-6">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search articles, guides, and documentation..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<select
|
||||||
|
value={selectedCategory}
|
||||||
|
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||||
|
className="px-4 py-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
{categories?.map((category) => (
|
||||||
|
<option key={category.id} value={category.name}>
|
||||||
|
{category.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button className="flex items-center gap-2 px-4 py-3 bg-slate-100 dark:bg-slate-600 text-slate-700 dark:text-slate-300 rounded-lg hover:bg-slate-200 dark:hover:bg-slate-500 transition-colors">
|
||||||
|
<Filter className="w-5 h-5" />
|
||||||
|
Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Articles Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{filteredArticles.map((article) => (
|
||||||
|
<div key={article.id} className="bg-white dark:bg-slate-800 rounded-xl shadow-lg border border-slate-200 dark:border-slate-700 overflow-hidden hover:shadow-xl transition-shadow">
|
||||||
|
{/* Article Header */}
|
||||||
|
<div className="p-6 border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 dark:text-white line-clamp-2">
|
||||||
|
{article.title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{article.featured && (
|
||||||
|
<Star className="w-5 h-5 text-yellow-500 fill-current" />
|
||||||
|
)}
|
||||||
|
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(article.status)}`}>
|
||||||
|
{article.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{article.excerpt && (
|
||||||
|
<p className="text-slate-600 dark:text-slate-400 text-sm line-clamp-3 mb-3">
|
||||||
|
{article.excerpt}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick Status Actions */}
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getVisibilityColor(article.visibility)}`}>
|
||||||
|
{article.visibility}
|
||||||
|
</span>
|
||||||
|
{article.category && (
|
||||||
|
<span className="px-2 py-1 text-xs font-medium bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-full">
|
||||||
|
{article.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Article Meta */}
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
<span>
|
||||||
|
{article.author?.firstName && article.author?.lastName
|
||||||
|
? `${article.author.firstName} ${article.author.lastName}`
|
||||||
|
: article.author?.email || `Author ID: ${article.authorId}`
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>{new Date(article.createdAt).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Article Stats */}
|
||||||
|
<div className="px-6 py-3 bg-slate-50 dark:bg-slate-700 border-b border-slate-200 dark:border-slate-600">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Eye className="w-4 h-4 text-slate-500" />
|
||||||
|
<span className="text-slate-600 dark:text-slate-400">{article.viewCount || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||||
|
<span className="text-slate-600 dark:text-slate-400">{article.helpfulCount || 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
{article.contentType === 'markdown' ? 'Article' : 'Documentation'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Admin Controls */}
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(article)}
|
||||||
|
className="flex items-center gap-1 px-3 py-2 text-sm font-medium text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-md transition-colors"
|
||||||
|
title="Edit Article"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(article.id)}
|
||||||
|
className="flex items-center gap-1 px-3 py-2 text-sm font-medium text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md transition-colors"
|
||||||
|
title="Delete Article"
|
||||||
|
>
|
||||||
|
<Trash className="w-4 h-4" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{article.status !== 'published' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handlePublish(article.id)}
|
||||||
|
className="flex items-center gap-1 px-3 py-2 text-sm font-medium text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-md transition-colors"
|
||||||
|
title="Publish Article"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
Publish
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedArticle(article)}
|
||||||
|
className="flex items-center gap-1 px-3 py-2 text-sm font-medium text-slate-600 hover:text-slate-700 hover:bg-slate-50 dark:hover:bg-slate-700/50 rounded-md transition-colors"
|
||||||
|
title="View Article"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{filteredArticles.length === 0 && !loading && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<BookOpen className="w-16 h-16 text-slate-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-slate-900 dark:text-white mb-2">
|
||||||
|
No articles found
|
||||||
|
</h3>
|
||||||
|
<p className="text-slate-600 dark:text-slate-400 mb-6">
|
||||||
|
{searchTerm || selectedCategory
|
||||||
|
? 'Try adjusting your search or filters'
|
||||||
|
: 'Get started by creating your first article'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(true)}
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors font-medium"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
Create New Article
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create/Edit Article Modal */}
|
||||||
|
{showCreateForm && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="p-6 border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||||
|
{editingArticle ? 'Edit Article' : 'Create New Article'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreateForm(false);
|
||||||
|
setEditingArticle(null);
|
||||||
|
setFormData({
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
excerpt: '',
|
||||||
|
category: '',
|
||||||
|
subcategory: '',
|
||||||
|
tags: '',
|
||||||
|
contentType: 'markdown',
|
||||||
|
visibility: 'public',
|
||||||
|
featured: false
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
Title *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => setFormData({...formData, title: e.target.value})}
|
||||||
|
className="w-full px-4 py-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
placeholder="Enter article title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
Content Type *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
value={formData.contentType}
|
||||||
|
onChange={(e) => setFormData({...formData, contentType: e.target.value as 'markdown' | 'html'})}
|
||||||
|
className="w-full px-4 py-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="markdown">Article (Markdown)</option>
|
||||||
|
<option value="html">Documentation (HTML)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.category}
|
||||||
|
onChange={(e) => setFormData({...formData, category: e.target.value})}
|
||||||
|
className="w-full px-4 py-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="">Select category</option>
|
||||||
|
{categories?.map((category) => (
|
||||||
|
<option key={category.id} value={category.name}>
|
||||||
|
{category.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
Visibility
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.visibility}
|
||||||
|
onChange={(e) => setFormData({...formData, visibility: e.target.value as 'public' | 'vendors' | 'resellers' | 'admin'})}
|
||||||
|
className="w-full px-4 py-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="public">Public</option>
|
||||||
|
<option value="vendors">Vendors Only</option>
|
||||||
|
<option value="resellers">Resellers Only</option>
|
||||||
|
<option value="admin">Admin Only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
Tags
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.tags}
|
||||||
|
onChange={(e) => setFormData({...formData, tags: e.target.value})}
|
||||||
|
className="w-full px-4 py-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
placeholder="Enter tags separated by commas"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="featured"
|
||||||
|
checked={formData.featured}
|
||||||
|
onChange={(e) => setFormData({...formData, featured: e.target.checked})}
|
||||||
|
className="w-4 h-4 text-purple-600 bg-slate-100 border-slate-300 rounded focus:ring-purple-500 focus:ring-2"
|
||||||
|
/>
|
||||||
|
<label htmlFor="featured" className="ml-2 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
Featured Article
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
Excerpt
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.excerpt}
|
||||||
|
onChange={(e) => setFormData({...formData, excerpt: e.target.value})}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
placeholder="Brief description of the article"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
Content *
|
||||||
|
</label>
|
||||||
|
<RichTextEditor
|
||||||
|
content={formData.content}
|
||||||
|
onChange={(content) => setFormData({...formData, content})}
|
||||||
|
placeholder="Write your article content here..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-6 border-t border-slate-200 dark:border-slate-700">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreateForm(false);
|
||||||
|
setEditingArticle(null);
|
||||||
|
setFormData({
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
excerpt: '',
|
||||||
|
category: '',
|
||||||
|
subcategory: '',
|
||||||
|
tags: '',
|
||||||
|
contentType: 'markdown',
|
||||||
|
visibility: 'public',
|
||||||
|
featured: false
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="px-6 py-3 border border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex items-center gap-2 px-6 py-3 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors font-medium"
|
||||||
|
>
|
||||||
|
<Save className="w-5 h-5" />
|
||||||
|
{editingArticle ? 'Update Article' : 'Create Article'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Category Modal */}
|
||||||
|
{showCategoryForm && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-md w-full">
|
||||||
|
<div className="p-6 border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||||
|
Create New Category
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await dispatch(createCategory(categoryFormData)).unwrap();
|
||||||
|
setCategoryFormData({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
icon: '',
|
||||||
|
color: '#3B82F6',
|
||||||
|
visibility: 'public'
|
||||||
|
});
|
||||||
|
setShowCategoryForm(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating category:', error);
|
||||||
|
}
|
||||||
|
}} className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={categoryFormData.name}
|
||||||
|
onChange={(e) => setCategoryFormData({...categoryFormData, name: e.target.value})}
|
||||||
|
className="w-full px-4 py-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
placeholder="Category name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={categoryFormData.description}
|
||||||
|
onChange={(e) => setCategoryFormData({...categoryFormData, description: e.target.value})}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
placeholder="Category description"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
Icon
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={categoryFormData.icon}
|
||||||
|
onChange={(e) => setCategoryFormData({...categoryFormData, icon: e.target.value})}
|
||||||
|
className="w-full px-4 py-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
placeholder="Icon name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
Color
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={categoryFormData.color}
|
||||||
|
onChange={(e) => setCategoryFormData({...categoryFormData, color: e.target.value})}
|
||||||
|
className="w-full h-12 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
Visibility
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={categoryFormData.visibility}
|
||||||
|
onChange={(e) => setCategoryFormData({...categoryFormData, visibility: e.target.value as 'public' | 'vendors' | 'resellers' | 'admin'})}
|
||||||
|
className="w-full px-4 py-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="public">Public</option>
|
||||||
|
<option value="vendors">Vendors Only</option>
|
||||||
|
<option value="resellers">Resellers Only</option>
|
||||||
|
<option value="admin">Admin Only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-6 border-t border-slate-200 dark:border-slate-700">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCategoryForm(false)}
|
||||||
|
className="px-6 py-3 border border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex items-center gap-2 px-6 py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors font-medium"
|
||||||
|
>
|
||||||
|
<Save className="w-5 h-5" />
|
||||||
|
Create Category
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* View Article Modal */}
|
||||||
|
{selectedArticle && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="p-6 border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||||
|
{selectedArticle.title}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedArticle(null)}
|
||||||
|
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(selectedArticle.status)}`}>
|
||||||
|
{selectedArticle.status}
|
||||||
|
</span>
|
||||||
|
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getVisibilityColor(selectedArticle.visibility)}`}>
|
||||||
|
{selectedArticle.visibility}
|
||||||
|
</span>
|
||||||
|
{selectedArticle.category && (
|
||||||
|
<span className="px-2 py-1 text-xs font-medium bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-full">
|
||||||
|
{selectedArticle.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{selectedArticle.excerpt && (
|
||||||
|
<p className="text-slate-600 dark:text-slate-400 mb-6 text-lg">
|
||||||
|
{selectedArticle.excerpt}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="prose prose-slate dark:prose-invert max-w-none">
|
||||||
|
{selectedArticle.contentType === 'html' ? (
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: selectedArticle.content }} />
|
||||||
|
) : (
|
||||||
|
<SyntaxHighlighter
|
||||||
|
language="markdown"
|
||||||
|
style={tomorrow}
|
||||||
|
className="rounded-lg"
|
||||||
|
>
|
||||||
|
{selectedArticle.content}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 pt-6 border-t border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="flex items-center justify-between text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span>
|
||||||
|
{selectedArticle.author?.firstName && selectedArticle.author?.lastName
|
||||||
|
? `${selectedArticle.author.firstName} ${selectedArticle.author.lastName}`
|
||||||
|
: selectedArticle.author?.email || `Author ID: ${selectedArticle.authorId}`
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span>Created: {new Date(selectedArticle.createdAt).toLocaleDateString()}</span>
|
||||||
|
<span>Views: {selectedArticle.viewCount || 0}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedArticle(null);
|
||||||
|
handleEdit(selectedArticle);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
Edit Article
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminKnowledgeBase;
|
||||||
452
src/pages/admin/Logs.tsx
Normal file
452
src/pages/admin/Logs.tsx
Normal file
@ -0,0 +1,452 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import logsService, { LoginLog, LogsFilter } from '../../services/logsService';
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
Clock,
|
||||||
|
User,
|
||||||
|
Shield,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Search,
|
||||||
|
Download
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const Logs: React.FC = () => {
|
||||||
|
|
||||||
|
const [logs, setLogs] = useState<LoginLog[]>([]);
|
||||||
|
const [filteredLogs, setFilteredLogs] = useState<LoginLog[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
|
const [roleFilter, setRoleFilter] = useState<string>('all');
|
||||||
|
const [showActiveOnly, setShowActiveOnly] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
|
const [stats, setStats] = useState({
|
||||||
|
activeUsers: 0,
|
||||||
|
totalSessions: 0,
|
||||||
|
adminUsers: 0,
|
||||||
|
failedLogins: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Load initial data
|
||||||
|
loadLogs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const loadLogs = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const [logsResponse, statsResponse] = await Promise.all([
|
||||||
|
logsService.getLogs(),
|
||||||
|
logsService.getLogsStats()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Ensure logs array exists and is valid
|
||||||
|
const logsData = logsResponse?.logs || [];
|
||||||
|
const statsData = statsResponse || {};
|
||||||
|
|
||||||
|
setLogs(logsData);
|
||||||
|
setFilteredLogs(logsData);
|
||||||
|
setStats(statsData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading logs:', error);
|
||||||
|
// Fallback to mock data if API fails
|
||||||
|
const mockData = logsService.getMockLogs();
|
||||||
|
const mockStats = logsService.getMockStats();
|
||||||
|
setLogs(mockData);
|
||||||
|
setFilteredLogs(mockData);
|
||||||
|
setStats(mockStats);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Filter logs based on search and filters
|
||||||
|
let filtered = logs || [];
|
||||||
|
|
||||||
|
// Search filter
|
||||||
|
if (searchTerm) {
|
||||||
|
filtered = filtered.filter(log =>
|
||||||
|
(log.userName && log.userName.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||||
|
(log.userEmail && log.userEmail.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||||
|
(log.userRole && log.userRole.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status filter
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
filtered = filtered.filter(log => log.status === statusFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role filter
|
||||||
|
if (roleFilter !== 'all') {
|
||||||
|
filtered = filtered.filter(log => log.userRole === roleFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active only filter
|
||||||
|
if (showActiveOnly) {
|
||||||
|
filtered = filtered.filter(log => log.status === 'active');
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilteredLogs(filtered);
|
||||||
|
}, [logs, searchTerm, statusFilter, roleFilter, showActiveOnly]);
|
||||||
|
|
||||||
|
const refreshLogs = async () => {
|
||||||
|
try {
|
||||||
|
console.log('Refreshing logs...');
|
||||||
|
// Refresh both logs and stats from API
|
||||||
|
const [logsResponse, statsResponse] = await Promise.all([
|
||||||
|
logsService.getLogs(),
|
||||||
|
logsService.getLogsStats()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Ensure logs array exists and is valid
|
||||||
|
const logsData = logsResponse?.logs || [];
|
||||||
|
const statsData = statsResponse || {};
|
||||||
|
|
||||||
|
setLogs(logsData);
|
||||||
|
setFilteredLogs(logsData);
|
||||||
|
setStats(statsData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refreshing logs:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'active':
|
||||||
|
return 'text-green-600 bg-green-100 dark:bg-green-900/20 dark:text-green-400';
|
||||||
|
case 'logged_out':
|
||||||
|
return 'text-blue-600 bg-blue-100 dark:bg-blue-900/20 dark:text-blue-400';
|
||||||
|
case 'expired':
|
||||||
|
return 'text-orange-600 bg-orange-100 dark:bg-orange-900/20 dark:text-orange-400';
|
||||||
|
case 'failed':
|
||||||
|
return 'text-red-600 bg-red-100 dark:bg-red-100/20 dark:text-red-400';
|
||||||
|
default:
|
||||||
|
return 'text-gray-600 bg-gray-100 dark:bg-gray-900/20 dark:text-gray-400';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'active':
|
||||||
|
return <CheckCircle className="w-4 h-4" />;
|
||||||
|
case 'logged_out':
|
||||||
|
return <Clock className="w-4 h-4" />;
|
||||||
|
case 'expired':
|
||||||
|
return <AlertTriangle className="w-4 h-4" />;
|
||||||
|
case 'failed':
|
||||||
|
return <XCircle className="w-4 h-4" />;
|
||||||
|
default:
|
||||||
|
return <Clock className="w-4 h-4" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const formatTimeAgo = (timestamp: string) => {
|
||||||
|
if (!timestamp || timestamp === 'null' || timestamp === 'undefined') {
|
||||||
|
return 'Never';
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const time = new Date(timestamp);
|
||||||
|
|
||||||
|
// Check if the date is valid
|
||||||
|
if (isNaN(time.getTime())) {
|
||||||
|
return 'Invalid date';
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffMs = now.getTime() - time.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'Just now';
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`;
|
||||||
|
return `${Math.floor(diffMins / 1440)}d ago`;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const exportLogs = async () => {
|
||||||
|
try {
|
||||||
|
const filters: LogsFilter = {
|
||||||
|
searchTerm,
|
||||||
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
|
role: roleFilter !== 'all' ? roleFilter : undefined,
|
||||||
|
showActiveOnly
|
||||||
|
};
|
||||||
|
|
||||||
|
const blob = await logsService.exportLogs(filters, 'csv');
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `user-logs-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error exporting logs:', error);
|
||||||
|
// Fallback to client-side export
|
||||||
|
const csvContent = [
|
||||||
|
['User', 'Email', 'Role', 'Status', 'Login Time', 'Last Activity'],
|
||||||
|
...(filteredLogs || []).map(log => [
|
||||||
|
log.userName || 'Unknown',
|
||||||
|
log.userEmail || 'No email',
|
||||||
|
log.userRole || 'Unknown',
|
||||||
|
log.status || 'unknown',
|
||||||
|
log.loginTime && !isNaN(new Date(log.loginTime).getTime()) ? new Date(log.loginTime).toLocaleString() : 'Never',
|
||||||
|
log.lastActivity && !isNaN(new Date(log.lastActivity).getTime()) ? new Date(log.lastActivity).toLocaleString() : 'Never'
|
||||||
|
])
|
||||||
|
].map(row => row.join(',')).join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `user-logs-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 p-6">
|
||||||
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900 dark:text-white">User Activity Logs</h1>
|
||||||
|
<p className="text-slate-600 dark:text-slate-400 mt-1">
|
||||||
|
Monitor user login activity and system access
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<button
|
||||||
|
onClick={refreshLogs}
|
||||||
|
className="inline-flex items-center justify-center px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg shadow-sm text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 transition-colors"
|
||||||
|
>
|
||||||
|
<span>Refresh</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={exportLogs}
|
||||||
|
className="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
<span>Export CSV</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-6 border border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Active Users</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||||
|
{stats.activeUsers}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/20 rounded-xl flex items-center justify-center">
|
||||||
|
<Activity className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-6 border border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Total Sessions</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 dark:text-white">{stats.totalSessions}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900/20 rounded-xl flex items-center justify-center">
|
||||||
|
<User className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-6 border border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Admin Users</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||||
|
{stats.adminUsers}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900/20 rounded-xl flex items-center justify-center">
|
||||||
|
<Shield className="w-6 h-6 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-6 border border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Failed Logins</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||||
|
{stats.failedLogins}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-red-100 dark:bg-red-900/20 rounded-xl flex items-center justify-center">
|
||||||
|
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-6 border border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4 items-start lg:items-center">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search users, emails, roles..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors"
|
||||||
|
>
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="logged_out">Logged Out</option>
|
||||||
|
<option value="expired">Expired</option>
|
||||||
|
<option value="failed">Failed</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={roleFilter}
|
||||||
|
onChange={(e) => setRoleFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors"
|
||||||
|
>
|
||||||
|
<option value="all">All Roles</option>
|
||||||
|
<option value="system_admin">System Admin</option>
|
||||||
|
<option value="channel_partner_admin">Channel Partner Admin</option>
|
||||||
|
<option value="vendor_admin">Vendor Admin</option>
|
||||||
|
<option value="reseller_admin">Reseller Admin</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label className="flex items-center space-x-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showActiveOnly}
|
||||||
|
onChange={(e) => setShowActiveOnly(e.target.checked)}
|
||||||
|
className="rounded border-slate-300 text-purple-600 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-700 dark:text-slate-300">Active Only</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logs Table - Fixed viewport overflow */}
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-lg shadow border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[700px]">
|
||||||
|
<thead className="bg-slate-50 dark:bg-slate-700/50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||||
|
User
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||||
|
Last Login
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||||
|
Last Activity
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||||
|
Role
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white dark:bg-slate-800 divide-y divide-slate-200 dark:divide-slate-700">
|
||||||
|
{(filteredLogs || []).map((log) => (
|
||||||
|
<tr key={log.id} className="hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors">
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-8 h-8 bg-gradient-to-r from-purple-500 to-indigo-500 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-white text-sm font-medium">
|
||||||
|
{log.userName && log.userName.charAt(0) ? log.userName.charAt(0).toUpperCase() : 'U'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-slate-900 dark:text-white truncate">
|
||||||
|
{log.userName || 'Unknown User'}
|
||||||
|
{log.isAdmin && (
|
||||||
|
<Shield className="inline w-4 h-4 ml-2 text-purple-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-500 dark:text-slate-400 truncate">
|
||||||
|
{log.userEmail || 'No email'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(log.status)}`}>
|
||||||
|
{getStatusIcon(log.status)}
|
||||||
|
<span className="ml-1 capitalize">{log.status ? log.status.replace(/_/g, ' ') : 'unknown'}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-sm text-slate-900 dark:text-white">
|
||||||
|
{log.loginTime ? formatTimeAgo(log.loginTime) : 'Never'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-sm text-slate-900 dark:text-white">
|
||||||
|
{log.lastActivity ? formatTimeAgo(log.lastActivity) : 'Never'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-sm text-slate-900 dark:text-white">
|
||||||
|
<span className="text-xs text-slate-400 dark:text-slate-500 capitalize">
|
||||||
|
{log.userRole ? log.userRole.replace(/_/g, ' ') : 'Unknown'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(filteredLogs || []).length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Activity className="w-12 h-12 text-slate-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-slate-900 dark:text-white mb-2">No logs found</h3>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400">
|
||||||
|
Try adjusting your search criteria or filters.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Logs;
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { Plus, Search, Edit, Trash2, Eye, Pencil } from 'lucide-react';
|
import { Search, Trash2, Eye } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { apiService, Product } from '../../services/api';
|
import { apiService, Product } from '../../services/api';
|
||||||
|
|
||||||
@ -14,27 +14,14 @@ const Products: React.FC = () => {
|
|||||||
const [vendors, setVendors] = useState<Array<{ id: number; firstName: string; lastName: string; company?: string }>>([]);
|
const [vendors, setVendors] = useState<Array<{ id: number; firstName: string; lastName: string; company?: string }>>([]);
|
||||||
const [vendorMap, setVendorMap] = useState<Record<number, { firstName: string; lastName: string; company?: string }>>({});
|
const [vendorMap, setVendorMap] = useState<Record<number, { firstName: string; lastName: string; company?: string }>>({});
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('list');
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('list');
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
|
||||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [deletingProductId, setDeletingProductId] = useState<number | null>(null);
|
const [deletingProductId, setDeletingProductId] = useState<number | null>(null);
|
||||||
const [editingProductId, setEditingProductId] = useState<number | null>(null);
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||||
const [totalItems, setTotalItems] = useState(0);
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
const [totalPages, setTotalPages] = useState(0);
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
category: '' as Product['category'],
|
|
||||||
price: '',
|
|
||||||
commissionRate: '',
|
|
||||||
status: 'draft' as Product['status'],
|
|
||||||
availability: 'available' as Product['availability'],
|
|
||||||
features: [] as string[],
|
|
||||||
specifications: {},
|
|
||||||
purchaseUrl: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{ value: 'cloud_storage', label: 'Cloud Storage' },
|
{ value: 'cloud_storage', label: 'Cloud Storage' },
|
||||||
@ -129,54 +116,9 @@ const Products: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [categoryFilter, statusFilter, searchTerm, vendorFilter, fetchProducts]);
|
}, [categoryFilter, statusFilter, searchTerm, vendorFilter, fetchProducts]);
|
||||||
|
|
||||||
const handleCreateProduct = async () => {
|
|
||||||
try {
|
|
||||||
await apiService.createProduct({
|
|
||||||
...formData,
|
|
||||||
price: parseFloat(formData.price),
|
|
||||||
commissionRate: parseFloat(formData.commissionRate),
|
|
||||||
category: formData.category || 'other'
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.success('Product created successfully');
|
|
||||||
setIsCreateModalOpen(false);
|
|
||||||
setFormData({
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
category: 'other',
|
|
||||||
price: '',
|
|
||||||
commissionRate: '',
|
|
||||||
status: 'draft',
|
|
||||||
availability: 'available',
|
|
||||||
features: [],
|
|
||||||
specifications: {},
|
|
||||||
purchaseUrl: ''
|
|
||||||
});
|
|
||||||
fetchProducts();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Error creating product');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateProduct = async () => {
|
|
||||||
if (!editingProductId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await apiService.updateProduct(editingProductId, {
|
|
||||||
...formData,
|
|
||||||
price: parseFloat(formData.price),
|
|
||||||
commissionRate: parseFloat(formData.commissionRate),
|
|
||||||
category: formData.category || 'other'
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.success('Product updated successfully');
|
|
||||||
setIsEditModalOpen(false);
|
|
||||||
setEditingProductId(null);
|
|
||||||
fetchProducts();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Error updating product');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteProduct = async (productId: number) => {
|
const handleDeleteProduct = async (productId: number) => {
|
||||||
setDeletingProductId(productId);
|
setDeletingProductId(productId);
|
||||||
@ -241,92 +183,123 @@ const Products: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6 max-w-full">
|
<div className="p-6 space-y-8 max-w-full">
|
||||||
<div className="flex justify-between items-center mb-6">
|
{/* Header */}
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Products</h1>
|
<div className="mb-8">
|
||||||
<button
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-3">
|
||||||
onClick={() => setIsCreateModalOpen(true)}
|
Products
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
|
</h1>
|
||||||
>
|
<p className="text-gray-600 dark:text-gray-400 text-lg">
|
||||||
<Plus className="w-5 h-5" />
|
Manage cloud products and services offered by vendors
|
||||||
Add Product
|
</p>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Action Bar */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 mb-4">
|
<div className="flex items-center space-x-4">
|
||||||
<input
|
{/* Add Product button removed */}
|
||||||
type="text"
|
|
||||||
placeholder="Search products..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
value={categoryFilter}
|
|
||||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
|
||||||
className="min-w-[150px] px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
>
|
|
||||||
<option value="all">All Categories</option>
|
|
||||||
{categories.map((category) => (
|
|
||||||
<option key={category.value} value={category.value}>
|
|
||||||
{category.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
|
||||||
className="min-w-[150px] px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
>
|
|
||||||
<option value="all">All Statuses</option>
|
|
||||||
<option value="draft">Draft</option>
|
|
||||||
<option value="active">Active</option>
|
|
||||||
<option value="inactive">Inactive</option>
|
|
||||||
<option value="discontinued">Discontinued</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Vendor filter on separate line */}
|
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
|
||||||
<select
|
|
||||||
value={vendorFilter}
|
|
||||||
onChange={(e) => setVendorFilter(e.target.value)}
|
|
||||||
className="min-w-[200px] px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
>
|
|
||||||
<option value="all">All Vendors</option>
|
|
||||||
{vendors.map((vendor) => (
|
|
||||||
<option key={vendor.id} value={vendor.id.toString()}>
|
|
||||||
{vendor.company ? `${vendor.company} (${vendor.firstName} ${vendor.lastName})` : `${vendor.firstName} ${vendor.lastName}`}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* View Toggle */}
|
{/* View Toggle */}
|
||||||
<div className="flex justify-end mt-4">
|
<div className="flex border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
|
||||||
<div className="flex border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
|
<button
|
||||||
<button
|
onClick={() => setViewMode('list')}
|
||||||
onClick={() => setViewMode('list')}
|
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
className={`px-3 py-2 text-sm font-medium ${
|
viewMode === 'list'
|
||||||
viewMode === 'list'
|
? 'bg-blue-600 text-white'
|
||||||
? 'bg-blue-600 text-white'
|
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
|
}`}
|
||||||
}`}
|
>
|
||||||
|
List View
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
|
viewMode === 'grid'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Grid View
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Search Products
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search products..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-12 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Filter */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white transition-all"
|
||||||
>
|
>
|
||||||
List
|
<option value="all">All Categories</option>
|
||||||
</button>
|
{categories.map((category) => (
|
||||||
<button
|
<option key={category.value} value={category.value}>
|
||||||
onClick={() => setViewMode('grid')}
|
{category.label}
|
||||||
className={`px-3 py-2 text-sm font-medium ${
|
</option>
|
||||||
viewMode === 'grid'
|
))}
|
||||||
? 'bg-blue-600 text-white'
|
</select>
|
||||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
|
</div>
|
||||||
}`}
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white transition-all"
|
||||||
>
|
>
|
||||||
Grid
|
<option value="all">All Statuses</option>
|
||||||
</button>
|
{statuses.map((status) => (
|
||||||
|
<option key={status.value} value={status.value}>
|
||||||
|
{status.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vendor Filter */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Vendor
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={vendorFilter}
|
||||||
|
onChange={(e) => setVendorFilter(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white transition-all"
|
||||||
|
>
|
||||||
|
<option value="all">All Vendors</option>
|
||||||
|
{vendors.map((vendor) => (
|
||||||
|
<option key={vendor.id} value={vendor.id.toString()}>
|
||||||
|
{vendor.company ? `${vendor.company} (${vendor.firstName} ${vendor.lastName})` : `${vendor.firstName} ${vendor.lastName}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -420,27 +393,7 @@ const Products: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setFormData({
|
|
||||||
name: product.name,
|
|
||||||
description: product.description || '',
|
|
||||||
category: product.category,
|
|
||||||
price: product.price.toString(),
|
|
||||||
commissionRate: product.commissionRate.toString(),
|
|
||||||
status: product.status,
|
|
||||||
availability: product.availability,
|
|
||||||
features: product.features || [],
|
|
||||||
specifications: product.specifications || {},
|
|
||||||
purchaseUrl: product.purchaseUrl || ''
|
|
||||||
});
|
|
||||||
setEditingProductId(product.id);
|
|
||||||
setIsEditModalOpen(true);
|
|
||||||
}}
|
|
||||||
className="p-2 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
|
|
||||||
>
|
|
||||||
<Pencil className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
{(product.isAdminCreated || product.source === 'admin') ? (
|
{(product.isAdminCreated || product.source === 'admin') ? (
|
||||||
<button
|
<button
|
||||||
disabled
|
disabled
|
||||||
@ -549,27 +502,7 @@ const Products: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setFormData({
|
|
||||||
name: product.name,
|
|
||||||
description: product.description || '',
|
|
||||||
category: product.category,
|
|
||||||
price: product.price.toString(),
|
|
||||||
commissionRate: product.commissionRate.toString(),
|
|
||||||
status: product.status,
|
|
||||||
availability: product.availability,
|
|
||||||
features: product.features || [],
|
|
||||||
specifications: product.specifications || {},
|
|
||||||
purchaseUrl: product.purchaseUrl || ''
|
|
||||||
});
|
|
||||||
setEditingProductId(product.id);
|
|
||||||
setIsEditModalOpen(true);
|
|
||||||
}}
|
|
||||||
className="p-2 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
|
|
||||||
>
|
|
||||||
<Pencil className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
{(product.isAdminCreated || product.source === 'admin') ? (
|
{(product.isAdminCreated || product.source === 'admin') ? (
|
||||||
<button
|
<button
|
||||||
disabled
|
disabled
|
||||||
@ -633,258 +566,10 @@ const Products: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create Product Modal */}
|
|
||||||
{isCreateModalOpen && (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999] p-4" style={{ backdropFilter: 'blur(4px)' }}>
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
||||||
<h2 className="text-xl font-bold mb-4">Create New Product</h2>
|
|
||||||
<pre className="bg-gray-100 dark:bg-gray-700 p-3 rounded-lg text-sm text-gray-700 dark:text-gray-300 mb-4 font-mono">
|
|
||||||
{`Note: Products created by admin will be:
|
|
||||||
• Available to ALL vendors
|
|
||||||
• Non-deletable by vendors
|
|
||||||
• Displayed as "From Cloudtopiaa" in vendor portal`}
|
|
||||||
</pre>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Product Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
||||||
placeholder="Enter product name"
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Description</label>
|
|
||||||
<textarea
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
||||||
placeholder="Enter product description"
|
|
||||||
rows={3}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Category</label>
|
|
||||||
<select
|
|
||||||
value={formData.category}
|
|
||||||
onChange={(e) => setFormData({ ...formData, category: e.target.value as Product['category'] })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
>
|
|
||||||
<option value="">Select category</option>
|
|
||||||
{categories.map((category) => (
|
|
||||||
<option key={category.value} value={category.value}>
|
|
||||||
{category.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Price</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={formData.price}
|
|
||||||
onChange={(e) => setFormData({ ...formData, price: e.target.value })}
|
|
||||||
placeholder="0.00"
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Commission Rate (%)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={formData.commissionRate}
|
|
||||||
onChange={(e) => setFormData({ ...formData, commissionRate: e.target.value })}
|
|
||||||
placeholder="10.0"
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Status</label>
|
|
||||||
<select
|
|
||||||
value={formData.status}
|
|
||||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as Product['status'] })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
>
|
|
||||||
{statuses.map((status) => (
|
|
||||||
<option key={status.value} value={status.value}>
|
|
||||||
{status.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Availability</label>
|
|
||||||
<select
|
|
||||||
value={formData.availability}
|
|
||||||
onChange={(e) => setFormData({ ...formData, availability: e.target.value as Product['availability'] })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
>
|
|
||||||
{availabilities.map((availability) => (
|
|
||||||
<option key={availability.value} value={availability.value}>
|
|
||||||
{availability.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Purchase URL</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={formData.purchaseUrl}
|
|
||||||
onChange={(e) => setFormData({ ...formData, purchaseUrl: e.target.value })}
|
|
||||||
placeholder="https://example.com/product"
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end space-x-2 pt-4">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsCreateModalOpen(false)}
|
|
||||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 dark:text-white"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleCreateProduct}
|
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Create Product
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Edit Product Modal */}
|
|
||||||
{isEditModalOpen && (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999] p-4" style={{ backdropFilter: 'blur(4px)' }}>
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
||||||
<h2 className="text-xl font-bold mb-4">Edit Product</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Product Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
||||||
placeholder="Enter product name"
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Description</label>
|
|
||||||
<textarea
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
||||||
placeholder="Enter product description"
|
|
||||||
rows={3}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Category</label>
|
|
||||||
<select
|
|
||||||
value={formData.category}
|
|
||||||
onChange={(e) => setFormData({ ...formData, category: e.target.value as Product['category'] })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
>
|
|
||||||
{categories.map((category) => (
|
|
||||||
<option key={category.value} value={category.value}>
|
|
||||||
{category.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Price</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={formData.price}
|
|
||||||
onChange={(e) => setFormData({ ...formData, price: e.target.value })}
|
|
||||||
placeholder="0.00"
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Commission Rate (%)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={formData.commissionRate}
|
|
||||||
onChange={(e) => setFormData({ ...formData, commissionRate: e.target.value })}
|
|
||||||
placeholder="10.0"
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Status</label>
|
|
||||||
<select
|
|
||||||
value={formData.status}
|
|
||||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as Product['status'] })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
>
|
|
||||||
{statuses.map((status) => (
|
|
||||||
<option key={status.value} value={status.value}>
|
|
||||||
{status.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Availability</label>
|
|
||||||
<select
|
|
||||||
value={formData.availability}
|
|
||||||
onChange={(e) => setFormData({ ...formData, availability: e.target.value as Product['availability'] })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
>
|
|
||||||
{availabilities.map((availability) => (
|
|
||||||
<option key={availability.value} value={availability.value}>
|
|
||||||
{availability.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Purchase URL</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={formData.purchaseUrl}
|
|
||||||
onChange={(e) => setFormData({ ...formData, purchaseUrl: e.target.value })}
|
|
||||||
placeholder="https://example.com/product"
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end space-x-2 pt-4">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsEditModalOpen(false)}
|
|
||||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 dark:text-white"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleUpdateProduct}
|
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Update Product
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete Confirmation Modal */}
|
{/* Delete Confirmation Modal */}
|
||||||
{isDeleteModalOpen && (
|
{isDeleteModalOpen && (
|
||||||
|
|||||||
@ -251,117 +251,124 @@ const Resellers: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6 max-w-full">
|
<div className="p-6 space-y-8 max-w-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6">
|
<div className="mb-8">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-3">
|
||||||
Resellers
|
Resellers
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-gray-600 dark:text-gray-400 text-lg">
|
||||||
Manage resellers who register with our vendors
|
Manage resellers who register with our vendors
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||||
<div className="flex flex-col lg:flex-row gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="flex-1">
|
<div className="lg:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Search Resellers
|
||||||
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search resellers..."
|
placeholder="Search resellers..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full pl-12 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={handleSearch}
|
|
||||||
className="mt-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors text-sm"
|
|
||||||
>
|
|
||||||
Search
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Filter */}
|
{/* Status Filter */}
|
||||||
<div className="flex items-center space-x-2">
|
<div>
|
||||||
<Filter className="w-4 h-4 text-gray-400" />
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
<select
|
Filter by Status
|
||||||
value={statusFilter}
|
</label>
|
||||||
onChange={(e) => handleStatusFilterChange(e.target.value as any)}
|
<div className="relative">
|
||||||
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||||
>
|
<select
|
||||||
<option value="all">All Status</option>
|
value={statusFilter}
|
||||||
<option value="active">Active</option>
|
onChange={(e) => handleStatusFilterChange(e.target.value as any)}
|
||||||
<option value="pending">Pending</option>
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all appearance-none"
|
||||||
<option value="suspended">Suspended</option>
|
>
|
||||||
</select>
|
<option value="all">All Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="suspended">Suspended</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Vendor Filter */}
|
{/* Vendor Filter */}
|
||||||
<div className="flex items-center space-x-2">
|
<div>
|
||||||
<Building className="w-4 h-4 text-gray-400" />
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
<select
|
Filter by Vendor
|
||||||
value={vendorFilter}
|
</label>
|
||||||
onChange={(e) => handleVendorFilterChange(e.target.value)}
|
<div className="relative">
|
||||||
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
<Building className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||||
>
|
<select
|
||||||
<option value="all">All Vendors</option>
|
value={vendorFilter}
|
||||||
{vendors.map((vendor) => (
|
onChange={(e) => handleVendorFilterChange(e.target.value)}
|
||||||
<option key={vendor.id} value={vendor.id}>
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all appearance-none"
|
||||||
{vendor.company}
|
>
|
||||||
</option>
|
<option value="all">All Vendors</option>
|
||||||
))}
|
{vendors.map((vendor) => (
|
||||||
</select>
|
<option key={vendor.id} value={vendor.id}>
|
||||||
|
{vendor.company}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Resellers Table */}
|
{/* Resellers Table */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
Reseller
|
Reseller
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
Contact
|
Contact
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
Vendor
|
Vendor
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
Tier
|
Tier
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
Status
|
Status
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
Commission
|
Commission
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
Joined
|
Joined
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{filteredResellers.map((reseller) => (
|
{filteredResellers.map((reseller) => (
|
||||||
<tr key={reseller.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
<tr key={reseller.id} className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="w-10 h-10 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center">
|
<div className="w-12 h-12 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
<Users className="w-5 h-5 text-green-600 dark:text-green-400" />
|
<Users className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4">
|
<div className="ml-4 min-w-0">
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
<div className="text-sm font-semibold text-gray-900 dark:text-white truncate">
|
||||||
{reseller.companyName}
|
{reseller.companyName}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
@ -371,64 +378,78 @@ const Resellers: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm text-gray-900 dark:text-white">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Mail className="w-4 h-4 mr-2 text-gray-400" />
|
<Mail className="w-4 h-4 mr-2 text-gray-400 flex-shrink-0" />
|
||||||
{reseller.contactEmail}
|
<span className="text-sm text-gray-900 dark:text-white truncate max-w-48">
|
||||||
|
{reseller.contactEmail}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center mt-1">
|
<div className="flex items-center">
|
||||||
<Phone className="w-4 h-4 mr-2 text-gray-400" />
|
<Phone className="w-4 h-4 mr-2 text-gray-400 flex-shrink-0" />
|
||||||
{reseller.contactPhone}
|
<span className="text-sm text-gray-900 dark:text-white">
|
||||||
|
{reseller.contactPhone}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Link className="w-4 h-4 mr-2 text-gray-400" />
|
<Link className="w-4 h-4 mr-2 text-gray-400 flex-shrink-0" />
|
||||||
<span className="text-sm text-gray-900 dark:text-white">
|
<span className="text-sm text-gray-900 dark:text-white truncate max-w-32">
|
||||||
{reseller.channelPartner?.companyName || 'Unknown Vendor'}
|
{reseller.channelPartner?.companyName || 'Unknown Vendor'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getTierColor(reseller.tier)}`}>
|
<span className={`inline-flex px-3 py-1 text-xs font-semibold rounded-full ${getTierColor(reseller.tier)}`}>
|
||||||
{reseller.tier?.charAt(0).toUpperCase() + reseller.tier?.slice(1) || 'Unknown'}
|
{reseller.tier?.charAt(0).toUpperCase() + reseller.tier?.slice(1) || 'Unknown'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(reseller.status)}`}>
|
<span className={`inline-flex px-3 py-1 text-xs font-semibold rounded-full ${getStatusColor(reseller.status)}`}>
|
||||||
{reseller.status?.charAt(0).toUpperCase() + reseller.status?.slice(1) || 'Unknown'}
|
{reseller.status?.charAt(0).toUpperCase() + reseller.status?.slice(1) || 'Unknown'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
{reseller.commissionRate}%
|
<span className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{reseller.commissionRate}%
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
{new Date(reseller.createdAt).toLocaleDateString()}
|
<div className="flex items-center">
|
||||||
|
<Calendar className="w-4 h-4 mr-2 text-gray-400 flex-shrink-0" />
|
||||||
|
<span className="text-sm text-gray-900 dark:text-white">
|
||||||
|
{new Date(reseller.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="flex space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedReseller(reseller);
|
setSelectedReseller(reseller);
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
}}
|
}}
|
||||||
className="text-blue-600 hover:text-blue-900 dark:hover:text-blue-400"
|
className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors"
|
||||||
>
|
title="View Details"
|
||||||
<Eye className="w-4 h-4" />
|
>
|
||||||
</button>
|
<Eye className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||||
<button
|
</button>
|
||||||
onClick={() => setEditingReseller(reseller)}
|
<button
|
||||||
className="text-green-600 hover:text-green-900 dark:hover:text-green-400"
|
onClick={() => setEditingReseller(reseller)}
|
||||||
>
|
className="p-2 bg-amber-100 dark:bg-amber-900 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-800 transition-colors"
|
||||||
<Edit className="w-4 h-4" />
|
title="Edit"
|
||||||
</button>
|
>
|
||||||
<button
|
<Edit className="w-4 h-4 text-amber-600 dark:text-amber-400" />
|
||||||
onClick={() => handleDelete(reseller.id)}
|
</button>
|
||||||
className="text-red-600 hover:text-red-900 dark:hover:text-red-400"
|
<button
|
||||||
>
|
onClick={() => handleDelete(reseller.id)}
|
||||||
<Trash2 className="w-4 h-4" />
|
className="p-2 bg-red-100 dark:bg-red-900 rounded-lg hover:bg-red-200 dark:hover:bg-red-800 transition-colors"
|
||||||
</button>
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -436,6 +457,19 @@ const Resellers: React.FC = () => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{filteredResellers.length === 0 && (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<Users className="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">No resellers found</h3>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
{searchTerm || statusFilter !== 'all' || vendorFilter !== 'all'
|
||||||
|
? 'Try adjusting your search or filters'
|
||||||
|
: 'No resellers have been registered yet'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reseller Details Modal */}
|
{/* Reseller Details Modal */}
|
||||||
|
|||||||
@ -1,42 +1,323 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Users, Shield, Settings } from 'lucide-react';
|
import { Users, Shield, Settings, Search, Mail, Building, Calendar, UserCheck, UserX } from 'lucide-react';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
status: string;
|
||||||
|
company?: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastLogin?: string;
|
||||||
|
channelPartner?: {
|
||||||
|
id: number;
|
||||||
|
companyName: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pagination {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalItems: number;
|
||||||
|
itemsPerPage: number;
|
||||||
|
}
|
||||||
|
|
||||||
const AdminUsers: React.FC = () => {
|
const AdminUsers: React.FC = () => {
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [roleFilter, setRoleFilter] = useState<string>('all');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
|
const [pagination, setPagination] = useState<Pagination>({
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
totalItems: 0,
|
||||||
|
itemsPerPage: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: pagination.currentPage.toString(),
|
||||||
|
limit: pagination.itemsPerPage.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (searchTerm) params.append('search', searchTerm);
|
||||||
|
if (roleFilter !== 'all') params.append('role', roleFilter);
|
||||||
|
if (statusFilter !== 'all') params.append('status', statusFilter);
|
||||||
|
|
||||||
|
const response = await fetch(`http://localhost:5000/api/admin/users?${params}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch users');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setUsers(data.data.users);
|
||||||
|
setPagination(data.data.pagination);
|
||||||
|
} else {
|
||||||
|
throw new Error(data.message || 'Failed to fetch users');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching users:', error);
|
||||||
|
setError(error instanceof Error ? error.message : 'Failed to fetch users');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers();
|
||||||
|
}, [pagination.currentPage, pagination.itemsPerPage, searchTerm, roleFilter, statusFilter]);
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
setPagination(prev => ({ ...prev, currentPage: page }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRoleBadge = (role: string) => {
|
||||||
|
const roleConfig = {
|
||||||
|
'system_admin': { color: 'bg-red-100 text-red-800', label: 'System Admin' },
|
||||||
|
'channel_partner_admin': { color: 'bg-blue-100 text-blue-800', label: 'Channel Partner Admin' },
|
||||||
|
'vendor_admin': { color: 'bg-green-100 text-green-800', label: 'Vendor Admin' },
|
||||||
|
'reseller_admin': { color: 'bg-purple-100 text-purple-800', label: 'Reseller Admin' },
|
||||||
|
'customer': { color: 'bg-gray-100 text-gray-800', label: 'Customer' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = roleConfig[role as keyof typeof roleConfig] || { color: 'bg-gray-100 text-gray-800', label: role };
|
||||||
|
return <span className={`px-2 py-1 rounded-full text-xs font-medium ${config.color}`}>{config.label}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
const statusConfig = {
|
||||||
|
'active': { color: 'bg-green-100 text-green-800', label: 'Active' },
|
||||||
|
'pending': { color: 'bg-yellow-100 text-yellow-800', label: 'Pending' },
|
||||||
|
'inactive': { color: 'bg-red-100 text-red-800', label: 'Inactive' },
|
||||||
|
'suspended': { color: 'bg-orange-100 text-orange-800', label: 'Suspended' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = statusConfig[status as keyof typeof statusConfig] || { color: 'bg-gray-100 text-gray-800', label: status };
|
||||||
|
return <span className={`px-2 py-1 rounded-full text-xs font-medium ${config.color}`}>{config.label}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 p-6">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-purple-50 to-indigo-100 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900">
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 p-6">
|
||||||
<div className="p-6">
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
<div className="mb-8">
|
{/* Header */}
|
||||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">
|
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||||
System Users
|
<div>
|
||||||
</h1>
|
<h1 className="text-3xl font-bold text-slate-900 dark:text-white">System Users</h1>
|
||||||
<p className="text-slate-600 dark:text-slate-400">
|
<p className="text-slate-600 dark:text-slate-400 mt-1">
|
||||||
Manage all system users and their permissions
|
Manage all system users and their permissions
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
Total Users: {pagination.totalItems}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-slate-800 rounded-xl p-12 shadow-lg border border-slate-200 dark:border-slate-700">
|
{/* Filters */}
|
||||||
<div className="text-center">
|
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-6 border border-slate-200 dark:border-slate-700">
|
||||||
<div className="w-16 h-16 bg-purple-100 dark:bg-purple-900 rounded-full flex items-center justify-center mx-auto mb-4">
|
<div className="flex flex-col lg:flex-row gap-4 items-start lg:items-center">
|
||||||
<Users className="w-8 h-8 text-purple-600 dark:text-purple-400" />
|
<div className="flex-1">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search users by name, email, or company..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">
|
|
||||||
System Users Management
|
<div className="flex flex-wrap gap-3">
|
||||||
</h3>
|
<select
|
||||||
<p className="text-slate-600 dark:text-slate-400 mb-6">
|
value={roleFilter}
|
||||||
This page will allow you to manage all system users, their roles, and permissions.
|
onChange={(e) => setRoleFilter(e.target.value)}
|
||||||
</p>
|
className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors"
|
||||||
<div className="flex items-center justify-center space-x-4 text-sm text-slate-500 dark:text-slate-400">
|
>
|
||||||
<div className="flex items-center space-x-2">
|
<option value="all">All Roles</option>
|
||||||
<Shield className="w-4 h-4" />
|
<option value="system_admin">System Admin</option>
|
||||||
<span>Role Management</span>
|
<option value="channel_partner_admin">Channel Partner Admin</option>
|
||||||
</div>
|
<option value="vendor_admin">Vendor Admin</option>
|
||||||
<div className="flex items-center space-x-2">
|
<option value="reseller_admin">Reseller Admin</option>
|
||||||
<Settings className="w-4 h-4" />
|
<option value="customer">Customer</option>
|
||||||
<span>Permissions</span>
|
</select>
|
||||||
</div>
|
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors"
|
||||||
|
>
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="inactive">Inactive</option>
|
||||||
|
<option value="suspended">Suspended</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Users Table */}
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-lg shadow border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[800px]">
|
||||||
|
<thead className="bg-slate-50 dark:bg-slate-700/50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||||
|
User
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||||
|
Role
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||||
|
Company
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||||
|
Joined
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||||
|
Last Login
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white dark:bg-slate-800 divide-y divide-slate-200 dark:divide-slate-700">
|
||||||
|
{users.map((user) => (
|
||||||
|
<tr key={user.id} className="hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors">
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-8 h-8 bg-gradient-to-r from-purple-500 to-indigo-500 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-white text-sm font-medium">
|
||||||
|
{user.firstName && user.firstName.charAt(0) ? user.firstName.charAt(0).toUpperCase() : 'U'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-slate-900 dark:text-white truncate">
|
||||||
|
{user.firstName && user.lastName ? `${user.firstName} ${user.lastName}` : 'Unknown User'}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-500 dark:text-slate-400 truncate flex items-center">
|
||||||
|
<Mail className="w-3 h-3 mr-1" />
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
{getRoleBadge(user.role)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
{getStatusBadge(user.status)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-sm text-slate-900 dark:text-white">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Building className="w-4 h-4 mr-1 text-slate-400" />
|
||||||
|
{user.company || user.channelPartner?.companyName || 'N/A'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-sm text-slate-900 dark:text-white">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Calendar className="w-4 h-4 mr-1 text-slate-400" />
|
||||||
|
{formatDate(user.createdAt)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4 text-sm text-slate-900 dark:text-white">
|
||||||
|
<div className="flex items-center">
|
||||||
|
{user.lastLogin ? (
|
||||||
|
<>
|
||||||
|
<UserCheck className="w-4 h-4 mr-1 text-green-400" />
|
||||||
|
{formatDate(user.lastLogin)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserX className="w-4 h-4 mr-1 text-slate-400" />
|
||||||
|
Never
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{users.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Users className="w-12 h-12 text-slate-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-slate-900 dark:text-white mb-2">No users found</h3>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400">
|
||||||
|
Try adjusting your search criteria or filters.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{pagination.totalPages > 1 && (
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4 border border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
Showing {((pagination.currentPage - 1) * pagination.itemsPerPage) + 1} to{' '}
|
||||||
|
{Math.min(pagination.currentPage * pagination.itemsPerPage, pagination.totalItems)} of{' '}
|
||||||
|
{pagination.totalItems} results
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handlePageChange(pagination.currentPage - 1)}
|
||||||
|
disabled={pagination.currentPage === 1}
|
||||||
|
className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePageChange(pagination.currentPage + 1)}
|
||||||
|
disabled={pagination.currentPage === pagination.totalPages}
|
||||||
|
className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -185,65 +185,73 @@ const VendorRequests: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6 max-w-full">
|
<div className="p-6 space-y-8 max-w-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6">
|
<div className="mb-8">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-3">
|
||||||
Vendor Requests
|
Vendor Requests
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-gray-600 dark:text-gray-400 text-lg">
|
||||||
Review and manage vendor registration requests
|
Review and manage vendor registration requests
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error Display */}
|
{/* Error Display */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4 mb-6">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<AlertCircle className="w-5 h-5 text-red-400 mr-2" />
|
<AlertCircle className="w-5 h-5 text-red-400 mr-3 flex-shrink-0" />
|
||||||
<p className="text-red-800 dark:text-red-200">{error}</p>
|
<p className="text-red-800 dark:text-red-200">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||||
<div className="flex flex-col lg:flex-row gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="flex-1">
|
<div className="lg:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Search Vendors
|
||||||
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search vendors..."
|
placeholder="Search vendors..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
|
className="w-full pl-12 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Filter */}
|
{/* Status Filter */}
|
||||||
<div className="flex items-center space-x-2">
|
<div>
|
||||||
<Filter className="w-4 h-4 text-gray-400" />
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
<select
|
Filter by Status
|
||||||
value={statusFilter}
|
</label>
|
||||||
onChange={(e) => setStatusFilter(e.target.value as any)}
|
<div className="relative">
|
||||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||||
>
|
<select
|
||||||
<option value="all" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">All Status</option>
|
value={statusFilter}
|
||||||
<option value="pending" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Pending</option>
|
onChange={(e) => setStatusFilter(e.target.value as any)}
|
||||||
<option value="approved" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Approved</option>
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white transition-all appearance-none"
|
||||||
<option value="rejected" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Rejected</option>
|
>
|
||||||
</select>
|
<option value="all" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">All Status</option>
|
||||||
|
<option value="pending" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Pending</option>
|
||||||
|
<option value="approved" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Approved</option>
|
||||||
|
<option value="rejected" className="bg-white dark:bg-gray-700 text-gray-900 dark:text-white">Rejected</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Vendor List */}
|
{/* Vendor List */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
Vendor Requests ({filteredVendors.length})
|
Vendor Requests ({filteredVendors.length})
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@ -252,46 +260,48 @@ const VendorRequests: React.FC = () => {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
Vendor
|
Vendor
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
Company
|
Company
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
Role
|
Role
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
Status
|
Status
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
Date
|
Date
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{filteredVendors.map((vendor) => (
|
{filteredVendors.map((vendor) => (
|
||||||
<tr key={vendor.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
<tr key={vendor.id} className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
<Users className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
<Users className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4">
|
<div className="ml-4 min-w-0">
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
<div className="text-sm font-semibold text-gray-900 dark:text-white truncate">
|
||||||
{vendor.firstName} {vendor.lastName}
|
{vendor.firstName} {vendor.lastName}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
<div className="text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||||
{vendor.email}
|
{vendor.email}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm text-gray-900 dark:text-white">{vendor.company}</div>
|
<div className="text-sm font-medium text-gray-900 dark:text-white truncate max-w-32">
|
||||||
|
{vendor.company}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm text-gray-900 dark:text-white">
|
<div className="text-sm text-gray-900 dark:text-white">
|
||||||
@ -299,40 +309,48 @@ const VendorRequests: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(vendor.status)}`}>
|
<span className={`inline-flex px-3 py-1 text-xs font-semibold rounded-full ${getStatusColor(vendor.status)}`}>
|
||||||
{vendor.status.charAt(0).toUpperCase() + vendor.status.slice(1)}
|
{vendor.status.charAt(0).toUpperCase() + vendor.status.slice(1)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
{new Date(vendor.createdAt).toLocaleDateString()}
|
<div className="flex items-center">
|
||||||
|
<Calendar className="w-4 h-4 mr-2 text-gray-400 flex-shrink-0" />
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{new Date(vendor.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="flex space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedVendor(vendor);
|
setSelectedVendor(vendor);
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
}}
|
}}
|
||||||
className="text-blue-600 hover:text-blue-900 dark:hover:text-blue-400"
|
className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors"
|
||||||
|
title="View Details"
|
||||||
>
|
>
|
||||||
<Eye className="w-4 h-4" />
|
<Eye className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||||
</button>
|
</button>
|
||||||
{vendor.status === 'pending' && (
|
{vendor.status === 'pending' && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => approveVendorRequest(vendor.id)}
|
onClick={() => approveVendorRequest(vendor.id)}
|
||||||
className="text-green-600 hover:text-green-900 dark:hover:text-green-400"
|
className="p-2 bg-green-100 dark:bg-green-900 rounded-lg hover:bg-green-200 dark:hover:bg-green-800 transition-colors"
|
||||||
|
title="Approve"
|
||||||
>
|
>
|
||||||
<CheckCircle className="w-4 h-4" />
|
<CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedVendor(vendor);
|
setSelectedVendor(vendor);
|
||||||
setShowRejectionModal(true);
|
setShowRejectionModal(true);
|
||||||
}}
|
}}
|
||||||
className="text-red-600 hover:text-red-900 dark:hover:text-red-400"
|
className="p-2 bg-red-100 dark:bg-red-900 rounded-lg hover:bg-red-200 dark:hover:bg-red-800 transition-colors"
|
||||||
|
title="Reject"
|
||||||
>
|
>
|
||||||
<XCircle className="w-4 h-4" />
|
<XCircle className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
505
src/pages/reseller/Certifications.tsx
Normal file
505
src/pages/reseller/Certifications.tsx
Normal file
@ -0,0 +1,505 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useAppSelector } from '../../store/hooks';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import {
|
||||||
|
Award,
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
Calendar,
|
||||||
|
BookOpen,
|
||||||
|
Star,
|
||||||
|
TrendingUp,
|
||||||
|
Filter,
|
||||||
|
Search,
|
||||||
|
RefreshCw,
|
||||||
|
Loader2
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface TrainingCertificate {
|
||||||
|
id: number;
|
||||||
|
courseId: number;
|
||||||
|
certificateNumber: string;
|
||||||
|
issuedAt: string;
|
||||||
|
completionDate: string;
|
||||||
|
grade?: string;
|
||||||
|
score?: number;
|
||||||
|
course: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
level: string;
|
||||||
|
category: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CertificateStats {
|
||||||
|
totalCertificates: number;
|
||||||
|
thisMonthCertificates: number;
|
||||||
|
thisYearCertificates: number;
|
||||||
|
averageScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Certifications: React.FC = () => {
|
||||||
|
const { user } = useAppSelector((state) => state.auth);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [certificates, setCertificates] = useState<TrainingCertificate[]>([]);
|
||||||
|
const [stats, setStats] = useState<CertificateStats>({
|
||||||
|
totalCertificates: 0,
|
||||||
|
thisMonthCertificates: 0,
|
||||||
|
thisYearCertificates: 0,
|
||||||
|
averageScore: 0
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||||
|
const [selectedLevel, setSelectedLevel] = useState<string>('all');
|
||||||
|
const [selectedGrade, setSelectedGrade] = useState<string>('all');
|
||||||
|
const [sortBy, setSortBy] = useState<string>('issuedAt');
|
||||||
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (!token) {
|
||||||
|
navigate('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [user, navigate]);
|
||||||
|
|
||||||
|
// Fetch certificates and stats
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCertificates = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
toast.error('Authentication required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch certificates
|
||||||
|
const certificatesResponse = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/training-new/reseller-certificates`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (certificatesResponse.ok) {
|
||||||
|
const certificatesData = await certificatesResponse.json();
|
||||||
|
console.log('Certificates response:', certificatesData);
|
||||||
|
setCertificates(certificatesData.data.certificates || []);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch certificates:', certificatesResponse.status);
|
||||||
|
toast.error('Failed to load certificates');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch certificate stats
|
||||||
|
const statsResponse = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/training-new/certificates/stats`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (statsResponse.ok) {
|
||||||
|
const statsData = await statsResponse.json();
|
||||||
|
setStats(statsData.data);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch certificate stats:', statsResponse.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching certificates:', error);
|
||||||
|
toast.error('Failed to load certificates');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCertificates();
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
// Filter and sort certificates
|
||||||
|
const filteredAndSortedCertificates = certificates
|
||||||
|
.filter(certificate => {
|
||||||
|
const matchesSearch = certificate.course.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
certificate.certificateNumber.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
const matchesCategory = selectedCategory === 'all' || certificate.course.category === selectedCategory;
|
||||||
|
const matchesLevel = selectedLevel === 'all' || certificate.course.level === selectedLevel;
|
||||||
|
const matchesGrade = selectedGrade === 'all' || certificate.grade === selectedGrade;
|
||||||
|
|
||||||
|
return matchesSearch && matchesCategory && matchesLevel && matchesGrade;
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
let aValue: any, bValue: any;
|
||||||
|
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'issuedAt':
|
||||||
|
aValue = new Date(a.issuedAt).getTime();
|
||||||
|
bValue = new Date(b.issuedAt).getTime();
|
||||||
|
break;
|
||||||
|
case 'completionDate':
|
||||||
|
aValue = new Date(a.completionDate).getTime();
|
||||||
|
bValue = new Date(b.completionDate).getTime();
|
||||||
|
break;
|
||||||
|
case 'score':
|
||||||
|
aValue = a.score || 0;
|
||||||
|
bValue = b.score || 0;
|
||||||
|
break;
|
||||||
|
case 'courseTitle':
|
||||||
|
aValue = a.course.title.toLowerCase();
|
||||||
|
bValue = b.course.title.toLowerCase();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
aValue = new Date(a.issuedAt).getTime();
|
||||||
|
bValue = new Date(b.issuedAt).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortOrder === 'asc') {
|
||||||
|
return aValue > bValue ? 1 : -1;
|
||||||
|
} else {
|
||||||
|
return aValue < bValue ? 1 : -1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download certificate
|
||||||
|
const downloadCertificate = async (certificateId: number) => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (!token) {
|
||||||
|
toast.error('Authentication required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.loading('Generating certificate...', { id: 'download' });
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/training-new/certificates/${certificateId}/download`,
|
||||||
|
{
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const contentDisposition = response.headers.get('content-disposition');
|
||||||
|
const filename = contentDisposition
|
||||||
|
? contentDisposition.split('filename=')[1]?.replace(/"/g, '')
|
||||||
|
: `certificate-${certificateId}.pdf`;
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast.success('Certificate downloaded successfully!', { id: 'download' });
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
toast.error(errorData.message || 'Failed to download certificate', { id: 'download' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading certificate:', error);
|
||||||
|
toast.error('Failed to download certificate', { id: 'download' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get unique categories, levels, and grades
|
||||||
|
const categories = ['all', ...Array.from(new Set(certificates.map(c => c.course.category)))];
|
||||||
|
const levels = ['all', ...Array.from(new Set(certificates.map(c => c.course.level)))];
|
||||||
|
const grades = ['all', ...Array.from(new Set(certificates.map(c => c.grade).filter(Boolean)))];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 className="w-12 h-12 animate-spin mx-auto mb-4 text-primary-600" />
|
||||||
|
<p className="text-lg text-gray-600">Loading your certifications...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between space-y-4 sm:space-y-0">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="p-3 bg-primary-100 dark:bg-primary-900 rounded-lg">
|
||||||
|
<Award className="w-8 h-8 text-primary-600 dark:text-primary-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-secondary-900 dark:text-white">
|
||||||
|
Certifications
|
||||||
|
</h1>
|
||||||
|
<p className="text-secondary-600 dark:text-secondary-400 mt-1">
|
||||||
|
Manage your professional certifications and achievements
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="inline-flex items-center px-4 py-2 bg-secondary-100 text-secondary-700 text-sm font-medium rounded-md hover:bg-secondary-200 dark:bg-secondary-800 dark:text-secondary-300 dark:hover:bg-secondary-700 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Overview */}
|
||||||
|
<div className="grid md:grid-cols-4 gap-6">
|
||||||
|
<div className="card p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">Total Certificates</h3>
|
||||||
|
<Award className="w-6 h-6 text-primary-600 dark:text-primary-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-secondary-900 dark:text-white">{stats.totalCertificates}</div>
|
||||||
|
<div className="text-sm text-secondary-500 dark:text-secondary-400">earned</div>
|
||||||
|
</div>
|
||||||
|
<div className="card p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">This Month</h3>
|
||||||
|
<Calendar className="w-6 h-6 text-success-600 dark:text-success-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-secondary-900 dark:text-white">{stats.thisMonthCertificates}</div>
|
||||||
|
<div className="text-sm text-secondary-500 dark:text-secondary-400">new certificates</div>
|
||||||
|
</div>
|
||||||
|
<div className="card p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">This Year</h3>
|
||||||
|
<TrendingUp className="w-6 h-6 text-warning-600 dark:text-warning-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-secondary-900 dark:text-white">{stats.thisYearCertificates}</div>
|
||||||
|
<div className="text-sm text-secondary-500 dark:text-secondary-400">achievements</div>
|
||||||
|
</div>
|
||||||
|
<div className="card p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">Average Score</h3>
|
||||||
|
<Star className="w-6 h-6 text-danger-600 dark:text-danger-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-secondary-900 dark:text-white">{stats.averageScore}%</div>
|
||||||
|
<div className="text-sm text-secondary-500 dark:text-secondary-400">overall performance</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters and Search */}
|
||||||
|
<div className="card p-6">
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search certificates or course titles..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Filter */}
|
||||||
|
<div className="min-w-[150px]">
|
||||||
|
<select
|
||||||
|
value={selectedCategory}
|
||||||
|
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<option key={category} value={category}>
|
||||||
|
{category === 'all' ? 'All Categories' : category}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Level Filter */}
|
||||||
|
<div className="min-w-[150px]">
|
||||||
|
<select
|
||||||
|
value={selectedLevel}
|
||||||
|
onChange={(e) => setSelectedLevel(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
{levels.map((level) => (
|
||||||
|
<option key={level} value={level}>
|
||||||
|
{level === 'all' ? 'All Levels' : level}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grade Filter */}
|
||||||
|
<div className="min-w-[150px]">
|
||||||
|
<select
|
||||||
|
value={selectedGrade}
|
||||||
|
onChange={(e) => setSelectedGrade(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
{grades.map((grade) => (
|
||||||
|
<option key={grade} value={grade}>
|
||||||
|
{grade === 'all' ? 'All Grades' : grade}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort */}
|
||||||
|
<div className="min-w-[150px]">
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="issuedAt">Issue Date</option>
|
||||||
|
<option value="completionDate">Completion Date</option>
|
||||||
|
<option value="score">Score</option>
|
||||||
|
<option value="courseTitle">Course Title</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort Order */}
|
||||||
|
<div className="min-w-[100px]">
|
||||||
|
<select
|
||||||
|
value={sortOrder}
|
||||||
|
onChange={(e) => setSortOrder(e.target.value as 'asc' | 'desc')}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="desc">Desc</option>
|
||||||
|
<option value="asc">Asc</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Certificates Grid */}
|
||||||
|
{filteredAndSortedCertificates.length > 0 ? (
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{filteredAndSortedCertificates.map((certificate) => (
|
||||||
|
<div key={certificate.id} className="card overflow-hidden hover:shadow-lg transition-all duration-200">
|
||||||
|
{/* Certificate Header */}
|
||||||
|
<div className="bg-gradient-to-r from-primary-600 to-primary-700 p-6 text-white">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<Award className="w-8 h-8" />
|
||||||
|
<span className="px-3 py-1 bg-white bg-opacity-20 rounded-full text-sm font-medium">
|
||||||
|
{certificate.grade || 'Passed'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold mb-2">{certificate.course.title}</h3>
|
||||||
|
<p className="text-primary-100 text-sm">{certificate.course.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Certificate Details */}
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Level
|
||||||
|
</label>
|
||||||
|
<p className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{certificate.course.level}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
<p className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{certificate.course.category}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Score
|
||||||
|
</label>
|
||||||
|
<p className="text-sm font-semibold text-success-600 dark:text-success-400">
|
||||||
|
{certificate.score || 'N/A'}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Issued
|
||||||
|
</label>
|
||||||
|
<p className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{new Date(certificate.issuedAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Certificate Number
|
||||||
|
</label>
|
||||||
|
<p className="text-xs font-mono text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-800 p-2 rounded">
|
||||||
|
{certificate.certificateNumber}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => downloadCertificate(certificate.id)}
|
||||||
|
className="flex-1 inline-flex items-center justify-center px-4 py-2 bg-primary-600 text-white text-sm font-medium rounded-md hover:bg-primary-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/reseller-dashboard/training`)}
|
||||||
|
className="inline-flex items-center justify-center px-4 py-2 bg-secondary-100 text-secondary-700 text-sm font-medium rounded-md hover:bg-secondary-200 dark:bg-secondary-800 dark:text-secondary-300 dark:hover:bg-secondary-700 transition-colors"
|
||||||
|
>
|
||||||
|
<BookOpen className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card p-12 text-center">
|
||||||
|
<Award className="w-16 h-16 mx-auto mb-4 text-gray-400" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||||
|
{searchTerm || selectedCategory !== 'all' || selectedLevel !== 'all' || selectedGrade !== 'all'
|
||||||
|
? 'No certificates match your filters'
|
||||||
|
: 'No certificates yet'
|
||||||
|
}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 mb-6">
|
||||||
|
{searchTerm || selectedCategory !== 'all' || selectedLevel !== 'all' || selectedGrade !== 'all'
|
||||||
|
? 'Try adjusting your search criteria or filters.'
|
||||||
|
: 'Complete your first training course to earn your first certificate!'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
{!searchTerm && selectedCategory === 'all' && selectedLevel === 'all' && selectedGrade === 'all' && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/reseller-dashboard/training')}
|
||||||
|
className="inline-flex items-center px-6 py-3 bg-primary-600 text-white text-lg font-medium rounded-lg hover:bg-primary-700 transition-colors"
|
||||||
|
>
|
||||||
|
<BookOpen className="w-5 h-5 mr-2" />
|
||||||
|
Start Training
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Debug Info (remove in production) */}
|
||||||
|
<div className="card p-4 bg-gray-100 dark:bg-gray-800">
|
||||||
|
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Debug Info</h4>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
||||||
|
<p>Total certificates: {certificates.length}</p>
|
||||||
|
<p>Filtered certificates: {filteredAndSortedCertificates.length}</p>
|
||||||
|
<p>Search term: "{searchTerm}"</p>
|
||||||
|
<p>Category filter: {selectedCategory}</p>
|
||||||
|
<p>Level filter: {selectedLevel}</p>
|
||||||
|
<p>Grade filter: {selectedGrade}</p>
|
||||||
|
<p>Sort by: {sortBy} ({sortOrder})</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Certifications;
|
||||||
@ -1,116 +1,90 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useAppSelector, useAppDispatch } from '../../store/hooks';
|
||||||
|
import { fetchCustomers, addCustomer } from '../../store/reseller/resellerDashboardSlice';
|
||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Search,
|
Search,
|
||||||
Filter,
|
Filter,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Mail,
|
Edit,
|
||||||
Phone,
|
Trash2,
|
||||||
MapPin,
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
|
X,
|
||||||
|
DollarSign,
|
||||||
Calendar,
|
Calendar,
|
||||||
DollarSign,
|
TrendingUp
|
||||||
TrendingUp,
|
|
||||||
CheckCircle,
|
|
||||||
XCircle,
|
|
||||||
Clock
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
interface Customer {
|
interface Customer {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
|
||||||
company: string;
|
company: string;
|
||||||
status: 'active' | 'inactive' | 'pending';
|
status: 'active' | 'inactive' | 'pending';
|
||||||
totalSpent: number;
|
totalSpent: number;
|
||||||
lastPurchase: string;
|
lastPurchase: string;
|
||||||
joinDate: string;
|
products: number;
|
||||||
location: string;
|
region: string;
|
||||||
avatar: string;
|
phone?: string;
|
||||||
|
address?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
zipCode?: string;
|
||||||
|
country?: string;
|
||||||
|
notes?: string;
|
||||||
|
customerSince?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockCustomers: Customer[] = [
|
interface AddCustomerForm {
|
||||||
{
|
firstName: string;
|
||||||
id: '1',
|
lastName: string;
|
||||||
name: 'John Smith',
|
email: string;
|
||||||
email: 'john.smith@techcorp.com',
|
phone: string;
|
||||||
phone: '+1 (555) 123-4567',
|
company: string;
|
||||||
company: 'TechCorp Solutions',
|
address: string;
|
||||||
status: 'active',
|
city: string;
|
||||||
totalSpent: 12500,
|
state: string;
|
||||||
lastPurchase: '2025-01-15T10:30:00Z',
|
zipCode: string;
|
||||||
joinDate: '2024-03-15T00:00:00Z',
|
country: string;
|
||||||
location: 'New York, NY',
|
notes: string;
|
||||||
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face'
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
name: 'Sarah Johnson',
|
|
||||||
email: 'sarah.johnson@dataflow.com',
|
|
||||||
phone: '+1 (555) 234-5678',
|
|
||||||
company: 'DataFlow Inc',
|
|
||||||
status: 'active',
|
|
||||||
totalSpent: 8900,
|
|
||||||
lastPurchase: '2025-01-14T14:20:00Z',
|
|
||||||
joinDate: '2024-05-20T00:00:00Z',
|
|
||||||
location: 'San Francisco, CA',
|
|
||||||
avatar: 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
name: 'Mike Wilson',
|
|
||||||
email: 'mike.wilson@cloudtech.com',
|
|
||||||
phone: '+1 (555) 345-6789',
|
|
||||||
company: 'CloudTech Ltd',
|
|
||||||
status: 'pending',
|
|
||||||
totalSpent: 0,
|
|
||||||
lastPurchase: '',
|
|
||||||
joinDate: '2025-01-10T00:00:00Z',
|
|
||||||
location: 'Austin, TX',
|
|
||||||
avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '4',
|
|
||||||
name: 'Emily Davis',
|
|
||||||
email: 'emily.davis@innovate.com',
|
|
||||||
phone: '+1 (555) 456-7890',
|
|
||||||
company: 'InnovateSoft',
|
|
||||||
status: 'active',
|
|
||||||
totalSpent: 15600,
|
|
||||||
lastPurchase: '2025-01-13T09:15:00Z',
|
|
||||||
joinDate: '2024-02-10T00:00:00Z',
|
|
||||||
location: 'Seattle, WA',
|
|
||||||
avatar: 'https://images.unsplash.com/photo-1494790108755-2616b612b786?w=150&h=150&fit=crop&crop=face'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '5',
|
|
||||||
name: 'David Brown',
|
|
||||||
email: 'david.brown@netsol.com',
|
|
||||||
phone: '+1 (555) 567-8901',
|
|
||||||
company: 'NetSolutions',
|
|
||||||
status: 'inactive',
|
|
||||||
totalSpent: 3200,
|
|
||||||
lastPurchase: '2024-11-20T16:45:00Z',
|
|
||||||
joinDate: '2024-08-15T00:00:00Z',
|
|
||||||
location: 'Chicago, IL',
|
|
||||||
avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=150&h=150&fit=crop&crop=face'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const Customers: React.FC = () => {
|
const Customers: React.FC = () => {
|
||||||
const [customers, setCustomers] = useState<Customer[]>(mockCustomers);
|
const dispatch = useAppDispatch();
|
||||||
|
const { customers, isLoading, error } = useAppSelector((state: any) => state.resellerDashboard);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
const [sortBy, setSortBy] = useState<string>('name');
|
const [sortBy, setSortBy] = useState<string>('customerSince');
|
||||||
|
const [isAddCustomerModalOpen, setIsAddCustomerModalOpen] = useState(false);
|
||||||
|
const [addCustomerForm, setAddCustomerForm] = useState<AddCustomerForm>({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
company: '',
|
||||||
|
address: '',
|
||||||
|
city: '',
|
||||||
|
state: '',
|
||||||
|
zipCode: '',
|
||||||
|
country: '',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
|
||||||
const filteredCustomers = customers.filter(customer => {
|
useEffect(() => {
|
||||||
|
dispatch(fetchCustomers());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const filteredCustomers = customers?.filter((customer: Customer) => {
|
||||||
const matchesSearch = customer.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const matchesSearch = customer.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
customer.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
customer.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
customer.company.toLowerCase().includes(searchTerm.toLowerCase());
|
customer.company.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
const matchesStatus = statusFilter === 'all' || customer.status === statusFilter;
|
const matchesStatus = statusFilter === 'all' || customer.status === statusFilter;
|
||||||
return matchesSearch && matchesStatus;
|
return matchesSearch && matchesStatus;
|
||||||
});
|
}) || [];
|
||||||
|
|
||||||
const sortedCustomers = [...filteredCustomers].sort((a, b) => {
|
const sortedCustomers = [...filteredCustomers].sort((a, b) => {
|
||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
@ -120,13 +94,79 @@ const Customers: React.FC = () => {
|
|||||||
return a.company.localeCompare(b.company);
|
return a.company.localeCompare(b.company);
|
||||||
case 'totalSpent':
|
case 'totalSpent':
|
||||||
return b.totalSpent - a.totalSpent;
|
return b.totalSpent - a.totalSpent;
|
||||||
case 'joinDate':
|
case 'customerSince':
|
||||||
return new Date(b.joinDate).getTime() - new Date(a.joinDate).getTime();
|
return new Date(b.customerSince || b.lastPurchase).getTime() - new Date(a.customerSince || a.lastPurchase).getTime();
|
||||||
default:
|
default:
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleAddCustomer = () => {
|
||||||
|
setIsAddCustomerModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddCustomerSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/resellers/customers`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
firstName: addCustomerForm.firstName,
|
||||||
|
lastName: addCustomerForm.lastName,
|
||||||
|
email: addCustomerForm.email,
|
||||||
|
phone: addCustomerForm.phone,
|
||||||
|
company: addCustomerForm.company,
|
||||||
|
address: addCustomerForm.address,
|
||||||
|
city: addCustomerForm.city,
|
||||||
|
state: addCustomerForm.state,
|
||||||
|
zipCode: addCustomerForm.zipCode,
|
||||||
|
country: addCustomerForm.country,
|
||||||
|
notes: addCustomerForm.notes
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
toast.success('Customer added successfully!');
|
||||||
|
setIsAddCustomerModalOpen(false);
|
||||||
|
// Reset form
|
||||||
|
setAddCustomerForm({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
company: '',
|
||||||
|
address: '',
|
||||||
|
city: '',
|
||||||
|
state: '',
|
||||||
|
zipCode: '',
|
||||||
|
country: '',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
// Refresh customer list
|
||||||
|
dispatch(fetchCustomers());
|
||||||
|
} else {
|
||||||
|
toast.error(data.message || 'Failed to add customer');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding customer:', error);
|
||||||
|
toast.error('Failed to add customer');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (field: keyof AddCustomerForm, value: string) => {
|
||||||
|
setAddCustomerForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
[field]: value
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'active':
|
case 'active':
|
||||||
@ -154,6 +194,7 @@ const Customers: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
|
if (!dateString) return 'No purchases';
|
||||||
return new Date(dateString).toLocaleDateString('en-US', {
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
@ -170,6 +211,45 @@ const Customers: React.FC = () => {
|
|||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Calculate summary statistics from API data
|
||||||
|
const totalCustomers = customers?.length || 0;
|
||||||
|
const activeCustomers = customers?.filter((c: Customer) => c.status === 'active').length || 0;
|
||||||
|
const totalRevenue = customers?.reduce((sum: number, c: Customer) => sum + c.totalSpent, 0) || 0;
|
||||||
|
const avgCustomerValue = totalCustomers > 0 ? totalRevenue / totalCustomers : 0;
|
||||||
|
|
||||||
|
// Calculate average customer age
|
||||||
|
const avgCustomerAge = totalCustomers > 0 ?
|
||||||
|
customers?.reduce((sum: number, c: Customer) => {
|
||||||
|
const customerSince = c.customerSince || c.lastPurchase;
|
||||||
|
if (customerSince && customerSince !== 'N/A') {
|
||||||
|
const days = Math.floor((new Date().getTime() - new Date(customerSince).getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
return sum + days;
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}, 0) / totalCustomers : 0;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||||
|
<p className="text-secondary-600 dark:text-secondary-400">Loading customers...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-center">
|
||||||
|
<XCircle className="h-12 w-12 text-danger-500 mx-auto mb-4" />
|
||||||
|
<p className="text-danger-600 dark:text-danger-400">Error loading customers: {error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -182,7 +262,10 @@ const Customers: React.FC = () => {
|
|||||||
Manage your customer relationships and accounts
|
Manage your customer relationships and accounts
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="inline-flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white text-sm font-medium rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-secondary-800">
|
<button
|
||||||
|
onClick={handleAddCustomer}
|
||||||
|
className="inline-flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white text-sm font-medium rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-secondary-800"
|
||||||
|
>
|
||||||
<UserPlus className="w-4 h-4 mr-2" />
|
<UserPlus className="w-4 h-4 mr-2" />
|
||||||
Add Customer
|
Add Customer
|
||||||
</button>
|
</button>
|
||||||
@ -197,10 +280,10 @@ const Customers: React.FC = () => {
|
|||||||
Total Customers
|
Total Customers
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||||
{customers.length}
|
{totalCustomers}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center">
|
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900 rounded-lg flex items-center justify-center">
|
||||||
<Users className="w-6 h-6 text-primary-600 dark:text-primary-400" />
|
<Users className="w-6 h-6 text-primary-600 dark:text-primary-400" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -213,10 +296,10 @@ const Customers: React.FC = () => {
|
|||||||
Active Customers
|
Active Customers
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||||
{customers.filter(c => c.status === 'active').length}
|
{activeCustomers}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-12 h-12 bg-success-100 dark:bg-success-900 rounded-full flex items-center justify-center">
|
<div className="w-12 h-12 bg-success-100 dark:bg-success-900 rounded-lg flex items-center justify-center">
|
||||||
<CheckCircle className="w-6 h-6 text-success-600 dark:text-success-400" />
|
<CheckCircle className="w-6 h-6 text-success-600 dark:text-success-400" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -229,10 +312,10 @@ const Customers: React.FC = () => {
|
|||||||
Total Revenue
|
Total Revenue
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||||
{formatCurrency(customers.reduce((sum, c) => sum + c.totalSpent, 0))}
|
{formatCurrency(totalRevenue)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-12 h-12 bg-warning-100 dark:bg-warning-900 rounded-full flex items-center justify-center">
|
<div className="w-12 h-12 bg-warning-100 dark:bg-warning-900 rounded-lg flex items-center justify-center">
|
||||||
<DollarSign className="w-6 h-6 text-warning-600 dark:text-warning-400" />
|
<DollarSign className="w-6 h-6 text-warning-600 dark:text-warning-400" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -245,53 +328,80 @@ const Customers: React.FC = () => {
|
|||||||
Avg. Customer Value
|
Avg. Customer Value
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||||
{formatCurrency(customers.filter(c => c.totalSpent > 0).reduce((sum, c) => sum + c.totalSpent, 0) / customers.filter(c => c.totalSpent > 0).length || 0)}
|
{formatCurrency(avgCustomerValue)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-12 h-12 bg-secondary-100 dark:bg-secondary-900 rounded-full flex items-center justify-center">
|
<div className="w-12 h-12 bg-warning-100 dark:bg-warning-900 rounded-lg flex items-center justify-center">
|
||||||
<TrendingUp className="w-6 h-6 text-secondary-600 dark:text-secondary-400" />
|
<DollarSign className="w-6 h-6 text-warning-600 dark:text-warning-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-4 sm:p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-secondary-600 dark:text-secondary-400">
|
||||||
|
Avg. Customer Age
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||||
|
{Math.round(avgCustomerAge)} days
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-info-100 dark:bg-info-900 rounded-lg flex items-center justify-center">
|
||||||
|
<Calendar className="w-6 h-6 text-info-600 dark:text-info-400" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters and Search */}
|
{/* Search and Filters */}
|
||||||
<div className="card p-4 sm:p-6">
|
<div className="card p-4 sm:p-6">
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
|
Search customers
|
||||||
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-secondary-400 w-4 h-4" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-secondary-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search customers..."
|
placeholder="Search by name, email, or company..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="w-full pl-10 pr-4 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
|
All Status
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
className="px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="px-4 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
>
|
>
|
||||||
<option value="all">All Status</option>
|
<option value="all">All Status</option>
|
||||||
<option value="active">Active</option>
|
<option value="active">Active</option>
|
||||||
<option value="inactive">Inactive</option>
|
<option value="inactive">Inactive</option>
|
||||||
<option value="pending">Pending</option>
|
<option value="pending">Pending</option>
|
||||||
</select>
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
|
Sort by Name
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={(e) => setSortBy(e.target.value)}
|
onChange={(e) => setSortBy(e.target.value)}
|
||||||
className="px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
>
|
>
|
||||||
<option value="name">Sort by Name</option>
|
<option value="name">Sort by Name</option>
|
||||||
<option value="company">Sort by Company</option>
|
<option value="company">Sort by Company</option>
|
||||||
<option value="totalSpent">Sort by Revenue</option>
|
<option value="totalSpent">Sort by Total Spent</option>
|
||||||
<option value="joinDate">Sort by Join Date</option>
|
<option value="customerSince">Sort by Customer Since</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -301,51 +411,51 @@ const Customers: React.FC = () => {
|
|||||||
<div className="card overflow-hidden">
|
<div className="card overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-secondary-50 dark:bg-secondary-800">
|
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||||
Customer
|
CUSTOMER
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||||
Company
|
COMPANY
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||||
Status
|
STATUS
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||||
Total Spent
|
TOTAL SPENT
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||||
Last Purchase
|
LAST PURCHASE
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||||
Join Date
|
CUSTOMER SINCE
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||||
Actions
|
ACTIONS
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white dark:bg-secondary-900 divide-y divide-secondary-200 dark:divide-secondary-700">
|
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||||
{sortedCustomers.map((customer) => (
|
{sortedCustomers.map((customer: Customer) => (
|
||||||
<tr key={customer.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-800">
|
<tr key={customer.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-700">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<img
|
<div className="w-10 h-10 bg-gradient-to-r from-primary-500 to-secondary-600 rounded-full flex items-center justify-center text-white font-medium text-sm">
|
||||||
className="h-10 w-10 rounded-full"
|
{customer.name.split(' ').map((n: string) => n[0]).join('').toUpperCase()}
|
||||||
src={customer.avatar}
|
</div>
|
||||||
alt={customer.name}
|
<div className="ml-3">
|
||||||
/>
|
|
||||||
<div className="ml-4">
|
|
||||||
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||||
{customer.name}
|
{customer.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-secondary-500 dark:text-secondary-400">
|
<div className="text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
{customer.email}
|
{customer.email}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-secondary-500 dark:text-secondary-400 flex items-center">
|
<div className="text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
<Phone className="w-3 h-3 mr-1" />
|
{customer.phone || 'No phone'}
|
||||||
{customer.phone}
|
</div>
|
||||||
|
<div className="text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
{customer.region !== 'N/A' ? customer.region : 'Location not specified'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -354,28 +464,30 @@ const Customers: React.FC = () => {
|
|||||||
<div className="text-sm text-secondary-900 dark:text-white">
|
<div className="text-sm text-secondary-900 dark:text-white">
|
||||||
{customer.company}
|
{customer.company}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-secondary-500 dark:text-secondary-400 flex items-center">
|
|
||||||
<MapPin className="w-3 h-3 mr-1" />
|
|
||||||
{customer.location}
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(customer.status)}`}>
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(customer.status)}`}>
|
||||||
{getStatusIcon(customer.status)}
|
{getStatusIcon(customer.status)}
|
||||||
<span className="ml-1 capitalize">{customer.status}</span>
|
<span className="ml-1">{customer.status}</span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||||
|
{formatCurrency(customer.totalSpent)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||||
{formatCurrency(customer.totalSpent)}
|
{customer.lastPurchase !== 'N/A'
|
||||||
|
? new Date(customer.lastPurchase).toLocaleDateString()
|
||||||
|
: 'No purchases yet'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||||
|
{customer.customerSince
|
||||||
|
? new Date(customer.customerSince).toLocaleDateString()
|
||||||
|
: new Date().toLocaleDateString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-400">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
{customer.lastPurchase ? formatDate(customer.lastPurchase) : 'No purchases'}
|
<button className="text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300">
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
{formatDate(customer.joinDate)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
||||||
<button className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300">
|
|
||||||
<MoreVertical className="w-4 h-4" />
|
<MoreVertical className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
@ -384,25 +496,209 @@ const Customers: React.FC = () => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{sortedCustomers.length === 0 && (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<Users className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">No customers found</h3>
|
||||||
|
<p className="text-secondary-600 dark:text-secondary-400">Try adjusting your search or filters.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
<div className="flex items-center justify-between">
|
{sortedCustomers.length > 0 && (
|
||||||
<div className="text-sm text-secondary-700 dark:text-secondary-300">
|
<div className="flex items-center justify-between">
|
||||||
Showing {sortedCustomers.length} of {customers.length} customers
|
<div className="text-sm text-secondary-700 dark:text-secondary-300">
|
||||||
|
Showing {sortedCustomers.length} of {totalCustomers} customers
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button className="px-3 py-2 text-sm text-secondary-700 dark:text-secondary-300 hover:text-secondary-900 dark:hover:text-white disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="px-3 py-2 text-sm text-secondary-900 dark:text-white bg-primary-100 dark:bg-primary-900 rounded-lg">
|
||||||
|
1
|
||||||
|
</span>
|
||||||
|
<button className="px-3 py-2 text-sm text-secondary-700 dark:text-secondary-300 hover:text-secondary-900 dark:hover:text-white disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
)}
|
||||||
<button className="px-3 py-1 text-sm text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300">
|
|
||||||
Previous
|
{/* Add Customer Modal */}
|
||||||
</button>
|
{isAddCustomerModalOpen && (
|
||||||
<span className="px-3 py-1 text-sm text-secondary-900 dark:text-white bg-primary-100 dark:bg-primary-900 rounded">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
1
|
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
</span>
|
<div className="flex items-center justify-between mb-6">
|
||||||
<button className="px-3 py-1 text-sm text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300">
|
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||||
Next
|
Add New Customer
|
||||||
</button>
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAddCustomerModalOpen(false)}
|
||||||
|
className="text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleAddCustomerSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
|
First Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={addCustomerForm.firstName}
|
||||||
|
onChange={(e) => handleInputChange('firstName', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
|
Last Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={addCustomerForm.lastName}
|
||||||
|
onChange={(e) => handleInputChange('lastName', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
|
Email *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={addCustomerForm.email}
|
||||||
|
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
|
Phone
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={addCustomerForm.phone}
|
||||||
|
onChange={(e) => handleInputChange('phone', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
|
Company
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={addCustomerForm.company}
|
||||||
|
onChange={(e) => handleInputChange('company', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
|
Address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={addCustomerForm.address}
|
||||||
|
onChange={(e) => handleInputChange('address', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
|
City
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={addCustomerForm.city}
|
||||||
|
onChange={(e) => handleInputChange('city', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
|
State/Province
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={addCustomerForm.state}
|
||||||
|
onChange={(e) => handleInputChange('state', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
|
ZIP/Postal Code
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={addCustomerForm.zipCode}
|
||||||
|
onChange={(e) => handleInputChange('zipCode', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
|
Country
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={addCustomerForm.country}
|
||||||
|
onChange={(e) => handleInputChange('country', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
|
Notes
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={addCustomerForm.notes}
|
||||||
|
onChange={(e) => handleInputChange('notes', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsAddCustomerModalOpen(false)}
|
||||||
|
className="px-4 py-2 text-secondary-700 dark:text-secondary-300 bg-secondary-100 dark:bg-secondary-700 hover:bg-secondary-200 dark:hover:bg-secondary-600 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Add Customer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -75,8 +75,10 @@ const ResellerLogin: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate to appropriate dashboard
|
// Wait a bit for Redux state to update, then navigate
|
||||||
|
setTimeout(() => {
|
||||||
navigate(redirectPath, { replace: true });
|
navigate(redirectPath, { replace: true });
|
||||||
|
}, 100);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.message || 'Invalid email or password. Please try again.';
|
const errorMessage = err.message || 'Invalid email or password. Please try again.';
|
||||||
|
|
||||||
|
|||||||
401
src/pages/reseller/Products.tsx
Normal file
401
src/pages/reseller/Products.tsx
Normal file
@ -0,0 +1,401 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../store/hooks';
|
||||||
|
import {
|
||||||
|
Package,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
Plus,
|
||||||
|
ShoppingCart,
|
||||||
|
Star,
|
||||||
|
Eye,
|
||||||
|
DollarSign,
|
||||||
|
TrendingUp,
|
||||||
|
Grid,
|
||||||
|
List,
|
||||||
|
X,
|
||||||
|
Clock,
|
||||||
|
CheckCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { fetchProducts, fetchCustomers } from '../../store/reseller/resellerDashboardSlice';
|
||||||
|
import MarkProductSoldForm from '../../components/forms/MarkProductSoldForm';
|
||||||
|
import { cn } from '../../utils/cn';
|
||||||
|
|
||||||
|
const Products: React.FC = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { products, customers, isLoading, error } = useAppSelector((state) => state.resellerDashboard);
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||||
|
const [sortBy, setSortBy] = useState('name');
|
||||||
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||||
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||||
|
const [isMarkProductSoldModalOpen, setIsMarkProductSoldModalOpen] = useState(false);
|
||||||
|
const [selectedProduct, setSelectedProduct] = useState<any>(null);
|
||||||
|
|
||||||
|
// Fetch products on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchProducts());
|
||||||
|
dispatch(fetchCustomers());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// Filter and sort products
|
||||||
|
const filteredAndSortedProducts = React.useMemo(() => {
|
||||||
|
let filtered = products || [];
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
|
if (searchTerm) {
|
||||||
|
filtered = filtered.filter((product: any) =>
|
||||||
|
product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
product.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
product.category?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply category filter
|
||||||
|
if (categoryFilter !== 'all') {
|
||||||
|
filtered = filtered.filter((product: any) => product.category === categoryFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
filtered.sort((a: any, b: any) => {
|
||||||
|
let aValue = a[sortBy];
|
||||||
|
let bValue = b[sortBy];
|
||||||
|
|
||||||
|
if (sortBy === 'price') {
|
||||||
|
aValue = parseFloat(aValue) || 0;
|
||||||
|
bValue = parseFloat(bValue) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortOrder === 'asc') {
|
||||||
|
return aValue > bValue ? 1 : -1;
|
||||||
|
} else {
|
||||||
|
return aValue < bValue ? 1 : -1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [products, searchTerm, categoryFilter, sortBy, sortOrder]);
|
||||||
|
|
||||||
|
// Get unique categories
|
||||||
|
const categories = React.useMemo(() => {
|
||||||
|
const uniqueCategories = Array.from(new Set(products?.map((p: any) => p.category).filter(Boolean)));
|
||||||
|
return ['all', ...uniqueCategories];
|
||||||
|
}, [products]);
|
||||||
|
|
||||||
|
// Handle product selection for sale
|
||||||
|
const handleSellProduct = (product: any) => {
|
||||||
|
setSelectedProduct(product);
|
||||||
|
setIsMarkProductSoldModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle modal close
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setIsMarkProductSoldModalOpen(false);
|
||||||
|
setSelectedProduct(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get commission display
|
||||||
|
const getCommissionDisplay = (product: any) => {
|
||||||
|
if (product.commissionRate) {
|
||||||
|
return `${product.commissionRate}% commission`;
|
||||||
|
}
|
||||||
|
return 'Commission rate not set';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get status color
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'active':
|
||||||
|
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||||
|
case 'inactive':
|
||||||
|
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||||
|
case 'pending':
|
||||||
|
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-emerald-50 via-teal-50 to-cyan-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 p-6">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-emerald-600 mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">Loading products...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-emerald-50 via-teal-50 to-cyan-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 p-6">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-red-500 text-lg mb-2">Error loading products</div>
|
||||||
|
<div className="text-gray-600 dark:text-gray-400 mb-4">{error}</div>
|
||||||
|
<button
|
||||||
|
onClick={() => dispatch(fetchProducts())}
|
||||||
|
className="px-4 py-2 bg-emerald-600 text-white rounded hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-emerald-50 via-teal-50 to-cyan-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 p-6">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Available Products
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
{(() => {
|
||||||
|
const totalProducts = products?.length || 0;
|
||||||
|
const activeProducts = products?.filter((p: any) => p.status === 'active').length || 0;
|
||||||
|
|
||||||
|
if (totalProducts === 0) {
|
||||||
|
return 'No products are currently available. Check back later for new additions.';
|
||||||
|
} else if (activeProducts === 0) {
|
||||||
|
return `You have ${totalProducts} product${totalProducts > 1 ? 's' : ''} but none are currently active.`;
|
||||||
|
} else if (activeProducts === totalProducts) {
|
||||||
|
return `Browse and sell from our catalog of ${totalProducts} active product${totalProducts > 1 ? 's' : ''}.`;
|
||||||
|
} else {
|
||||||
|
return `Browse and sell from our catalog. ${activeProducts} of ${totalProducts} product${totalProducts > 1 ? 's' : ''} are currently active.`;
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Dynamic Summary */}
|
||||||
|
{products && products.length > 0 && (
|
||||||
|
<div className="mt-4 flex flex-wrap gap-4 text-sm">
|
||||||
|
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400">
|
||||||
|
<Package className="w-4 h-4" />
|
||||||
|
<span>{products.length} total product{products.length > 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
{products.filter((p: any) => p.status === 'active').length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
<span>{products.filter((p: any) => p.status === 'active').length} active</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{products.filter((p: any) => p.commissionRate).length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400">
|
||||||
|
<TrendingUp className="w-4 h-4" />
|
||||||
|
<span>{products.filter((p: any) => p.commissionRate).length} with commission</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters and Search Bar */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-sm border border-gray-200 dark:border-gray-700 mb-8">
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4 items-center justify-between">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search products..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<select
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<option key={category} value={category}>
|
||||||
|
{category === 'all' ? 'All Categories' : category}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="name">Sort by Name</option>
|
||||||
|
<option value="price">Sort by Price</option>
|
||||||
|
<option value="category">Sort by Category</option>
|
||||||
|
<option value="createdAt">Sort by Date</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
|
||||||
|
className="p-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
title={sortOrder === 'asc' ? 'Sort Descending' : 'Sort Ascending'}
|
||||||
|
>
|
||||||
|
<TrendingUp className={cn("w-4 h-4", sortOrder === 'desc' && "rotate-180")} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View Mode Toggle */}
|
||||||
|
<div className="flex border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
className={cn(
|
||||||
|
"p-2 transition-colors",
|
||||||
|
viewMode === 'grid'
|
||||||
|
? "bg-emerald-600 text-white"
|
||||||
|
: "bg-white dark:bg-gray-700 text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Grid className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
className={cn(
|
||||||
|
"p-2 transition-colors",
|
||||||
|
viewMode === 'list'
|
||||||
|
? "bg-emerald-600 text-white"
|
||||||
|
: "bg-white dark:bg-gray-700 text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<List className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Products Count */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Showing {filteredAndSortedProducts.length} of {products?.length || 0} products
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Products Grid/List */}
|
||||||
|
{filteredAndSortedProducts.length === 0 ? (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-12 text-center shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<Package className="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">No products found</h3>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
{searchTerm || categoryFilter !== 'all'
|
||||||
|
? 'Try adjusting your search or filters'
|
||||||
|
: 'No products are currently available'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={cn(
|
||||||
|
viewMode === 'grid'
|
||||||
|
? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||||
|
: "space-y-4"
|
||||||
|
)}>
|
||||||
|
{filteredAndSortedProducts.map((product: any) => (
|
||||||
|
<div
|
||||||
|
key={product.id}
|
||||||
|
className={cn(
|
||||||
|
"bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden transition-all duration-200 hover:shadow-lg hover:scale-105",
|
||||||
|
viewMode === 'list' && "flex"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Product Image Placeholder */}
|
||||||
|
<div className={cn(
|
||||||
|
"bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900 dark:to-teal-900 p-6 flex items-center justify-center",
|
||||||
|
viewMode === 'list' ? "w-32 flex-shrink-0" : "h-48"
|
||||||
|
)}>
|
||||||
|
<Package className={cn(
|
||||||
|
"text-emerald-600 dark:text-emerald-400",
|
||||||
|
viewMode === 'list' ? "w-12 h-12" : "w-16 h-16"
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product Content */}
|
||||||
|
<div className={cn("p-6", viewMode === 'list' && "flex-1")}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white text-lg mb-1 line-clamp-2">
|
||||||
|
{product.name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center px-2 py-1 rounded-full text-xs font-medium",
|
||||||
|
getStatusColor(product.status)
|
||||||
|
)}>
|
||||||
|
{product.status}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{product.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 text-sm mb-4 line-clamp-3">
|
||||||
|
{product.description || 'No description available'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Price and Commission */}
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
${product.price}
|
||||||
|
</span>
|
||||||
|
{product.stock !== undefined && (
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Stock: {product.stock}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-emerald-600 dark:text-emerald-400 font-medium">
|
||||||
|
{getCommissionDisplay(product)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSellProduct(product)}
|
||||||
|
className="flex-1 bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<ShoppingCart className="w-4 h-4" />
|
||||||
|
Sell Product
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="p-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
title="View Details"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mark Product Sold Modal */}
|
||||||
|
{isMarkProductSoldModalOpen && selectedProduct && (
|
||||||
|
<MarkProductSoldForm
|
||||||
|
isOpen={isMarkProductSoldModalOpen}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
products={[selectedProduct]}
|
||||||
|
customers={customers || []}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Products;
|
||||||
File diff suppressed because it is too large
Load Diff
407
src/pages/reseller/ResellerTraining.tsx
Normal file
407
src/pages/reseller/ResellerTraining.tsx
Normal file
@ -0,0 +1,407 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useAppSelector } from '../../store/hooks';
|
||||||
|
import { Play, CheckCircle, Clock, Award, Users } from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import ResellerCertificates from '../../components/ResellerCertificates';
|
||||||
|
|
||||||
|
interface TrainingCourse {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
level: string;
|
||||||
|
category: string;
|
||||||
|
totalVideos: number;
|
||||||
|
totalDuration: number;
|
||||||
|
vendor: {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
company?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrainingVideo {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
videoType: 'youtube' | 'manual_upload';
|
||||||
|
youtubeUrl?: string;
|
||||||
|
videoFile?: string;
|
||||||
|
duration: number;
|
||||||
|
requiredForCompletion: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VideoProgress {
|
||||||
|
videoId: number;
|
||||||
|
status: 'not_started' | 'in_progress' | 'completed';
|
||||||
|
progressPercentage: number;
|
||||||
|
watchedDuration: number;
|
||||||
|
timeSpent: number;
|
||||||
|
lastWatchedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CourseProgress {
|
||||||
|
courseId: number;
|
||||||
|
status: 'not_started' | 'in_progress' | 'completed';
|
||||||
|
progressPercentage: number;
|
||||||
|
completedVideos: number;
|
||||||
|
totalVideos: number;
|
||||||
|
videos: VideoProgress[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResellerTraining: React.FC = () => {
|
||||||
|
const { user } = useAppSelector((state) => state.auth);
|
||||||
|
const [courses, setCourses] = useState<TrainingCourse[]>([]);
|
||||||
|
const [courseProgress, setCourseProgress] = useState<CourseProgress[]>([]);
|
||||||
|
const [selectedCourse, setSelectedCourse] = useState<TrainingCourse | null>(null);
|
||||||
|
const [selectedVideo, setSelectedVideo] = useState<TrainingVideo | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [activeTab, setActiveTab] = useState<'courses' | 'certificates'>('courses');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAvailableCourses();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchAvailableCourses = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/training-new/courses`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setCourses(data.data.courses || []);
|
||||||
|
|
||||||
|
// Fetch progress for each course
|
||||||
|
const progressPromises = data.data.courses.map((course: TrainingCourse) =>
|
||||||
|
fetchCourseProgress(course.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const progressResults = await Promise.all(progressPromises);
|
||||||
|
setCourseProgress(progressResults.filter(Boolean));
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to fetch courses');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching courses:', error);
|
||||||
|
toast.error('Failed to fetch courses');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchCourseProgress = async (courseId: number): Promise<CourseProgress | null> => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/training-new/courses/${courseId}/progress`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
return data.data.userProgress || null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching course progress:', error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateVideoProgress = async (courseId: number, videoId: number, progress: Partial<VideoProgress>) => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/training-new/courses/${courseId}/videos/${videoId}/progress`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(progress)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast.success('Progress updated successfully');
|
||||||
|
// Refresh progress
|
||||||
|
fetchAvailableCourses();
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to update progress');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating progress:', error);
|
||||||
|
toast.error('Failed to update progress');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLevelColor = (level: string) => {
|
||||||
|
switch (level) {
|
||||||
|
case 'Beginner': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||||
|
case 'Intermediate': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
|
||||||
|
case 'Advanced': return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||||
|
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProgressColor = (percentage: number) => {
|
||||||
|
if (percentage >= 80) return 'text-green-600 dark:text-green-400';
|
||||||
|
if (percentage >= 50) return 'text-yellow-600 dark:text-yellow-400';
|
||||||
|
return 'text-red-600 dark:text-red-400';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return <CheckCircle className="w-5 h-5 text-green-600" />;
|
||||||
|
case 'in_progress': return <Clock className="w-5 h-5 text-yellow-600" />;
|
||||||
|
default: return <Play className="w-5 h-5 text-gray-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-secondary-900 dark:text-white mb-3">
|
||||||
|
Training & Development
|
||||||
|
</h1>
|
||||||
|
<p className="text-secondary-600 dark:text-secondary-400 text-lg">
|
||||||
|
Complete training courses to enhance your skills and earn certificates
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex space-x-1 bg-gray-100 dark:bg-gray-800 p-1 rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('courses')}
|
||||||
|
className={`px-4 py-2 rounded-md transition-colors ${
|
||||||
|
activeTab === 'courses'
|
||||||
|
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||||
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Available Courses
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('certificates')}
|
||||||
|
className={`px-4 py-2 rounded-md transition-colors ${
|
||||||
|
activeTab === 'certificates'
|
||||||
|
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||||
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
My Certificates
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Courses Tab */}
|
||||||
|
{activeTab === 'courses' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{courses.map((course) => {
|
||||||
|
const progress = courseProgress.find(p => p.courseId === course.id);
|
||||||
|
const progressPercentage = progress ? progress.progressPercentage : 0;
|
||||||
|
const status = progress ? progress.status : 'not_started';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={course.id} className="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
|
{course.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 mb-3">
|
||||||
|
{course.description}
|
||||||
|
</p>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
By {course.vendor.firstName} {course.vendor.lastName}
|
||||||
|
{course.vendor.company && ` • ${course.vendor.company}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
{getStatusIcon(status)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Level:</span>
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getLevelColor(course.level)}`}>
|
||||||
|
{course.level}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Category:</span>
|
||||||
|
<span className="text-gray-900 dark:text-white font-medium">
|
||||||
|
{course.category || 'Uncategorized'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Videos:</span>
|
||||||
|
<span className="text-gray-900 dark:text-white font-medium">
|
||||||
|
{course.totalVideos}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Duration:</span>
|
||||||
|
<span className="text-gray-900 dark:text-white font-medium">
|
||||||
|
{Math.round(course.totalDuration / 60)} min
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center justify-between text-sm mb-2">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Progress</span>
|
||||||
|
<span className={`font-medium ${getProgressColor(progressPercentage)}`}>
|
||||||
|
{progressPercentage}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${progressPercentage}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedCourse(course)}
|
||||||
|
className="w-full btn btn-primary btn-sm"
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
{status === 'completed' ? 'Review Course' : 'Continue Learning'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{courses.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Play className="w-8 h-8 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||||
|
No courses available
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
Check back later for new training courses
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Certificates Tab */}
|
||||||
|
{activeTab === 'certificates' && (
|
||||||
|
<ResellerCertificates />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Course Detail Modal */}
|
||||||
|
{selectedCourse && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{selectedCourse.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
{selectedCourse.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedCourse(null)}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Course Info */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Level:</span>
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getLevelColor(selectedCourse.level)}`}>
|
||||||
|
{selectedCourse.level}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Category:</span>
|
||||||
|
<span className="text-gray-900 dark:text-white font-medium">
|
||||||
|
{selectedCourse.category || 'Uncategorized'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Total Videos:</span>
|
||||||
|
<span className="text-gray-900 dark:text-white font-medium">
|
||||||
|
{selectedCourse.totalVideos}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Duration:</span>
|
||||||
|
<span className="text-gray-900 dark:text-white font-medium">
|
||||||
|
{Math.round(selectedCourse.totalDuration / 60)} minutes
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Vendor:</span>
|
||||||
|
<span className="text-gray-900 dark:text-white font-medium">
|
||||||
|
{selectedCourse.vendor.firstName} {selectedCourse.vendor.lastName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video List */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Course Content
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Placeholder for video list */}
|
||||||
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
<Play className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||||
|
<p>Video content will be displayed here</p>
|
||||||
|
<p className="text-sm">Videos can be YouTube links or uploaded files</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResellerTraining;
|
||||||
@ -55,7 +55,13 @@ const ResellerSignup: React.FC = () => {
|
|||||||
const [showBusinessTypeDropdown, setShowBusinessTypeDropdown] = useState(false);
|
const [showBusinessTypeDropdown, setShowBusinessTypeDropdown] = useState(false);
|
||||||
const [showVendorDropdown, setShowVendorDropdown] = useState(false);
|
const [showVendorDropdown, setShowVendorDropdown] = useState(false);
|
||||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||||
const [availableVendors, setAvailableVendors] = useState<Array<{ id: number; company: string; firstName: string; lastName: string; email: string }>>([]);
|
const [availableVendors, setAvailableVendors] = useState<Array<{
|
||||||
|
id: number;
|
||||||
|
companyName: string;
|
||||||
|
companyType: string;
|
||||||
|
tier: string;
|
||||||
|
commissionRate: string;
|
||||||
|
}>>([]);
|
||||||
const [isLoadingVendors, setIsLoadingVendors] = useState(false);
|
const [isLoadingVendors, setIsLoadingVendors] = useState(false);
|
||||||
const [userTypes, setUserTypes] = useState<Array<{ value: string; label: string; description: string; permissions: string[] }>>([]);
|
const [userTypes, setUserTypes] = useState<Array<{ value: string; label: string; description: string; permissions: string[] }>>([]);
|
||||||
const [isLoadingUserTypes, setIsLoadingUserTypes] = useState(false);
|
const [isLoadingUserTypes, setIsLoadingUserTypes] = useState(false);
|
||||||
@ -84,7 +90,9 @@ const ResellerSignup: React.FC = () => {
|
|||||||
setIsLoadingVendors(true);
|
setIsLoadingVendors(true);
|
||||||
try {
|
try {
|
||||||
const vendorsResponse = await apiService.getAvailableVendorCompanies();
|
const vendorsResponse = await apiService.getAvailableVendorCompanies();
|
||||||
|
console.log('Vendors API response:', vendorsResponse);
|
||||||
if (vendorsResponse.success) {
|
if (vendorsResponse.success) {
|
||||||
|
console.log('Setting vendors:', vendorsResponse.data);
|
||||||
setAvailableVendors(vendorsResponse.data);
|
setAvailableVendors(vendorsResponse.data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -448,6 +456,11 @@ const ResellerSignup: React.FC = () => {
|
|||||||
|
|
||||||
{showVendorDropdown && !isLoadingVendors && (
|
{showVendorDropdown && !isLoadingVendors && (
|
||||||
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-xl shadow-lg max-h-48 overflow-y-auto">
|
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-xl shadow-lg max-h-48 overflow-y-auto">
|
||||||
|
{/* Debug info */}
|
||||||
|
<div className="p-2 text-xs text-gray-500 bg-gray-100 dark:bg-gray-800">
|
||||||
|
Debug: {availableVendors.length} vendors loaded
|
||||||
|
</div>
|
||||||
|
|
||||||
{availableVendors.length === 0 ? (
|
{availableVendors.length === 0 ? (
|
||||||
<div className="p-3 text-center text-slate-500 dark:text-slate-400">
|
<div className="p-3 text-center text-slate-500 dark:text-slate-400">
|
||||||
No vendors available
|
No vendors available
|
||||||
@ -458,14 +471,14 @@ const ResellerSignup: React.FC = () => {
|
|||||||
key={vendor.id}
|
key={vendor.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleInputChange('company', vendor.company);
|
handleInputChange('company', vendor.companyName);
|
||||||
setShowVendorDropdown(false);
|
setShowVendorDropdown(false);
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-3 py-2 hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors duration-200 first:rounded-t-xl last:rounded-b-xl"
|
className="w-full text-left px-3 py-2 hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors duration-200 first:rounded-t-xl last:rounded-b-xl"
|
||||||
>
|
>
|
||||||
<div className="font-medium text-slate-900 dark:text-white">{vendor.company}</div>
|
<div className="font-medium text-slate-900 dark:text-white">{vendor.companyName}</div>
|
||||||
<div className="text-sm text-slate-500 dark:text-slate-400">
|
<div className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
{vendor.firstName} {vendor.lastName}
|
{vendor.companyType} • {vendor.tier} tier • {vendor.commissionRate}% commission
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
@ -538,14 +551,14 @@ const ResellerSignup: React.FC = () => {
|
|||||||
key={vendor.id}
|
key={vendor.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleInputChange('company', vendor.company);
|
handleInputChange('company', vendor.companyName);
|
||||||
setShowVendorDropdown(false);
|
setShowVendorDropdown(false);
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-3 py-2 hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors duration-200 first:rounded-t-xl last:rounded-b-xl"
|
className="w-full text-left px-3 py-2 hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors duration-200 first:rounded-t-xl last:rounded-b-xl"
|
||||||
>
|
>
|
||||||
<div className="font-medium text-slate-900 dark:text-white">{vendor.company}</div>
|
<div className="font-medium text-slate-900 dark:text-white">{vendor.companyName}</div>
|
||||||
<div className="text-sm text-slate-500 dark:text-slate-400">
|
<div className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
{vendor.firstName} {vendor.lastName}
|
{vendor.companyType} • {vendor.tier} tier • {vendor.commissionRate}% commission
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
|
|||||||
@ -1,6 +1,177 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { MessageSquare, Ticket, Send, X, User, Clock, AlertCircle, CheckCircle } from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface SupportTicket {
|
||||||
|
id: string;
|
||||||
|
ticketNumber: string;
|
||||||
|
subject: string;
|
||||||
|
description: string;
|
||||||
|
status: 'open' | 'in_progress' | 'waiting_for_customer' | 'waiting_for_vendor' | 'resolved' | 'closed';
|
||||||
|
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||||
|
category: 'technical' | 'billing' | 'product' | 'commission' | 'general' | 'other';
|
||||||
|
createdAt: string;
|
||||||
|
lastMessage?: string;
|
||||||
|
lastMessageAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
senderType: 'reseller' | 'vendor' | 'admin';
|
||||||
|
senderName: string;
|
||||||
|
timestamp: string;
|
||||||
|
isRead: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const Support: React.FC = () => {
|
const Support: React.FC = () => {
|
||||||
|
const [activeTab, setActiveTab] = useState<'tickets' | 'chat'>('tickets');
|
||||||
|
const [tickets, setTickets] = useState<SupportTicket[]>([]);
|
||||||
|
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [newMessage, setNewMessage] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showTicketForm, setShowTicketForm] = useState(false);
|
||||||
|
const [ticketForm, setTicketForm] = useState({
|
||||||
|
subject: '',
|
||||||
|
description: '',
|
||||||
|
priority: 'medium' as const,
|
||||||
|
category: 'general' as const
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'tickets') {
|
||||||
|
fetchTickets();
|
||||||
|
} else {
|
||||||
|
fetchChatMessages();
|
||||||
|
}
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
const fetchTickets = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/support/tickets`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
setTickets(data.data || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching tickets:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchChatMessages = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/support/chat`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
setChatMessages(data.data || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching chat messages:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTicket = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!ticketForm.subject || !ticketForm.description) {
|
||||||
|
toast.error('Please fill in all required fields');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/support/tickets`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(ticketForm)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
toast.success('Support ticket created successfully!');
|
||||||
|
setShowTicketForm(false);
|
||||||
|
setTicketForm({ subject: '', description: '', priority: 'medium', category: 'general' });
|
||||||
|
fetchTickets();
|
||||||
|
} else {
|
||||||
|
toast.error(data.message || 'Failed to create ticket');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating ticket:', error);
|
||||||
|
toast.error('Failed to create ticket. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendChatMessage = async () => {
|
||||||
|
if (!newMessage.trim()) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/support/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: newMessage,
|
||||||
|
messageType: 'text'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
setNewMessage('');
|
||||||
|
fetchChatMessages();
|
||||||
|
toast.success('Message sent successfully!');
|
||||||
|
} else {
|
||||||
|
toast.error(data.message || 'Failed to send message');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending message:', error);
|
||||||
|
toast.error('Failed to send message. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'open': return 'text-blue-600 bg-blue-100 dark:bg-blue-900/30';
|
||||||
|
case 'in_progress': return 'text-yellow-600 bg-yellow-100 dark:bg-yellow-900/30';
|
||||||
|
case 'waiting_for_customer': return 'text-orange-600 bg-orange-100 dark:bg-orange-900/30';
|
||||||
|
case 'waiting_for_vendor': return 'text-purple-600 bg-purple-100 dark:bg-purple-900/30';
|
||||||
|
case 'resolved': return 'text-green-600 bg-green-100 dark:bg-green-900/30';
|
||||||
|
case 'closed': return 'text-gray-600 bg-gray-100 dark:bg-gray-900/30';
|
||||||
|
default: return 'text-gray-600 bg-gray-100 dark:bg-gray-900/30';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityColor = (priority: string) => {
|
||||||
|
switch (priority) {
|
||||||
|
case 'urgent': return 'text-red-600 bg-red-100 dark:bg-red-900/30';
|
||||||
|
case 'high': return 'text-orange-600 bg-orange-100 dark:bg-orange-900/30';
|
||||||
|
case 'medium': return 'text-yellow-600 bg-yellow-100 dark:bg-yellow-900/30';
|
||||||
|
case 'low': return 'text-green-600 bg-green-100 dark:bg-green-900/30';
|
||||||
|
default: return 'text-gray-600 bg-gray-100 dark:bg-gray-900/30';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@ -8,23 +179,281 @@ const Support: React.FC = () => {
|
|||||||
Support Center
|
Support Center
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-secondary-600 dark:text-secondary-400 mt-1">
|
<p className="text-secondary-600 dark:text-secondary-400 mt-1">
|
||||||
Get help and submit support tickets
|
Get help and support from your vendor
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card p-12">
|
{/* Tab Navigation */}
|
||||||
<div className="text-center">
|
<div className="flex space-x-1 bg-slate-100 dark:bg-slate-800 rounded-lg p-1">
|
||||||
<div className="w-16 h-16 bg-secondary-100 dark:bg-secondary-800 rounded-full flex items-center justify-center mx-auto mb-4">
|
<button
|
||||||
<div className="w-8 h-8 bg-secondary-400 rounded"></div>
|
onClick={() => setActiveTab('tickets')}
|
||||||
|
className={`flex-1 py-2 px-4 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
activeTab === 'tickets'
|
||||||
|
? 'bg-white dark:bg-slate-700 text-slate-900 dark:text-white shadow-sm'
|
||||||
|
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Ticket className="w-4 h-4 inline mr-2" />
|
||||||
|
Support Tickets
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('chat')}
|
||||||
|
className={`flex-1 py-2 px-4 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
activeTab === 'chat'
|
||||||
|
? 'bg-white dark:bg-slate-700 text-slate-900 dark:text-white shadow-sm'
|
||||||
|
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-4 h-4 inline mr-2" />
|
||||||
|
Direct Chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tickets Tab */}
|
||||||
|
{activeTab === 'tickets' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Create Ticket Button */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">Support Tickets</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTicketForm(true)}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center"
|
||||||
|
>
|
||||||
|
<Ticket className="w-4 h-4 mr-2" />
|
||||||
|
Create New Ticket
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-2">
|
|
||||||
Coming Soon
|
{/* Tickets List */}
|
||||||
|
{tickets.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Ticket className="w-16 h-16 text-slate-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-slate-900 dark:text-white mb-2">No tickets yet</h3>
|
||||||
|
<p className="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
Create your first support ticket to get help from your vendor
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTicketForm(true)}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Create Ticket
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{tickets.map((ticket) => (
|
||||||
|
<div key={ticket.id} className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-6">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">
|
||||||
|
#{ticket.ticketNumber} - {ticket.subject}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-secondary-600 dark:text-secondary-400">
|
<p className="text-slate-600 dark:text-slate-400 text-sm mb-3">
|
||||||
Support center features will be available soon.
|
{ticket.description}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(ticket.status)}`}>
|
||||||
|
{ticket.status.replace('_', ' ').toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getPriorityColor(ticket.priority)}`}>
|
||||||
|
{ticket.priority.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm text-slate-500">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<span className="capitalize">{ticket.category}</span>
|
||||||
|
<span className="flex items-center">
|
||||||
|
<Clock className="w-4 h-4 mr-1" />
|
||||||
|
{new Date(ticket.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{ticket.lastMessage && (
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-slate-600 dark:text-slate-400">Last message: {ticket.lastMessage}</p>
|
||||||
|
{ticket.lastMessageAt && (
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{new Date(ticket.lastMessageAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chat Tab */}
|
||||||
|
{activeTab === 'chat' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">Direct Chat with Vendor</h2>
|
||||||
|
|
||||||
|
{/* Chat Messages */}
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 h-96 flex flex-col">
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
{chatMessages.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<MessageSquare className="w-16 h-16 text-slate-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-slate-900 dark:text-white mb-2">No messages yet</h3>
|
||||||
|
<p className="text-slate-600 dark:text-slate-400">
|
||||||
|
Start a conversation with your vendor
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
chatMessages.map((message) => (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className={`flex ${message.senderType === 'reseller' ? 'justify-end' : 'justify-start'}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`max-w-xs lg:max-w-md px-4 py-2 rounded-lg ${
|
||||||
|
message.senderType === 'reseller'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-slate-100 dark:bg-slate-700 text-slate-900 dark:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2 mb-1">
|
||||||
|
<span className="text-xs font-medium capitalize">
|
||||||
|
{message.senderType === 'reseller' ? 'You' : message.senderName}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs opacity-75">
|
||||||
|
{new Date(message.timestamp).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm">{message.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message Input */}
|
||||||
|
<div className="border-t border-slate-200 dark:border-slate-700 p-4">
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newMessage}
|
||||||
|
onChange={(e) => setNewMessage(e.target.value)}
|
||||||
|
onKeyPress={(e) => e.key === 'Enter' && sendChatMessage()}
|
||||||
|
placeholder="Type your message..."
|
||||||
|
className="flex-1 px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={sendChatMessage}
|
||||||
|
disabled={isLoading || !newMessage.trim()}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Ticket Modal */}
|
||||||
|
{showTicketForm && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">Create Support Ticket</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTicketForm(false)}
|
||||||
|
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={createTicket} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
Subject *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={ticketForm.subject}
|
||||||
|
onChange={(e) => setTicketForm(prev => ({ ...prev, subject: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
Description *
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={ticketForm.description}
|
||||||
|
onChange={(e) => setTicketForm(prev => ({ ...prev, description: e.target.value }))}
|
||||||
|
rows={4}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
Priority
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={ticketForm.priority}
|
||||||
|
onChange={(e) => setTicketForm(prev => ({ ...prev, priority: e.target.value as any }))}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
<option value="urgent">Urgent</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={ticketForm.category}
|
||||||
|
onChange={(e) => setTicketForm(prev => ({ ...prev, category: e.target.value as any }))}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="general">General</option>
|
||||||
|
<option value="technical">Technical</option>
|
||||||
|
<option value="billing">Billing</option>
|
||||||
|
<option value="product">Product</option>
|
||||||
|
<option value="commission">Commission</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowTicketForm(false)}
|
||||||
|
className="px-4 py-2 text-slate-600 dark:text-slate-400 border border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Creating...' : 'Create Ticket'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
155
src/services/analyticsService.ts
Normal file
155
src/services/analyticsService.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
|
||||||
|
|
||||||
|
export interface VendorData {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
company: string;
|
||||||
|
status: 'active' | 'pending' | 'inactive';
|
||||||
|
totalRevenue: number;
|
||||||
|
totalResellers: number;
|
||||||
|
activeResellers: number;
|
||||||
|
monthlyGrowth: number;
|
||||||
|
topProducts: Array<{
|
||||||
|
name: string;
|
||||||
|
revenue: number;
|
||||||
|
sales: number;
|
||||||
|
}>;
|
||||||
|
resellers: Array<ResellerData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResellerData {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
company: string;
|
||||||
|
status: 'active' | 'pending' | 'inactive';
|
||||||
|
totalSales: number;
|
||||||
|
totalRevenue: number;
|
||||||
|
commissionEarned: number;
|
||||||
|
monthlyGrowth: number;
|
||||||
|
topProducts: Array<{
|
||||||
|
name: string;
|
||||||
|
revenue: number;
|
||||||
|
sales: number;
|
||||||
|
}>;
|
||||||
|
performance: {
|
||||||
|
conversionRate: number;
|
||||||
|
averageDealSize: number;
|
||||||
|
customerSatisfaction: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsData {
|
||||||
|
overview: {
|
||||||
|
totalVendors: number;
|
||||||
|
totalResellers: number;
|
||||||
|
totalRevenue: number;
|
||||||
|
totalSales: number;
|
||||||
|
monthlyGrowth: number;
|
||||||
|
};
|
||||||
|
vendors: VendorData[];
|
||||||
|
topPerformers: {
|
||||||
|
vendors: VendorData[];
|
||||||
|
resellers: ResellerData[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class AnalyticsService {
|
||||||
|
private baseUrl = `${API_BASE_URL}/analytics`;
|
||||||
|
|
||||||
|
async getVendorAnalytics(timeRange: string = '30d'): Promise<AnalyticsData> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/vendors?timeRange=${timeRange}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
console.warn('Vendor analytics endpoint not found (404)');
|
||||||
|
return {
|
||||||
|
overview: { totalVendors: 0, totalResellers: 0, totalRevenue: 0, totalSales: 0, monthlyGrowth: 0 },
|
||||||
|
vendors: [],
|
||||||
|
topPerformers: { vendors: [], resellers: [] }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.data || data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching vendor analytics:', error);
|
||||||
|
return {
|
||||||
|
overview: { totalVendors: 0, totalResellers: 0, totalRevenue: 0, totalSales: 0, monthlyGrowth: 0 },
|
||||||
|
vendors: [],
|
||||||
|
topPerformers: { vendors: [], resellers: [] }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getResellerAnalytics(vendorId?: number, timeRange: string = '30d'): Promise<ResellerData[]> {
|
||||||
|
try {
|
||||||
|
const url = vendorId
|
||||||
|
? `${this.baseUrl}/vendors/${vendorId}/resellers?timeRange=${timeRange}`
|
||||||
|
: `${this.baseUrl}/resellers?timeRange=${timeRange}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
console.warn('Reseller analytics endpoint not found (404)');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.data || data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching reseller analytics:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDashboardOverview(timeRange: string = '30d'): Promise<AnalyticsData['overview']> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/overview?timeRange=${timeRange}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
console.warn('Dashboard overview endpoint not found (404)');
|
||||||
|
return { totalVendors: 0, totalResellers: 0, totalRevenue: 0, totalSales: 0, monthlyGrowth: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.data || data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching dashboard overview:', error);
|
||||||
|
return { totalVendors: 0, totalResellers: 0, totalRevenue: 0, totalSales: 0, monthlyGrowth: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new AnalyticsService();
|
||||||
@ -351,8 +351,32 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Vendor operations
|
// Vendor operations
|
||||||
async getAvailableVendorCompanies(): Promise<{ success: boolean; data: Array<{ id: number; company: string; firstName: string; lastName: string; email: string }> }> {
|
async getAvailableVendorCompanies(): Promise<{
|
||||||
return this.request<{ success: boolean; data: Array<{ id: number; company: string; firstName: string; lastName: string; email: string }> }>('/public/vendors/available-companies');
|
success: boolean;
|
||||||
|
data: Array<{
|
||||||
|
id: number;
|
||||||
|
companyName: string;
|
||||||
|
companyType: string;
|
||||||
|
tier: string;
|
||||||
|
commissionRate: string;
|
||||||
|
territory: any[];
|
||||||
|
specializations: any[];
|
||||||
|
certifications: any[];
|
||||||
|
}>
|
||||||
|
}> {
|
||||||
|
return this.request<{
|
||||||
|
success: boolean;
|
||||||
|
data: Array<{
|
||||||
|
id: number;
|
||||||
|
companyName: string;
|
||||||
|
companyType: string;
|
||||||
|
tier: string;
|
||||||
|
commissionRate: string;
|
||||||
|
territory: any[];
|
||||||
|
specializations: any[];
|
||||||
|
certifications: any[];
|
||||||
|
}>
|
||||||
|
}>('/public/vendors/available-companies');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reseller operations
|
// Reseller operations
|
||||||
@ -425,13 +449,13 @@ class ApiService {
|
|||||||
|
|
||||||
async approveResellerRequest(userId: number): Promise<{ success: boolean; message: string }> {
|
async approveResellerRequest(userId: number): Promise<{ success: boolean; message: string }> {
|
||||||
return this.request<{ success: boolean; message: string }>(`/vendors/resellers/${userId}/approve`, {
|
return this.request<{ success: boolean; message: string }>(`/vendors/resellers/${userId}/approve`, {
|
||||||
method: 'POST'
|
method: 'PUT'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async rejectResellerRequest(userId: number, reason: string): Promise<{ success: boolean; message: string }> {
|
async rejectResellerRequest(userId: number, reason: string): Promise<{ success: boolean; message: string }> {
|
||||||
return this.request<{ success: boolean; message: string }>(`/vendors/resellers/${userId}/reject`, {
|
return this.request<{ success: boolean; message: string }>(`/vendors/resellers/${userId}/reject`, {
|
||||||
method: 'POST',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ reason })
|
body: JSON.stringify({ reason })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
321
src/services/dashboardService.ts
Normal file
321
src/services/dashboardService.ts
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
totalRevenue: number;
|
||||||
|
totalResellers: number;
|
||||||
|
activePartnerships: number;
|
||||||
|
pendingApprovals: number;
|
||||||
|
monthlyGrowth: number;
|
||||||
|
commissionEarned: number;
|
||||||
|
averageDealSize: number;
|
||||||
|
conversionRate: number;
|
||||||
|
currency?: 'USD' | 'INR';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentActivity {
|
||||||
|
id: string;
|
||||||
|
type: 'reseller_added' | 'deal_closed' | 'commission_earned' | 'partnership_approved' | 'training_completed' | 'customer_added' | 'instance_created' | 'payment_received' | 'support_ticket' | 'product_sold';
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
timestamp: string;
|
||||||
|
amount?: number;
|
||||||
|
currency?: 'USD' | 'INR';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuickAction {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
action: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DashboardService {
|
||||||
|
private baseUrl = `${API_BASE_URL}/dashboard`;
|
||||||
|
|
||||||
|
async getDashboardStats(): Promise<DashboardStats> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/stats`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
console.warn('Dashboard stats endpoint not found (404) - using mock data');
|
||||||
|
return this.getMockStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.data || data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching dashboard stats:', error);
|
||||||
|
// Return default stats on error
|
||||||
|
return this.getMockStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecentActivities(): Promise<RecentActivity[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/activities`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
console.warn('Recent activities endpoint not found (404) - using mock data');
|
||||||
|
return this.getMockActivities();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
if (errorData.message && errorData.message.includes('not implemented')) {
|
||||||
|
console.warn('Recent activities endpoint not implemented - using mock data');
|
||||||
|
return this.getMockActivities();
|
||||||
|
}
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.data || data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching recent activities:', error);
|
||||||
|
return this.getMockActivities();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getQuickActions(): Promise<QuickAction[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/quick-actions`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
console.warn('Quick actions endpoint not found (404) - using mock data');
|
||||||
|
return this.getMockQuickActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
if (errorData.message && errorData.message.includes('not implemented')) {
|
||||||
|
console.warn('Quick actions endpoint not implemented - using mock data');
|
||||||
|
return this.getMockQuickActions();
|
||||||
|
}
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.data || data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching quick actions:', error);
|
||||||
|
return this.getMockQuickActions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNotificationStats(): Promise<{ total: number; unread: number; read: number }> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/notifications/stats`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
console.warn('Notification stats endpoint not found (404) - using default values');
|
||||||
|
return { total: 0, unread: 0, read: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.data || data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching notification stats:', error);
|
||||||
|
return { total: 0, unread: 0, read: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check dashboard health status
|
||||||
|
async getDashboardHealth(): Promise<{
|
||||||
|
stats: boolean;
|
||||||
|
activities: boolean;
|
||||||
|
quickActions: boolean;
|
||||||
|
overall: boolean;
|
||||||
|
}> {
|
||||||
|
const health = {
|
||||||
|
stats: false,
|
||||||
|
activities: false,
|
||||||
|
quickActions: false,
|
||||||
|
overall: false
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check stats endpoint
|
||||||
|
const statsResponse = await fetch(`${this.baseUrl}/stats`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
health.stats = statsResponse.ok;
|
||||||
|
|
||||||
|
// Check activities endpoint
|
||||||
|
const activitiesResponse = await fetch(`${this.baseUrl}/activities`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
health.activities = activitiesResponse.ok;
|
||||||
|
|
||||||
|
// Check quick actions endpoint
|
||||||
|
const quickActionsResponse = await fetch(`${this.baseUrl}/quick-actions`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
health.quickActions = quickActionsResponse.ok;
|
||||||
|
|
||||||
|
// Overall health is true if at least stats endpoint is working
|
||||||
|
health.overall = health.stats;
|
||||||
|
|
||||||
|
return health;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking dashboard health:', error);
|
||||||
|
return health;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock data fallback for development
|
||||||
|
getMockStats(): DashboardStats {
|
||||||
|
return {
|
||||||
|
totalRevenue: 684000,
|
||||||
|
totalResellers: 24,
|
||||||
|
activePartnerships: 18,
|
||||||
|
pendingApprovals: 3,
|
||||||
|
monthlyGrowth: 12.5,
|
||||||
|
commissionEarned: 8200,
|
||||||
|
averageDealSize: 28500,
|
||||||
|
conversionRate: 78.5,
|
||||||
|
currency: 'USD'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getMockActivities(): RecentActivity[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
type: 'reseller_added',
|
||||||
|
title: 'New Reseller Added',
|
||||||
|
description: 'Tech Solutions Inc. has been registered as a new reseller partner',
|
||||||
|
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
||||||
|
amount: 5000,
|
||||||
|
currency: 'USD'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
type: 'deal_closed',
|
||||||
|
title: 'Deal Closed Successfully',
|
||||||
|
description: 'Cloud migration project worth $25,000 has been completed',
|
||||||
|
timestamp: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
|
||||||
|
amount: 25000,
|
||||||
|
currency: 'USD'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
type: 'commission_earned',
|
||||||
|
title: 'Commission Earned',
|
||||||
|
description: 'Earned $1,200 commission from reseller sales this week',
|
||||||
|
timestamp: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(),
|
||||||
|
amount: 1200,
|
||||||
|
currency: 'USD'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
getMockQuickActions(): QuickAction[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'add-reseller',
|
||||||
|
title: 'Add New Reseller',
|
||||||
|
description: 'Register a new reseller partner',
|
||||||
|
icon: 'UserPlus',
|
||||||
|
action: '/resellers',
|
||||||
|
color: 'primary'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'product-management',
|
||||||
|
title: 'Manage Products',
|
||||||
|
description: 'Add or update product catalog',
|
||||||
|
icon: 'Package',
|
||||||
|
action: '/product-management',
|
||||||
|
color: 'success'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'approve-partnership',
|
||||||
|
title: 'Review Partnerships',
|
||||||
|
description: 'Approve pending reseller requests',
|
||||||
|
icon: 'CheckCircle',
|
||||||
|
action: '/partnerships',
|
||||||
|
color: 'warning'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'create-deal',
|
||||||
|
title: 'Create Deal',
|
||||||
|
description: 'Set up new business opportunity',
|
||||||
|
icon: 'Briefcase',
|
||||||
|
action: '/deals',
|
||||||
|
color: 'primary'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'training-session',
|
||||||
|
title: 'Training Session',
|
||||||
|
description: 'Schedule reseller training',
|
||||||
|
icon: 'GraduationCap',
|
||||||
|
action: '/training',
|
||||||
|
color: 'success'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'commission-report',
|
||||||
|
title: 'Commission Report',
|
||||||
|
description: 'Generate commission reports',
|
||||||
|
icon: 'FileText',
|
||||||
|
action: '/commissions',
|
||||||
|
color: 'secondary'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'performance-analytics',
|
||||||
|
title: 'Performance Analytics',
|
||||||
|
description: 'View detailed performance metrics',
|
||||||
|
icon: 'BarChart3',
|
||||||
|
action: '/analytics',
|
||||||
|
color: 'info'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboardServiceInstance = new DashboardService();
|
||||||
|
export default dashboardServiceInstance;
|
||||||
144
src/services/knowledgeService.ts
Normal file
144
src/services/knowledgeService.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import {
|
||||||
|
KnowledgeArticle,
|
||||||
|
KnowledgeCategory,
|
||||||
|
KnowledgeSearchParams,
|
||||||
|
KnowledgeSearchResponse,
|
||||||
|
CreateArticleData,
|
||||||
|
UpdateArticleData,
|
||||||
|
CreateCategoryData,
|
||||||
|
FeedbackData
|
||||||
|
} from '../types/knowledge';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
|
||||||
|
|
||||||
|
class KnowledgeService {
|
||||||
|
private baseUrl = `${API_BASE_URL}/knowledge`;
|
||||||
|
|
||||||
|
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const url = `${this.baseUrl}${endpoint}`;
|
||||||
|
|
||||||
|
const config: RequestInit = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add auth token if available
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (token) {
|
||||||
|
config.headers = {
|
||||||
|
...config.headers,
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, config);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || 'API request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API request failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Articles
|
||||||
|
async getArticles(params: KnowledgeSearchParams = {}): Promise<KnowledgeSearchResponse> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach(v => searchParams.append(key, v));
|
||||||
|
} else {
|
||||||
|
searchParams.append(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.request<KnowledgeSearchResponse>(`/articles?${searchParams.toString()}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getArticleBySlug(slug: string): Promise<{ data: KnowledgeArticle }> {
|
||||||
|
const response = await this.request<{ data: KnowledgeArticle }>(`/articles/${slug}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createArticle(data: CreateArticleData): Promise<{ data: KnowledgeArticle; message: string }> {
|
||||||
|
const response = await this.request<{ data: KnowledgeArticle; message: string }>('/articles', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateArticle(id: number, data: UpdateArticleData): Promise<{ data: KnowledgeArticle; message: string }> {
|
||||||
|
const response = await this.request<{ data: KnowledgeArticle; message: string }>(`/articles/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteArticle(id: number): Promise<{ message: string }> {
|
||||||
|
const response = await this.request<{ message: string }>(`/articles/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishArticle(id: number): Promise<{ data: KnowledgeArticle; message: string }> {
|
||||||
|
const response = await this.request<{ data: KnowledgeArticle; message: string }>(`/articles/${id}/publish`, {
|
||||||
|
method: 'PATCH'
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categories
|
||||||
|
async getCategories(params: { parentId?: number; visibility?: string } = {}): Promise<{ data: KnowledgeCategory[] }> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
searchParams.append(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.request<{ data: KnowledgeCategory[] }>(`/categories?${searchParams.toString()}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCategory(data: CreateCategoryData): Promise<{ data: KnowledgeCategory; message: string }> {
|
||||||
|
const response = await this.request<{ data: KnowledgeCategory; message: string }>('/categories', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search
|
||||||
|
async searchArticles(query: string, page: number = 1, limit: number = 10): Promise<KnowledgeSearchResponse> {
|
||||||
|
const response = await this.request<KnowledgeSearchResponse>(`/articles/search?q=${encodeURIComponent(query)}&page=${page}&limit=${limit}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feedback
|
||||||
|
async submitFeedback(articleId: number, data: FeedbackData): Promise<{ message: string }> {
|
||||||
|
const response = await this.request<{ message: string }>(`/articles/${articleId}/feedback`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const knowledgeService = new KnowledgeService();
|
||||||
|
export default knowledgeService;
|
||||||
283
src/services/logsService.ts
Normal file
283
src/services/logsService.ts
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
|
||||||
|
|
||||||
|
export interface LoginLog {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
userEmail: string;
|
||||||
|
userName: string;
|
||||||
|
userRole: string;
|
||||||
|
loginTime: string;
|
||||||
|
logoutTime?: string;
|
||||||
|
ipAddress: string;
|
||||||
|
userAgent: string;
|
||||||
|
location?: string;
|
||||||
|
status: 'active' | 'logged_out' | 'expired' | 'failed';
|
||||||
|
sessionDuration?: number;
|
||||||
|
deviceType: 'desktop' | 'mobile' | 'tablet';
|
||||||
|
browser: string;
|
||||||
|
os: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
lastActivity: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogsFilter {
|
||||||
|
searchTerm?: string;
|
||||||
|
status?: string;
|
||||||
|
role?: string;
|
||||||
|
showActiveOnly?: boolean;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LogsService {
|
||||||
|
private baseUrl = `${API_BASE_URL}/admin/logs`;
|
||||||
|
|
||||||
|
async getLogs(filters: LogsFilter = {}): Promise<{ logs: LoginLog[]; total: number }> {
|
||||||
|
try {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
|
if (filters.searchTerm) queryParams.append('search', filters.searchTerm);
|
||||||
|
if (filters.status && filters.status !== 'all') queryParams.append('status', filters.status);
|
||||||
|
if (filters.role && filters.role !== 'all') queryParams.append('role', filters.role);
|
||||||
|
if (filters.showActiveOnly) queryParams.append('activeOnly', 'true');
|
||||||
|
if (filters.startDate) queryParams.append('startDate', filters.startDate);
|
||||||
|
if (filters.endDate) queryParams.append('endDate', filters.endDate);
|
||||||
|
if (filters.limit) queryParams.append('limit', filters.limit.toString());
|
||||||
|
if (filters.offset) queryParams.append('offset', filters.offset.toString());
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseUrl}?${queryParams}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching logs:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLogsStats(): Promise<{
|
||||||
|
activeUsers: number;
|
||||||
|
totalSessions: number;
|
||||||
|
adminUsers: number;
|
||||||
|
failedLogins: number;
|
||||||
|
totalUsers: number;
|
||||||
|
averageSessionDuration: number;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/stats`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching logs stats:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRealTimeLogs(): Promise<LoginLog[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/realtime`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching real-time logs:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportLogs(filters: LogsFilter = {}, format: 'csv' | 'json' = 'csv'): Promise<Blob> {
|
||||||
|
try {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
|
if (filters.searchTerm) queryParams.append('search', filters.searchTerm);
|
||||||
|
if (filters.status && filters.status !== 'all') queryParams.append('status', filters.status);
|
||||||
|
if (filters.role && filters.role !== 'all') queryParams.append('role', filters.role);
|
||||||
|
if (filters.showActiveOnly) queryParams.append('activeOnly', 'true');
|
||||||
|
if (filters.startDate) queryParams.append('startDate', filters.startDate);
|
||||||
|
if (filters.endDate) queryParams.append('endDate', filters.endDate);
|
||||||
|
queryParams.append('format', format);
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseUrl}/export?${queryParams}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.blob();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error exporting logs:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async forceLogoutUser(userId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/force-logout/${userId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error forcing user logout:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessionDetails(sessionId: string): Promise<LoginLog> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/session/${sessionId}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching session details:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock data for development/testing
|
||||||
|
getMockLogs(): LoginLog[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
userId: '1',
|
||||||
|
userEmail: 'admin@cloudtopiaa.com',
|
||||||
|
userName: 'System Administrator',
|
||||||
|
userRole: 'system_admin',
|
||||||
|
loginTime: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
||||||
|
ipAddress: '192.168.1.100',
|
||||||
|
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
|
location: 'New York, US',
|
||||||
|
status: 'active',
|
||||||
|
sessionDuration: 120,
|
||||||
|
deviceType: 'desktop',
|
||||||
|
browser: 'Chrome 120.0',
|
||||||
|
os: 'Windows 11',
|
||||||
|
isAdmin: true,
|
||||||
|
lastActivity: new Date().toISOString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
userId: '2',
|
||||||
|
userEmail: 'yasha.khandelwal@tech4biz.io',
|
||||||
|
userName: 'Yasha Khandelwal',
|
||||||
|
userRole: 'channel_partner_admin',
|
||||||
|
loginTime: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
|
||||||
|
ipAddress: '203.45.67.89',
|
||||||
|
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||||
|
location: 'Mumbai, IN',
|
||||||
|
status: 'active',
|
||||||
|
sessionDuration: 30,
|
||||||
|
deviceType: 'desktop',
|
||||||
|
browser: 'Safari 17.0',
|
||||||
|
os: 'macOS 14.0',
|
||||||
|
isAdmin: false,
|
||||||
|
lastActivity: new Date().toISOString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
userId: '3',
|
||||||
|
userEmail: 'vendor@example.com',
|
||||||
|
userName: 'Cloud Vendor',
|
||||||
|
userRole: 'vendor_admin',
|
||||||
|
loginTime: new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString(),
|
||||||
|
logoutTime: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
|
||||||
|
ipAddress: '45.67.89.123',
|
||||||
|
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15',
|
||||||
|
location: 'London, UK',
|
||||||
|
status: 'logged_out',
|
||||||
|
sessionDuration: 60,
|
||||||
|
deviceType: 'mobile',
|
||||||
|
browser: 'Safari Mobile',
|
||||||
|
os: 'iOS 17.0',
|
||||||
|
isAdmin: false,
|
||||||
|
lastActivity: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
userId: '4',
|
||||||
|
userEmail: 'reseller@example.com',
|
||||||
|
userName: 'Tech Reseller',
|
||||||
|
userRole: 'reseller_admin',
|
||||||
|
loginTime: new Date(Date.now() - 15 * 60 * 1000).toISOString(),
|
||||||
|
ipAddress: '98.76.54.32',
|
||||||
|
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
|
||||||
|
location: 'Berlin, DE',
|
||||||
|
status: 'active',
|
||||||
|
sessionDuration: 15,
|
||||||
|
deviceType: 'desktop',
|
||||||
|
browser: 'Firefox 121.0',
|
||||||
|
os: 'Ubuntu 22.04',
|
||||||
|
isAdmin: false,
|
||||||
|
lastActivity: new Date().toISOString()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
getMockStats() {
|
||||||
|
return {
|
||||||
|
activeUsers: 2,
|
||||||
|
totalSessions: 4,
|
||||||
|
adminUsers: 1,
|
||||||
|
failedLogins: 0,
|
||||||
|
totalUsers: 4,
|
||||||
|
averageSessionDuration: 56.25
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new LogsService();
|
||||||
@ -31,26 +31,62 @@ export interface ResellerProduct {
|
|||||||
price: number;
|
price: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
category: string;
|
category: string;
|
||||||
|
subcategory?: string;
|
||||||
status: 'active' | 'inactive';
|
status: 'active' | 'inactive';
|
||||||
salesCount: number;
|
availability?: string;
|
||||||
revenue: number;
|
stockQuantity?: number;
|
||||||
|
sku?: string;
|
||||||
|
features?: string[];
|
||||||
|
specifications?: any;
|
||||||
|
images?: string[];
|
||||||
|
documents?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
commissionRate?: number;
|
||||||
vendorId: string;
|
vendorId: string;
|
||||||
vendorName: string;
|
vendorName: string;
|
||||||
|
vendorEmail?: string;
|
||||||
|
soldQuantity?: number;
|
||||||
|
totalRevenue?: number;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResellerSale {
|
export interface ResellerSale {
|
||||||
id: string;
|
id: string;
|
||||||
customerId: string;
|
customerId: string;
|
||||||
customerName: string;
|
customerName?: string;
|
||||||
productId: string;
|
productId: string;
|
||||||
productName: string;
|
productName?: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
totalAmount?: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
commission: number;
|
commission: number;
|
||||||
status: 'completed' | 'pending' | 'cancelled';
|
commissionAmount?: number;
|
||||||
|
status: 'completed' | 'pending' | 'cancelled' | 'confirmed';
|
||||||
|
verificationStatus?: 'pending' | 'verified' | 'rejected';
|
||||||
|
paymentStatus?: 'pending' | 'paid' | 'failed';
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
|
createdAt?: string;
|
||||||
vendorId: string;
|
vendorId: string;
|
||||||
vendorName: string;
|
vendorName?: string;
|
||||||
|
quantity?: number;
|
||||||
|
// Product relationship
|
||||||
|
product?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
sku: string;
|
||||||
|
category: string;
|
||||||
|
price: number;
|
||||||
|
};
|
||||||
|
// Customer relationship
|
||||||
|
customer?: {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
company: string;
|
||||||
|
phone: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResellerReceipt {
|
export interface ResellerReceipt {
|
||||||
@ -151,16 +187,31 @@ class ResellerDashboardService {
|
|||||||
|
|
||||||
// Fetch sales
|
// Fetch sales
|
||||||
async getSales(): Promise<ResellerSale[]> {
|
async getSales(): Promise<ResellerSale[]> {
|
||||||
const response = await fetch(`${this.baseUrl}/resellers/sales`, {
|
try {
|
||||||
headers: this.getHeaders()
|
console.log('Fetching sales from:', `${this.baseUrl}/sales/reseller`);
|
||||||
});
|
console.log('Headers:', this.getHeaders());
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseUrl}/sales/reseller`, {
|
||||||
|
headers: this.getHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
console.log('Response status:', response.status);
|
||||||
throw new Error('Failed to fetch sales');
|
console.log('Response ok:', response.ok);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Error response:', errorText);
|
||||||
|
throw new Error(`Failed to fetch sales: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Response data:', data);
|
||||||
|
|
||||||
|
return data.data.sales || data.data; // Handle both response formats
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getSales error:', error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return data.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch receipts
|
// Fetch receipts
|
||||||
|
|||||||
@ -101,6 +101,23 @@ class SocketService {
|
|||||||
this.triggerListeners('SYSTEM_ALERT', data);
|
this.triggerListeners('SYSTEM_ALERT', data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle user activity events for admin logs
|
||||||
|
this.socket.on('USER_LOGIN', (data: any) => {
|
||||||
|
this.triggerListeners('USER_LOGIN', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('USER_LOGOUT', (data: any) => {
|
||||||
|
this.triggerListeners('USER_LOGOUT', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('USER_ACTIVITY', (data: any) => {
|
||||||
|
this.triggerListeners('USER_ACTIVITY', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('USER_SESSION_EXPIRED', (data: any) => {
|
||||||
|
this.triggerListeners('USER_SESSION_EXPIRED', data);
|
||||||
|
});
|
||||||
|
|
||||||
// Handle general notifications
|
// Handle general notifications
|
||||||
this.socket.on('notification', (data: any) => {
|
this.socket.on('notification', (data: any) => {
|
||||||
this.triggerListeners('notification', data);
|
this.triggerListeners('notification', data);
|
||||||
|
|||||||
@ -2,9 +2,11 @@ import { configureStore } from '@reduxjs/toolkit';
|
|||||||
import themeReducer from './slices/themeSlice';
|
import themeReducer from './slices/themeSlice';
|
||||||
import authReducer from './slices/authSlice';
|
import authReducer from './slices/authSlice';
|
||||||
import dashboardReducer from './slices/dashboardSlice';
|
import dashboardReducer from './slices/dashboardSlice';
|
||||||
import resellerDashboardReducer from './reseller/dashboardSlice';
|
import resellerDashboardReducer from './reseller/resellerDashboardSlice';
|
||||||
import productReducer from './slices/productSlice';
|
import productReducer from './slices/productSlice';
|
||||||
import receiptReducer from './slices/receiptSlice';
|
import receiptReducer from './slices/receiptSlice';
|
||||||
|
import knowledgeReducer from './slices/knowledgeSlice';
|
||||||
|
import commissionReducer from './slices/commissionSlice';
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
@ -14,6 +16,8 @@ export const store = configureStore({
|
|||||||
resellerDashboard: resellerDashboardReducer,
|
resellerDashboard: resellerDashboardReducer,
|
||||||
product: productReducer,
|
product: productReducer,
|
||||||
receipts: receiptReducer,
|
receipts: receiptReducer,
|
||||||
|
knowledge: knowledgeReducer,
|
||||||
|
commission: commissionReducer,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
108
src/store/slices/commissionSlice.ts
Normal file
108
src/store/slices/commissionSlice.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import { apiService } from '../../services/api';
|
||||||
|
|
||||||
|
export interface Commission {
|
||||||
|
id: string;
|
||||||
|
reseller: string;
|
||||||
|
deal: string;
|
||||||
|
amount: number;
|
||||||
|
commissionRate: number;
|
||||||
|
commissionEarned: number;
|
||||||
|
status: 'paid' | 'pending' | 'processing';
|
||||||
|
paidDate: string | null;
|
||||||
|
dealValue: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommissionState {
|
||||||
|
commissions: Commission[];
|
||||||
|
pagination: {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalItems: number;
|
||||||
|
itemsPerPage: number;
|
||||||
|
};
|
||||||
|
filters: {
|
||||||
|
status: string;
|
||||||
|
dateRange: string;
|
||||||
|
searchTerm: string;
|
||||||
|
};
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: CommissionState = {
|
||||||
|
commissions: [],
|
||||||
|
pagination: {
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
totalItems: 0,
|
||||||
|
itemsPerPage: 20
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
status: 'all',
|
||||||
|
dateRange: 'all',
|
||||||
|
searchTerm: ''
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Async thunks
|
||||||
|
export const fetchCommissions = createAsyncThunk(
|
||||||
|
'commission/fetchCommissions',
|
||||||
|
async (params: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
status?: string;
|
||||||
|
dateRange?: string;
|
||||||
|
} = {}, { rejectWithValue }: any) => {
|
||||||
|
try {
|
||||||
|
const response = await apiService.getVendorCommissions(params);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || 'Failed to fetch commissions');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const commissionSlice = createSlice({
|
||||||
|
name: 'commission',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setFilters: (state, action: PayloadAction<Partial<CommissionState['filters']>>) => {
|
||||||
|
state.filters = { ...state.filters, ...action.payload };
|
||||||
|
state.pagination.currentPage = 1; // Reset to first page when filters change
|
||||||
|
},
|
||||||
|
setPage: (state, action: PayloadAction<number>) => {
|
||||||
|
state.pagination.currentPage = action.payload;
|
||||||
|
},
|
||||||
|
setItemsPerPage: (state, action: PayloadAction<number>) => {
|
||||||
|
state.pagination.itemsPerPage = action.payload;
|
||||||
|
state.pagination.currentPage = 1; // Reset to first page when changing items per page
|
||||||
|
},
|
||||||
|
clearError: (state) => {
|
||||||
|
state.error = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
.addCase(fetchCommissions.pending, (state) => {
|
||||||
|
state.isLoading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchCommissions.fulfilled, (state, action) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.commissions = action.payload.commissions;
|
||||||
|
state.pagination = action.payload.pagination;
|
||||||
|
})
|
||||||
|
.addCase(fetchCommissions.rejected, (state, action) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { setFilters, setPage, setItemsPerPage, clearError } = commissionSlice.actions;
|
||||||
|
export default commissionSlice.reducer;
|
||||||
342
src/store/slices/knowledgeSlice.ts
Normal file
342
src/store/slices/knowledgeSlice.ts
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import { knowledgeService } from '../../services/knowledgeService';
|
||||||
|
import {
|
||||||
|
KnowledgeArticle,
|
||||||
|
KnowledgeCategory,
|
||||||
|
KnowledgeSearchParams,
|
||||||
|
KnowledgeSearchResponse,
|
||||||
|
CreateArticleData,
|
||||||
|
UpdateArticleData,
|
||||||
|
CreateCategoryData,
|
||||||
|
FeedbackData,
|
||||||
|
ApiResponse
|
||||||
|
} from '../../types/knowledge';
|
||||||
|
|
||||||
|
interface KnowledgeState {
|
||||||
|
articles: KnowledgeArticle[];
|
||||||
|
categories: KnowledgeCategory[];
|
||||||
|
currentArticle: KnowledgeArticle | null;
|
||||||
|
searchResults: KnowledgeArticle[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
pagination: {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalItems: number;
|
||||||
|
itemsPerPage: number;
|
||||||
|
} | null;
|
||||||
|
searchQuery: string;
|
||||||
|
filters: {
|
||||||
|
category: string;
|
||||||
|
subcategory: string;
|
||||||
|
tags: string[];
|
||||||
|
status: 'draft' | 'published' | 'archived';
|
||||||
|
visibility: 'public' | 'vendors' | 'resellers' | 'admin';
|
||||||
|
featured: boolean | undefined;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: KnowledgeState = {
|
||||||
|
articles: [],
|
||||||
|
categories: [],
|
||||||
|
currentArticle: null,
|
||||||
|
searchResults: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
pagination: null,
|
||||||
|
searchQuery: '',
|
||||||
|
filters: {
|
||||||
|
category: '',
|
||||||
|
subcategory: '',
|
||||||
|
tags: [],
|
||||||
|
status: 'published',
|
||||||
|
visibility: 'public',
|
||||||
|
featured: undefined
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Async thunks
|
||||||
|
export const fetchArticles = createAsyncThunk(
|
||||||
|
'knowledge/fetchArticles',
|
||||||
|
async (params: KnowledgeSearchParams = {}, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await knowledgeService.getArticles(params);
|
||||||
|
return response;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || 'Failed to fetch articles');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fetchArticleBySlug = createAsyncThunk(
|
||||||
|
'knowledge/fetchArticleBySlug',
|
||||||
|
async (slug: string, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await knowledgeService.getArticleBySlug(slug);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || 'Failed to fetch article');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const createArticle = createAsyncThunk(
|
||||||
|
'knowledge/createArticle',
|
||||||
|
async (data: CreateArticleData, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await knowledgeService.createArticle(data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || 'Failed to create article');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const updateArticle = createAsyncThunk(
|
||||||
|
'knowledge/updateArticle',
|
||||||
|
async ({ id, data }: { id: number; data: UpdateArticleData }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await knowledgeService.updateArticle(id, data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || 'Failed to update article');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const deleteArticle = createAsyncThunk(
|
||||||
|
'knowledge/deleteArticle',
|
||||||
|
async (id: number, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
await knowledgeService.deleteArticle(id);
|
||||||
|
return id;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || 'Failed to delete article');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const publishArticle = createAsyncThunk(
|
||||||
|
'knowledge/publishArticle',
|
||||||
|
async (id: number, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await knowledgeService.publishArticle(id);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || 'Failed to publish article');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fetchCategories = createAsyncThunk(
|
||||||
|
'knowledge/fetchCategories',
|
||||||
|
async (params: { parentId?: number; visibility?: string } = {}, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await knowledgeService.getCategories(params);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || 'Failed to fetch categories');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const createCategory = createAsyncThunk(
|
||||||
|
'knowledge/createCategory',
|
||||||
|
async (data: CreateCategoryData, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await knowledgeService.createCategory(data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || 'Failed to create category');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const searchArticles = createAsyncThunk(
|
||||||
|
'knowledge/searchArticles',
|
||||||
|
async ({ query, page = 1, limit = 10 }: { query: string; page?: number; limit?: number }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await knowledgeService.searchArticles(query, page, limit);
|
||||||
|
return { ...response, searchQuery: query };
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || 'Failed to search articles');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const submitFeedback = createAsyncThunk(
|
||||||
|
'knowledge/submitFeedback',
|
||||||
|
async ({ articleId, data }: { articleId: number; data: FeedbackData }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
await knowledgeService.submitFeedback(articleId, data);
|
||||||
|
return { articleId, data };
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || 'Failed to submit feedback');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const knowledgeSlice = createSlice({
|
||||||
|
name: 'knowledge',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
clearError: (state) => {
|
||||||
|
state.error = null;
|
||||||
|
},
|
||||||
|
clearCurrentArticle: (state) => {
|
||||||
|
state.currentArticle = null;
|
||||||
|
},
|
||||||
|
clearSearchResults: (state) => {
|
||||||
|
state.searchResults = [];
|
||||||
|
state.searchQuery = '';
|
||||||
|
},
|
||||||
|
setFilters: (state, action: PayloadAction<Partial<KnowledgeState['filters']>>) => {
|
||||||
|
state.filters = { ...state.filters, ...action.payload };
|
||||||
|
},
|
||||||
|
resetFilters: (state) => {
|
||||||
|
state.filters = initialState.filters;
|
||||||
|
},
|
||||||
|
setSearchQuery: (state, action: PayloadAction<string>) => {
|
||||||
|
state.searchQuery = action.payload;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
// Fetch articles
|
||||||
|
.addCase(fetchArticles.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchArticles.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
// Handle both direct response and nested data structure
|
||||||
|
const payload = action.payload as any;
|
||||||
|
if (payload.data && payload.data.articles) {
|
||||||
|
state.articles = payload.data.articles;
|
||||||
|
state.pagination = payload.data.pagination;
|
||||||
|
} else if (payload.articles) {
|
||||||
|
state.articles = payload.articles;
|
||||||
|
state.pagination = payload.pagination;
|
||||||
|
} else {
|
||||||
|
state.articles = payload;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addCase(fetchArticles.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch article by slug
|
||||||
|
.addCase(fetchArticleBySlug.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchArticleBySlug.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.currentArticle = action.payload;
|
||||||
|
})
|
||||||
|
.addCase(fetchArticleBySlug.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create article
|
||||||
|
.addCase(createArticle.fulfilled, (state, action) => {
|
||||||
|
state.articles.unshift(action.payload);
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update article
|
||||||
|
.addCase(updateArticle.fulfilled, (state, action) => {
|
||||||
|
const index = state.articles.findIndex(article => article.id === action.payload.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
state.articles[index] = action.payload;
|
||||||
|
}
|
||||||
|
if (state.currentArticle?.id === action.payload.id) {
|
||||||
|
state.currentArticle = action.payload;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete article
|
||||||
|
.addCase(deleteArticle.fulfilled, (state, action) => {
|
||||||
|
state.articles = state.articles.filter(article => article.id !== action.payload);
|
||||||
|
if (state.currentArticle?.id === action.payload) {
|
||||||
|
state.currentArticle = null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Publish article
|
||||||
|
.addCase(publishArticle.fulfilled, (state, action) => {
|
||||||
|
const index = state.articles.findIndex(article => article.id === action.payload.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
state.articles[index] = action.payload;
|
||||||
|
}
|
||||||
|
if (state.currentArticle?.id === action.payload.id) {
|
||||||
|
state.currentArticle = action.payload;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch categories
|
||||||
|
.addCase(fetchCategories.fulfilled, (state, action) => {
|
||||||
|
const payload = action.payload as any;
|
||||||
|
if (payload.data && payload.data.categories) {
|
||||||
|
state.categories = payload.data.categories;
|
||||||
|
} else if (payload.data) {
|
||||||
|
state.categories = payload.data;
|
||||||
|
} else {
|
||||||
|
state.categories = payload;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create category
|
||||||
|
.addCase(createCategory.fulfilled, (state, action) => {
|
||||||
|
state.categories.push(action.payload);
|
||||||
|
})
|
||||||
|
|
||||||
|
// Search articles
|
||||||
|
.addCase(searchArticles.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(searchArticles.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.searchResults = action.payload.articles;
|
||||||
|
state.pagination = action.payload.pagination;
|
||||||
|
state.searchQuery = action.payload.searchQuery;
|
||||||
|
})
|
||||||
|
.addCase(searchArticles.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// Submit feedback
|
||||||
|
.addCase(submitFeedback.fulfilled, (state, action) => {
|
||||||
|
const { articleId, data } = action.payload;
|
||||||
|
const article = state.articles.find(a => a.id === articleId);
|
||||||
|
if (article) {
|
||||||
|
if (data.feedbackType === 'helpful') {
|
||||||
|
article.helpfulCount += 1;
|
||||||
|
} else {
|
||||||
|
article.notHelpfulCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.currentArticle?.id === articleId) {
|
||||||
|
if (data.feedbackType === 'helpful') {
|
||||||
|
state.currentArticle.helpfulCount += 1;
|
||||||
|
} else {
|
||||||
|
state.currentArticle.notHelpfulCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
clearError,
|
||||||
|
clearCurrentArticle,
|
||||||
|
clearSearchResults,
|
||||||
|
setFilters,
|
||||||
|
resetFilters,
|
||||||
|
setSearchQuery
|
||||||
|
} = knowledgeSlice.actions;
|
||||||
|
|
||||||
|
export default knowledgeSlice.reducer;
|
||||||
132
src/types/knowledge.ts
Normal file
132
src/types/knowledge.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
export interface KnowledgeArticle {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
content: string;
|
||||||
|
excerpt?: string;
|
||||||
|
contentType: 'markdown' | 'html';
|
||||||
|
category: string;
|
||||||
|
subcategory?: string;
|
||||||
|
tags: string[];
|
||||||
|
authorId: number;
|
||||||
|
status: 'draft' | 'published' | 'archived';
|
||||||
|
visibility: 'public' | 'vendors' | 'resellers' | 'admin';
|
||||||
|
featured: boolean;
|
||||||
|
viewCount: number;
|
||||||
|
helpfulCount: number;
|
||||||
|
notHelpfulCount: number;
|
||||||
|
publishedAt?: string;
|
||||||
|
metaTitle?: string;
|
||||||
|
metaDescription?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
author?: {
|
||||||
|
id: number;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
categoryInfo?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
color?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeCategory {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description?: string;
|
||||||
|
parentId?: number;
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
sortOrder: number;
|
||||||
|
isActive: boolean;
|
||||||
|
visibility: 'public' | 'vendors' | 'resellers' | 'admin';
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
children?: KnowledgeCategory[];
|
||||||
|
parent?: KnowledgeCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeFeedback {
|
||||||
|
id: number;
|
||||||
|
articleId: number;
|
||||||
|
userId: number;
|
||||||
|
feedbackType: 'helpful' | 'not_helpful';
|
||||||
|
comment?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
ipAddress?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
article?: KnowledgeArticle;
|
||||||
|
user?: {
|
||||||
|
id: number;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeSearchParams {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
search?: string;
|
||||||
|
category?: string;
|
||||||
|
subcategory?: string;
|
||||||
|
tags?: string[];
|
||||||
|
status?: 'draft' | 'published' | 'archived';
|
||||||
|
visibility?: 'public' | 'vendors' | 'resellers' | 'admin';
|
||||||
|
featured?: boolean;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeSearchResponse {
|
||||||
|
articles: KnowledgeArticle[];
|
||||||
|
pagination: {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalItems: number;
|
||||||
|
itemsPerPage: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateArticleData {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
excerpt?: string;
|
||||||
|
contentType?: 'markdown' | 'html';
|
||||||
|
category: string;
|
||||||
|
subcategory?: string;
|
||||||
|
tags?: string[];
|
||||||
|
visibility?: 'public' | 'vendors' | 'resellers' | 'admin';
|
||||||
|
featured?: boolean;
|
||||||
|
metaTitle?: string;
|
||||||
|
metaDescription?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateArticleData extends Partial<CreateArticleData> {}
|
||||||
|
|
||||||
|
export interface CreateCategoryData {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
parentId?: number;
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
visibility?: 'public' | 'vendors' | 'resellers' | 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackData {
|
||||||
|
feedbackType: 'helpful' | 'not_helpful';
|
||||||
|
comment?: string;
|
||||||
|
}
|
||||||
@ -1,4 +1,13 @@
|
|||||||
export const formatCurrency = (amount: number, currency: 'USD' | 'INR' = 'INR'): string => {
|
export const formatCurrency = (amount: number | undefined | null, currency: 'USD' | 'INR' = 'INR'): string => {
|
||||||
|
if (amount === undefined || amount === null || isNaN(amount)) {
|
||||||
|
return new Intl.NumberFormat(currency === 'INR' ? 'en-IN' : 'en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency,
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(0);
|
||||||
|
}
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
style: 'currency' as const,
|
style: 'currency' as const,
|
||||||
currency: currency,
|
currency: currency,
|
||||||
@ -9,16 +18,18 @@ export const formatCurrency = (amount: number, currency: 'USD' | 'INR' = 'INR'):
|
|||||||
return new Intl.NumberFormat(currency === 'INR' ? 'en-IN' : 'en-US', options).format(amount);
|
return new Intl.NumberFormat(currency === 'INR' ? 'en-IN' : 'en-US', options).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatCurrencyDual = (amount: number, currency: 'USD' | 'INR' = 'INR'): { primary: string; secondary: string } => {
|
export const formatCurrencyDual = (amount: number | undefined | null, currency: 'USD' | 'INR' = 'INR'): { primary: string; secondary: string } => {
|
||||||
|
const safeAmount = amount ?? 0;
|
||||||
|
|
||||||
if (currency === 'INR') {
|
if (currency === 'INR') {
|
||||||
const usdAmount = amount / 83; // Approximate conversion rate
|
const usdAmount = safeAmount / 83; // Approximate conversion rate
|
||||||
return {
|
return {
|
||||||
primary: new Intl.NumberFormat('en-IN', {
|
primary: new Intl.NumberFormat('en-IN', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'INR',
|
currency: 'INR',
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
}).format(amount),
|
}).format(safeAmount),
|
||||||
secondary: new Intl.NumberFormat('en-US', {
|
secondary: new Intl.NumberFormat('en-US', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
@ -27,14 +38,14 @@ export const formatCurrencyDual = (amount: number, currency: 'USD' | 'INR' = 'IN
|
|||||||
}).format(usdAmount)
|
}).format(usdAmount)
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const inrAmount = amount * 83; // Approximate conversion rate
|
const inrAmount = safeAmount * 83; // Approximate conversion rate
|
||||||
return {
|
return {
|
||||||
primary: new Intl.NumberFormat('en-US', {
|
primary: new Intl.NumberFormat('en-US', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
}).format(amount),
|
}).format(safeAmount),
|
||||||
secondary: new Intl.NumberFormat('en-IN', {
|
secondary: new Intl.NumberFormat('en-IN', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'INR',
|
currency: 'INR',
|
||||||
@ -45,11 +56,13 @@ export const formatCurrencyDual = (amount: number, currency: 'USD' | 'INR' = 'IN
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatCurrencyDualDisplay = (amount: number, currency: 'USD' | 'INR' = 'INR'): { primary: string; secondary: string } => {
|
export const formatCurrencyDualDisplay = (amount: number | undefined | null, currency: 'USD' | 'INR' = 'INR'): { primary: string; secondary: string } => {
|
||||||
|
const safeAmount = amount ?? 0;
|
||||||
|
|
||||||
if (currency === 'INR') {
|
if (currency === 'INR') {
|
||||||
const usdAmount = amount / 83; // Approximate conversion rate
|
const usdAmount = safeAmount / 83; // Approximate conversion rate
|
||||||
return {
|
return {
|
||||||
primary: `${new Intl.NumberFormat('en-IN').format(amount)} rs`,
|
primary: `${new Intl.NumberFormat('en-IN').format(safeAmount)} rs`,
|
||||||
secondary: `<small>${new Intl.NumberFormat('en-US', {
|
secondary: `<small>${new Intl.NumberFormat('en-US', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
@ -58,20 +71,30 @@ export const formatCurrencyDualDisplay = (amount: number, currency: 'USD' | 'INR
|
|||||||
}).format(usdAmount)}</small>`
|
}).format(usdAmount)}</small>`
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const inrAmount = amount * 83; // Approximate conversion rate
|
const inrAmount = safeAmount * 83; // Approximate conversion rate
|
||||||
return {
|
return {
|
||||||
primary: new Intl.NumberFormat('en-US', {
|
primary: new Intl.NumberFormat('en-US', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
}).format(amount),
|
}).format(safeAmount),
|
||||||
secondary: `${new Intl.NumberFormat('en-IN').format(inrAmount)} rs`
|
secondary: `${new Intl.NumberFormat('en-IN').format(inrAmount)} rs`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatCurrencyCompact = (amount: number, currency: 'USD' | 'INR' = 'INR'): string => {
|
export const formatCurrencyCompact = (amount: number | undefined | null, currency: 'USD' | 'INR' = 'INR'): string => {
|
||||||
|
if (amount === undefined || amount === null || isNaN(amount)) {
|
||||||
|
return new Intl.NumberFormat(currency === 'INR' ? 'en-IN' : 'en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency,
|
||||||
|
notation: 'compact' as const,
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}).format(0);
|
||||||
|
}
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
style: 'currency' as const,
|
style: 'currency' as const,
|
||||||
currency: currency,
|
currency: currency,
|
||||||
@ -83,7 +106,10 @@ export const formatCurrencyCompact = (amount: number, currency: 'USD' | 'INR' =
|
|||||||
return new Intl.NumberFormat(currency === 'INR' ? 'en-IN' : 'en-US', options).format(amount);
|
return new Intl.NumberFormat(currency === 'INR' ? 'en-IN' : 'en-US', options).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatNumber = (num: number): string => {
|
export const formatNumber = (num: number | undefined | null): string => {
|
||||||
|
if (num === undefined || num === null || isNaN(num)) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
return new Intl.NumberFormat('en-IN').format(num);
|
return new Intl.NumberFormat('en-IN').format(num);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -154,6 +180,9 @@ export const formatFileSize = (bytes: number): string => {
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatPercentage = (value: number, decimals = 1): string => {
|
export const formatPercentage = (value: number | undefined | null, decimals = 1): string => {
|
||||||
|
if (value === undefined || value === null || isNaN(value)) {
|
||||||
|
return '0.0%';
|
||||||
|
}
|
||||||
return `${value.toFixed(decimals)}%`;
|
return `${value.toFixed(decimals)}%`;
|
||||||
};
|
};
|
||||||
97
src/utils/modalUtils.ts
Normal file
97
src/utils/modalUtils.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Modal utility functions to ensure proper viewport coverage
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Lock body scroll when modal is open
|
||||||
|
export const lockBodyScroll = (): void => {
|
||||||
|
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
document.body.style.paddingRight = `${scrollBarWidth}px`;
|
||||||
|
document.body.classList.add('modal-open');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Unlock body scroll when modal is closed
|
||||||
|
export const unlockBodyScroll = (): void => {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
document.body.style.paddingRight = '';
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure modal covers entire viewport
|
||||||
|
export const ensureModalCoverage = (modalElement: HTMLElement): void => {
|
||||||
|
if (!modalElement) return;
|
||||||
|
|
||||||
|
// Force modal to cover entire viewport
|
||||||
|
modalElement.style.position = 'fixed';
|
||||||
|
modalElement.style.top = '0';
|
||||||
|
modalElement.style.left = '0';
|
||||||
|
modalElement.style.right = '0';
|
||||||
|
modalElement.style.bottom = '0';
|
||||||
|
modalElement.style.width = '100vw';
|
||||||
|
modalElement.style.height = '100vh';
|
||||||
|
modalElement.style.zIndex = '9999';
|
||||||
|
|
||||||
|
// Ensure proper stacking context
|
||||||
|
modalElement.style.isolation = 'isolate';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if modal is properly positioned
|
||||||
|
export const isModalProperlyPositioned = (modalElement: HTMLElement): boolean => {
|
||||||
|
if (!modalElement) return false;
|
||||||
|
|
||||||
|
const rect = modalElement.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
return (
|
||||||
|
rect.top === 0 &&
|
||||||
|
rect.left === 0 &&
|
||||||
|
rect.width >= viewportWidth &&
|
||||||
|
rect.height >= viewportHeight
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fix common modal positioning issues
|
||||||
|
export const fixModalPositioning = (modalElement: HTMLElement): void => {
|
||||||
|
if (!modalElement) return;
|
||||||
|
|
||||||
|
// Remove any conflicting styles
|
||||||
|
modalElement.style.position = 'fixed';
|
||||||
|
modalElement.style.top = '0';
|
||||||
|
modalElement.style.left = '0';
|
||||||
|
modalElement.style.right = '0';
|
||||||
|
modalElement.style.bottom = '0';
|
||||||
|
modalElement.style.width = '100vw';
|
||||||
|
modalElement.style.height = '100vh';
|
||||||
|
modalElement.style.zIndex = '9999';
|
||||||
|
modalElement.style.display = 'flex';
|
||||||
|
modalElement.style.alignItems = 'center';
|
||||||
|
modalElement.style.justifyContent = 'center';
|
||||||
|
|
||||||
|
// Ensure backdrop covers entire viewport
|
||||||
|
const backdrop = modalElement.querySelector('.modal-backdrop') as HTMLElement;
|
||||||
|
if (backdrop) {
|
||||||
|
backdrop.style.position = 'absolute';
|
||||||
|
backdrop.style.top = '0';
|
||||||
|
backdrop.style.left = '0';
|
||||||
|
backdrop.style.right = '0';
|
||||||
|
backdrop.style.bottom = '0';
|
||||||
|
backdrop.style.width = '100%';
|
||||||
|
backdrop.style.height = '100%';
|
||||||
|
backdrop.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
|
||||||
|
backdrop.style.zIndex = '-1';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook for React components to manage modal state
|
||||||
|
export const useModalManager = () => {
|
||||||
|
const openModal = () => {
|
||||||
|
lockBodyScroll();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
unlockBodyScroll();
|
||||||
|
};
|
||||||
|
|
||||||
|
return { openModal, closeModal };
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user