v1.0.0-alpha
This commit is contained in:
parent
b0c762c57a
commit
8ac3c89b10
144
package-lock.json
generated
144
package-lock.json
generated
@ -20,6 +20,7 @@
|
||||
"@types/node": "^16.18.126",
|
||||
"@types/react": "^19.1.9",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@types/socket.io-client": "^1.4.36",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.536.0",
|
||||
@ -31,6 +32,7 @@
|
||||
"react-router-dom": "^6.30.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"recharts": "^3.1.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4"
|
||||
@ -3428,6 +3430,12 @@
|
||||
"@sinonjs/commons": "^1.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
||||
@ -4282,6 +4290,12 @@
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/socket.io-client": {
|
||||
"version": "1.4.36",
|
||||
"resolved": "https://registry.npmjs.org/@types/socket.io-client/-/socket.io-client-1.4.36.tgz",
|
||||
"integrity": "sha512-ZJWjtFBeBy1kRSYpVbeGYTElf6BqPQUkXDlHHD4k/42byCN5Rh027f4yARHCink9sKAkbtGZXEAmR0ZCnc2/Ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/sockjs": {
|
||||
"version": "0.3.36",
|
||||
"resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz",
|
||||
@ -7494,6 +7508,66 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
"version": "6.6.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
|
||||
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.3.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.17.1",
|
||||
"xmlhttprequest-ssl": "~2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-client/node_modules/debug": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-client/node_modules/ws": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
|
||||
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.18.2",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz",
|
||||
@ -15911,6 +15985,68 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.1",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
|
||||
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.3.2",
|
||||
"engine.io-client": "~6.6.1",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-client/node_modules/debug": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
|
||||
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser/node_modules/debug": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/sockjs": {
|
||||
"version": "0.3.24",
|
||||
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz",
|
||||
@ -18435,6 +18571,14 @@
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xmlhttprequest-ssl": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
"@types/node": "^16.18.126",
|
||||
"@types/react": "^19.1.9",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@types/socket.io-client": "^1.4.36",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.536.0",
|
||||
@ -26,6 +27,7 @@
|
||||
"react-router-dom": "^6.30.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"recharts": "^3.1.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4"
|
||||
|
||||
121
src/App.tsx
121
src/App.tsx
@ -3,6 +3,7 @@ import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-d
|
||||
import { Provider } from 'react-redux';
|
||||
import { CookiesProvider } from 'react-cookie';
|
||||
import { store } from './store';
|
||||
import { useAppSelector } from './store/hooks';
|
||||
import { setTheme } from './store/slices/themeSlice';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import AuthInitializer from './components/AuthInitializer';
|
||||
@ -11,8 +12,8 @@ import Toast from './components/Toast';
|
||||
// Channel Partner Components
|
||||
import Layout from './components/Layout/Layout';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import ResellersPage from './pages/Resellers';
|
||||
import PartnershipsPage from './pages/Partnerships';
|
||||
import ResellerRequestsPage from './pages/Resellers';
|
||||
import ApprovedResellersPage from './pages/ApprovedResellers/ApprovedResellers';
|
||||
import DealsPage from './pages/Deals';
|
||||
import CommissionsPage from './pages/Commissions';
|
||||
import ProductManagement from './pages/ProductManagement';
|
||||
@ -22,7 +23,7 @@ import Analytics from './pages/Analytics';
|
||||
import Reports from './pages/Reports';
|
||||
import Settings from './pages/Settings';
|
||||
import Login from './pages/Login';
|
||||
import Signup from './pages/Signup';
|
||||
import Signup from './pages/SignupStepwise';
|
||||
import VerifyEmail from './pages/VerifyEmail';
|
||||
|
||||
// Reseller Components
|
||||
@ -35,6 +36,7 @@ import ResellerBilling from './pages/reseller/Billing';
|
||||
import ResellerSupport from './pages/reseller/Support';
|
||||
import ResellerReports from './pages/reseller/Reports';
|
||||
import ResellerTraining from './pages/reseller/Training';
|
||||
import Receipts from './pages/reseller/Receipts';
|
||||
import ResellerLayout from './components/Layout/ResellerLayout';
|
||||
|
||||
// Admin Components
|
||||
@ -43,11 +45,39 @@ import AdminDashboard from './pages/admin/Dashboard';
|
||||
import VendorRequests from './pages/admin/VendorRequests';
|
||||
import ChannelPartners from './pages/admin/ChannelPartners';
|
||||
import AdminUsers from './pages/admin/Users';
|
||||
import Products from './pages/admin/Products';
|
||||
import AdminAnalytics from './pages/admin/Analytics';
|
||||
import AdminReports from './pages/admin/Reports';
|
||||
import AdminSettings from './pages/admin/Settings';
|
||||
import AdminFeedback from './pages/admin/Feedback';
|
||||
import RegisteredVendors from './pages/admin/RegisteredVendors';
|
||||
import Resellers from './pages/admin/Resellers';
|
||||
|
||||
import Unauthorized from './pages/Unauthorized';
|
||||
import CookieConsent from './components/CookieConsent';
|
||||
import AuthDebug from './components/AuthDebug';
|
||||
import DeveloperFeedback from './components/DeveloperFeedback';
|
||||
import socketService from './services/socketService';
|
||||
import './index.css';
|
||||
|
||||
// Component to handle role-based redirects
|
||||
const RoleBasedRedirect: React.FC = () => {
|
||||
const { user } = useAppSelector((state) => state.auth);
|
||||
|
||||
// Check if user has roles array and find the primary role
|
||||
const primaryRole = user?.roles?.[0]?.name || user?.role;
|
||||
|
||||
if (primaryRole === 'system_admin') {
|
||||
return <Navigate to="/admin" replace />;
|
||||
} else if (primaryRole?.startsWith('channel_partner_')) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
} else if (primaryRole?.startsWith('reseller_')) {
|
||||
return <Navigate to="/reseller-dashboard" replace />;
|
||||
} else {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
};
|
||||
|
||||
// Placeholder components for other pages
|
||||
const PlaceholderPage: React.FC<{ title: string; description: string }> = ({ title, description }) => (
|
||||
<div className="space-y-6">
|
||||
@ -91,7 +121,17 @@ function App() {
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
|
||||
// Initialize socket connection if user is logged in
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (token) {
|
||||
socketService.connect(token);
|
||||
}
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleChange);
|
||||
socketService.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@ -107,8 +147,11 @@ function App() {
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/unauthorized" element={<Unauthorized />} />
|
||||
|
||||
{/* Root route - Redirect based on user role */}
|
||||
<Route path="/" element={<RoleBasedRedirect />} />
|
||||
|
||||
{/* Protected Routes - Vendor Only */}
|
||||
<Route path="/" element={
|
||||
<Route path="/dashboard" element={
|
||||
<ProtectedRoute requiredRole="channel_partner_admin">
|
||||
<Layout>
|
||||
<Dashboard />
|
||||
@ -118,14 +161,14 @@ function App() {
|
||||
<Route path="/resellers" element={
|
||||
<ProtectedRoute requiredRole="channel_partner_admin">
|
||||
<Layout>
|
||||
<ResellersPage />
|
||||
<ResellerRequestsPage />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/partnerships" element={
|
||||
<Route path="/approved-resellers" element={
|
||||
<ProtectedRoute requiredRole="channel_partner_admin">
|
||||
<Layout>
|
||||
<PartnershipsPage />
|
||||
<ApprovedResellersPage />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
@ -298,6 +341,13 @@ function App() {
|
||||
</ResellerLayout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/reseller-dashboard/receipts" element={
|
||||
<ProtectedRoute requiredRole="reseller_admin">
|
||||
<ResellerLayout>
|
||||
<Receipts />
|
||||
</ResellerLayout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* Admin Routes */}
|
||||
<Route path="/admin" element={
|
||||
@ -328,6 +378,55 @@ function App() {
|
||||
</AdminLayout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/admin/products" element={
|
||||
<ProtectedRoute requiredRole="system_admin">
|
||||
<AdminLayout>
|
||||
<Products />
|
||||
</AdminLayout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/admin/analytics" element={
|
||||
<ProtectedRoute requiredRole="system_admin">
|
||||
<AdminLayout>
|
||||
<AdminAnalytics />
|
||||
</AdminLayout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/admin/reports" element={
|
||||
<ProtectedRoute requiredRole="system_admin">
|
||||
<AdminLayout>
|
||||
<AdminReports />
|
||||
</AdminLayout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/admin/settings" element={
|
||||
<ProtectedRoute requiredRole="system_admin">
|
||||
<AdminLayout>
|
||||
<AdminSettings />
|
||||
</AdminLayout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/admin/feedback" element={
|
||||
<ProtectedRoute requiredRole="system_admin">
|
||||
<AdminLayout>
|
||||
<AdminFeedback />
|
||||
</AdminLayout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/admin/registered-vendors" element={
|
||||
<ProtectedRoute requiredRole="system_admin">
|
||||
<AdminLayout>
|
||||
<RegisteredVendors />
|
||||
</AdminLayout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/admin/resellers" element={
|
||||
<ProtectedRoute requiredRole="system_admin">
|
||||
<AdminLayout>
|
||||
<Resellers />
|
||||
</AdminLayout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/reseller-dashboard/wallet" element={
|
||||
<ProtectedRoute requiredRole="reseller_admin">
|
||||
<ResellerLayout>
|
||||
@ -401,11 +500,13 @@ function App() {
|
||||
</Layout>
|
||||
} />
|
||||
|
||||
{/* Default Route - Redirect to login */}
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
{/* Default Route - Redirect based on user role */}
|
||||
<Route path="*" element={<RoleBasedRedirect />} />
|
||||
</Routes>
|
||||
<CookieConsent />
|
||||
<Toast />
|
||||
<AuthDebug />
|
||||
<DeveloperFeedback />
|
||||
</div>
|
||||
</Router>
|
||||
</AuthInitializer>
|
||||
|
||||
163
src/components/ApprovalStatusModal.tsx
Normal file
163
src/components/ApprovalStatusModal.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import React from 'react';
|
||||
import { Clock, AlertCircle, Mail, Phone, X } from 'lucide-react';
|
||||
|
||||
interface ApprovalStatusModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
userEmail?: string;
|
||||
companyName?: string;
|
||||
}
|
||||
|
||||
const ApprovalStatusModal: React.FC<ApprovalStatusModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
userEmail,
|
||||
companyName
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<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 max-w-md w-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center">
|
||||
<Clock className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Account Under Review
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Your registration is being processed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-start space-x-3 mb-4">
|
||||
<AlertCircle className="h-5 w-5 text-blue-600 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-2">
|
||||
Thank you for your registration!
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm leading-relaxed">
|
||||
Your account is currently under review by our administrative team.
|
||||
This process typically takes 1-3 business days. We'll notify you
|
||||
via email once your account has been approved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account Details */}
|
||||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 mb-6">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-3">
|
||||
Registration Details
|
||||
</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
{companyName && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Company:</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{companyName}</span>
|
||||
</div>
|
||||
)}
|
||||
{userEmail && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Email:</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{userEmail}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Status:</span>
|
||||
<span className="font-medium text-yellow-600">Under Review</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What happens next */}
|
||||
<div className="mb-6">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-3">
|
||||
What happens next?
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="w-6 h-6 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">1</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Our team reviews your business information and documentation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="w-6 h-6 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">2</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
We verify your business credentials and compliance requirements
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="w-6 h-6 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">3</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
You'll receive an email notification once approved
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Information */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-3">
|
||||
Need help? Contact our support team
|
||||
</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Mail className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
support@cloutopiaa.com
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Phone className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
+1 (555) 123-4567
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end p-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApprovalStatusModal;
|
||||
50
src/components/AuthDebug.tsx
Normal file
50
src/components/AuthDebug.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
import { testAuthPersistence } from '../utils/authTest';
|
||||
|
||||
const AuthDebug: React.FC = () => {
|
||||
const { isAuthenticated, user, isLoading, token, refreshToken, sessionId } = useAppSelector((state) => state.auth);
|
||||
|
||||
const handleTestAuth = () => {
|
||||
testAuthPersistence();
|
||||
};
|
||||
|
||||
const handleClearAuth = () => {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
localStorage.removeItem('sessionId');
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg p-4 shadow-lg z-50 max-w-sm">
|
||||
<h3 className="text-sm font-semibold text-slate-900 dark:text-white mb-2">Auth Debug</h3>
|
||||
|
||||
<div className="space-y-1 text-xs">
|
||||
<div>Authenticated: <span className={isAuthenticated ? 'text-green-600' : 'text-red-600'}>{isAuthenticated ? 'Yes' : 'No'}</span></div>
|
||||
<div>Loading: <span className={isLoading ? 'text-yellow-600' : 'text-gray-600'}>{isLoading ? 'Yes' : 'No'}</span></div>
|
||||
<div>User: <span className={user ? 'text-green-600' : 'text-red-600'}>{user ? 'Loaded' : 'None'}</span></div>
|
||||
<div>Token: <span className={token ? 'text-green-600' : 'text-red-600'}>{token ? 'Present' : 'Missing'}</span></div>
|
||||
<div>Refresh Token: <span className={refreshToken ? 'text-green-600' : 'text-red-600'}>{refreshToken ? 'Present' : 'Missing'}</span></div>
|
||||
<div>Session ID: <span className={sessionId ? 'text-green-600' : 'text-red-600'}>{sessionId ? 'Present' : 'Missing'}</span></div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-1">
|
||||
<button
|
||||
onClick={handleTestAuth}
|
||||
className="w-full px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Test Auth
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearAuth}
|
||||
className="w-full px-2 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700"
|
||||
>
|
||||
Clear Auth
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthDebug;
|
||||
@ -1,11 +1,12 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import { getCurrentUser, refreshUserToken } from '../store/slices/authThunks';
|
||||
import { setTokens } from '../store/slices/authSlice';
|
||||
import { setTokens, logout } from '../store/slices/authSlice';
|
||||
|
||||
const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { isAuthenticated, user } = useAppSelector((state) => state.auth);
|
||||
const { isAuthenticated, user, isLoading } = useAppSelector((state) => state.auth);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeAuth = async () => {
|
||||
@ -26,29 +27,52 @@ const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children })
|
||||
// Try to get current user
|
||||
try {
|
||||
await dispatch(getCurrentUser()).unwrap();
|
||||
console.log('User loaded successfully');
|
||||
} catch (error) {
|
||||
console.log('Failed to get current user, trying to refresh token...', error);
|
||||
// If getting user fails, try to refresh token
|
||||
console.log('Failed to get current user, trying to refresh token...');
|
||||
try {
|
||||
await dispatch(refreshUserToken()).unwrap();
|
||||
// Try to get user again after token refresh
|
||||
await dispatch(getCurrentUser()).unwrap();
|
||||
console.log('User loaded successfully after token refresh');
|
||||
} catch (refreshError) {
|
||||
console.log('Token refresh failed, clearing auth data...');
|
||||
// Clear invalid tokens
|
||||
console.log('Token refresh failed, clearing auth data...', refreshError);
|
||||
// Clear invalid tokens and logout
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
localStorage.removeItem('sessionId');
|
||||
dispatch(logout());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('No tokens found in localStorage');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth initialization error:', error);
|
||||
// Clear any invalid tokens
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
localStorage.removeItem('sessionId');
|
||||
dispatch(logout());
|
||||
} finally {
|
||||
setIsInitialized(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isInitialized) {
|
||||
initializeAuth();
|
||||
}, [dispatch]);
|
||||
}
|
||||
}, [dispatch, isInitialized]);
|
||||
|
||||
// Show loading while initializing authentication
|
||||
if (!isInitialized || (isAuthenticated && !user && isLoading)) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
197
src/components/BlockedAccountModal.tsx
Normal file
197
src/components/BlockedAccountModal.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
import React from 'react';
|
||||
import { X, AlertTriangle, Mail, Phone, MessageCircle, Clock, UserCheck, ExternalLink, RefreshCw } from 'lucide-react';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
interface BlockedAccountModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
userEmail: string;
|
||||
userStatus: string;
|
||||
}
|
||||
|
||||
const BlockedAccountModal: React.FC<BlockedAccountModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
userEmail,
|
||||
userStatus
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getStatusMessage = () => {
|
||||
switch (userStatus) {
|
||||
case 'inactive':
|
||||
return {
|
||||
title: 'Account Deactivated',
|
||||
message: 'Your account has been deactivated by the administrator. This usually happens when your account has been inactive for an extended period or when requested by you.',
|
||||
icon: <AlertTriangle className="w-12 h-12 text-red-500" />,
|
||||
color: 'red',
|
||||
bgColor: 'bg-red-50 dark:bg-red-900/20',
|
||||
borderColor: 'border-red-200 dark:border-red-800',
|
||||
textColor: 'text-red-800 dark:text-red-200',
|
||||
actionMessage: 'To reactivate your account, please contact your vendor administrator.'
|
||||
};
|
||||
case 'suspended':
|
||||
return {
|
||||
title: 'Account Suspended',
|
||||
message: 'Your account has been suspended due to policy violations or security concerns. This is a temporary measure to protect our platform and users.',
|
||||
icon: <AlertTriangle className="w-12 h-12 text-orange-500" />,
|
||||
color: 'orange',
|
||||
bgColor: 'bg-orange-50 dark:bg-orange-900/20',
|
||||
borderColor: 'border-orange-200 dark:border-orange-800',
|
||||
textColor: 'text-orange-800 dark:text-orange-200',
|
||||
actionMessage: 'Please contact your vendor administrator to understand the reason and resolve the issue.'
|
||||
};
|
||||
case 'pending':
|
||||
return {
|
||||
title: 'Account Pending Approval',
|
||||
message: 'Your account is currently pending approval from your vendor administrator. This is normal for new registrations and helps maintain platform security.',
|
||||
icon: <Clock className="w-12 h-12 text-yellow-500" />,
|
||||
color: 'yellow',
|
||||
bgColor: 'bg-yellow-50 dark:bg-yellow-900/20',
|
||||
borderColor: 'border-yellow-200 dark:border-yellow-800',
|
||||
textColor: 'text-yellow-800 dark:text-yellow-200',
|
||||
actionMessage: 'You will receive an email notification once your account is approved by your vendor administrator.'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: 'Account Blocked',
|
||||
message: 'Your account is currently blocked and cannot be used to access the system.',
|
||||
icon: <AlertTriangle className="w-12 h-12 text-gray-500" />,
|
||||
color: 'gray',
|
||||
bgColor: 'bg-gray-50 dark:bg-gray-900/20',
|
||||
borderColor: 'border-gray-200 dark:border-gray-800',
|
||||
textColor: 'text-gray-800 dark:text-gray-200',
|
||||
actionMessage: 'Please contact your vendor administrator to resolve this issue.'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const statusInfo = getStatusMessage();
|
||||
|
||||
const handleContactVendor = () => {
|
||||
const subject = encodeURIComponent(`Account Status Inquiry - ${statusInfo.title}`);
|
||||
const body = encodeURIComponent(
|
||||
`Hello Vendor Administrator,\n\nI am inquiring about my account status.\n\nEmail: ${userEmail}\nCurrent Status: ${userStatus}\n\nPlease let me know what additional information you need to resolve this issue.\n\nThank you.`
|
||||
);
|
||||
window.open(`mailto:admin@cloudtopiaa.com?subject=${subject}&body=${body}`, '_blank');
|
||||
};
|
||||
|
||||
const handleContactSupport = () => {
|
||||
const subject = encodeURIComponent(`Account Status Support - ${statusInfo.title}`);
|
||||
const body = encodeURIComponent(
|
||||
`Hello Support Team,\n\nI need assistance with my account status.\n\nEmail: ${userEmail}\nCurrent Status: ${userStatus}\n\nPlease help me understand what I need to do to resolve this.\n\nThank you.`
|
||||
);
|
||||
window.open(`mailto:support@cloudtopiaa.com?subject=${subject}&body=${body}`, '_blank');
|
||||
};
|
||||
|
||||
const handleRefreshPage = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<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-lg shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center space-x-3">
|
||||
{statusInfo.icon}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{statusInfo.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Access Restricted
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<div className={cn(
|
||||
"p-4 rounded-lg border",
|
||||
statusInfo.bgColor,
|
||||
statusInfo.borderColor
|
||||
)}>
|
||||
<p className={cn("text-sm", statusInfo.textColor)}>
|
||||
{statusInfo.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
|
||||
What you need to do:
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
{statusInfo.actionMessage}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={handleContactVendor}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
Contact Vendor Administrator
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleContactSupport}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
Contact Support
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleRefreshPage}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Refresh Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||
Need immediate assistance?
|
||||
</h4>
|
||||
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="w-4 h-4" />
|
||||
<span>Call: +1 (555) 123-4567</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-4 h-4" />
|
||||
<span>Email: support@cloudtopiaa.com</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
<span>Support Portal: support.cloudtopiaa.com</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlockedAccountModal;
|
||||
@ -36,7 +36,7 @@ const CookieConsent: React.FC = () => {
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center p-4 sm:items-center sm:p-6">
|
||||
<div className="fixed inset-0 z-[9999] flex items-end justify-center p-4 sm:items-center sm:p-6" style={{ backdropFilter: 'blur(4px)' }}>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-300"
|
||||
|
||||
334
src/components/DeveloperFeedback.tsx
Normal file
334
src/components/DeveloperFeedback.tsx
Normal file
@ -0,0 +1,334 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
MessageSquare,
|
||||
Send,
|
||||
X,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Bug,
|
||||
Lightbulb,
|
||||
HelpCircle,
|
||||
Star
|
||||
} from 'lucide-react';
|
||||
|
||||
interface FeedbackTicket {
|
||||
id: string;
|
||||
type: 'bug' | 'feature' | 'question' | 'general';
|
||||
title: string;
|
||||
description: string;
|
||||
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||
status: 'open' | 'in-progress' | 'resolved' | 'closed';
|
||||
createdAt: string;
|
||||
userAgent: string;
|
||||
url: string;
|
||||
submittedBy?: string;
|
||||
rating?: number;
|
||||
}
|
||||
|
||||
const DeveloperFeedback: React.FC = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [tickets, setTickets] = useState<FeedbackTicket[]>([]);
|
||||
const [currentTicket, setCurrentTicket] = useState<Partial<FeedbackTicket>>({
|
||||
type: 'general',
|
||||
priority: 'medium',
|
||||
title: '',
|
||||
description: ''
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const ticketTypes = [
|
||||
{ id: 'bug', label: 'Bug Report', icon: Bug, color: 'text-red-600' },
|
||||
{ id: 'feature', label: 'Feature Request', icon: Lightbulb, color: 'text-blue-600' },
|
||||
{ id: 'question', label: 'Question', icon: HelpCircle, color: 'text-green-600' },
|
||||
{ id: 'general', label: 'General Feedback', icon: Star, color: 'text-yellow-600' }
|
||||
];
|
||||
|
||||
const priorities = [
|
||||
{ id: 'low', label: 'Low', color: 'text-gray-600' },
|
||||
{ id: 'medium', label: 'Medium', color: 'text-yellow-600' },
|
||||
{ id: 'high', label: 'High', color: 'text-orange-600' },
|
||||
{ id: 'critical', label: 'Critical', color: 'text-red-600' }
|
||||
];
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!currentTicket.title || !currentTicket.description) {
|
||||
alert('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const newTicket: FeedbackTicket = {
|
||||
id: Date.now().toString(),
|
||||
type: currentTicket.type as 'bug' | 'feature' | 'question' | 'general',
|
||||
title: currentTicket.title,
|
||||
description: currentTicket.description,
|
||||
priority: currentTicket.priority as 'low' | 'medium' | 'high' | 'critical',
|
||||
status: 'open',
|
||||
createdAt: new Date().toISOString(),
|
||||
userAgent: navigator.userAgent,
|
||||
url: window.location.href,
|
||||
submittedBy: 'Developer' // This will be overridden by backend with actual user info
|
||||
};
|
||||
|
||||
// API call to submit feedback
|
||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/developer-feedback`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: newTicket.type,
|
||||
title: newTicket.title,
|
||||
description: newTicket.description,
|
||||
priority: newTicket.priority,
|
||||
userAgent: newTicket.userAgent,
|
||||
url: newTicket.url,
|
||||
submittedBy: newTicket.submittedBy
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `HTTP ${response.status}: Failed to submit feedback`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'Failed to submit feedback');
|
||||
}
|
||||
|
||||
setTickets(prev => [newTicket, ...prev]);
|
||||
setCurrentTicket({ type: 'general', priority: 'medium', title: '', description: '' });
|
||||
setSubmitted(true);
|
||||
|
||||
setTimeout(() => setSubmitted(false), 3000);
|
||||
} catch (error) {
|
||||
console.error('Error submitting feedback:', error);
|
||||
alert('Failed to submit feedback. Please try again.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'open': return 'text-blue-600';
|
||||
case 'in-progress': return 'text-yellow-600';
|
||||
case 'resolved': return 'text-green-600';
|
||||
case 'closed': return 'text-gray-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'open': return <Clock className="h-4 w-4" />;
|
||||
case 'in-progress': return <AlertCircle className="h-4 w-4" />;
|
||||
case 'resolved': return <CheckCircle className="h-4 w-4" />;
|
||||
case 'closed': return <X className="h-4 w-4" />;
|
||||
default: return <Clock className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating Button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="fixed bottom-20 right-4 bg-blue-600 hover:bg-blue-700 text-white rounded-full p-3 shadow-lg z-[9998] transition-all duration-200"
|
||||
title="Developer Feedback"
|
||||
>
|
||||
<MessageSquare className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
{/* Modal */}
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999] p-4" style={{ backdropFilter: 'blur(4px)' }}>
|
||||
<style>
|
||||
{`
|
||||
.dev-feedback-scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.dev-feedback-scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto dev-feedback-scrollbar-hide">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Developer Feedback
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Success Message */}
|
||||
{submitted && (
|
||||
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400 mr-2" />
|
||||
<span className="text-green-800 dark:text-green-200">
|
||||
Feedback submitted successfully! Thank you for your input.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ticket Type Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Ticket Type
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{ticketTypes.map((type) => {
|
||||
const Icon = type.icon;
|
||||
return (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => setCurrentTicket(prev => ({ ...prev, type: type.id as any }))}
|
||||
className={`p-3 border rounded-lg text-left transition-colors ${
|
||||
currentTicket.type === type.id
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Icon className={`h-5 w-5 ${type.color}`} />
|
||||
<span className="text-sm font-medium">{type.label}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Priority
|
||||
</label>
|
||||
<div className="flex space-x-3">
|
||||
{priorities.map((priority) => (
|
||||
<button
|
||||
key={priority.id}
|
||||
onClick={() => setCurrentTicket(prev => ({ ...prev, priority: priority.id as any }))}
|
||||
className={`px-3 py-2 border rounded-md text-sm transition-colors ${
|
||||
currentTicket.priority === priority.id
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<span className={priority.color}>{priority.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={currentTicket.title}
|
||||
onChange={(e) => setCurrentTicket(prev => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="Brief description of the issue or request"
|
||||
className="w-full px-3 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Description *
|
||||
</label>
|
||||
<textarea
|
||||
value={currentTicket.description}
|
||||
onChange={(e) => setCurrentTicket(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Please provide detailed information about the issue, feature request, or feedback..."
|
||||
rows={6}
|
||||
className="w-full px-3 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 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* System Info */}
|
||||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
System Information (Auto-collected)
|
||||
</h4>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<div>URL: {window.location.href}</div>
|
||||
<div>User Agent: {navigator.userAgent.substring(0, 100)}...</div>
|
||||
<div>Timestamp: {new Date().toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || !currentTicket.title || !currentTicket.description}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2"
|
||||
>
|
||||
{submitting ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
<span>{submitting ? 'Submitting...' : 'Submit Feedback'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Tickets (if any) */}
|
||||
{tickets.length > 0 && (
|
||||
<div className="fixed bottom-20 left-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 max-w-sm z-40">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Recent Tickets ({tickets.length})
|
||||
</h3>
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto">
|
||||
{tickets.slice(0, 3).map((ticket) => (
|
||||
<div key={ticket.id} className="text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium truncate">{ticket.title}</span>
|
||||
<div className={`flex items-center ${getStatusColor(ticket.status)}`}>
|
||||
{getStatusIcon(ticket.status)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
{new Date(ticket.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeveloperFeedback;
|
||||
383
src/components/DraggableFeedback.tsx
Normal file
383
src/components/DraggableFeedback.tsx
Normal file
@ -0,0 +1,383 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { MessageCircle, X, Minimize2, Maximize2, Move } from 'lucide-react';
|
||||
|
||||
interface DraggableFeedbackProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const DraggableFeedback: React.FC<DraggableFeedbackProps> = ({
|
||||
onClose
|
||||
}) => {
|
||||
const [position, setPosition] = useState({ x: 20, y: 20 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [rating, setRating] = useState(0);
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
|
||||
// Reset form when component mounts or when user navigates
|
||||
useEffect(() => {
|
||||
const resetForm = () => {
|
||||
setFeedback('');
|
||||
setEmail('');
|
||||
setRating(0);
|
||||
setIsSubmitted(false);
|
||||
};
|
||||
|
||||
// Reset on mount
|
||||
resetForm();
|
||||
|
||||
// Reset when user navigates (page visibility change)
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
resetForm();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const feedbackRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Enhanced mouse move handler with no viewport constraints
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (isDragging && feedbackRef.current) {
|
||||
const newX = e.clientX - dragOffset.x;
|
||||
const newY = e.clientY - dragOffset.y;
|
||||
|
||||
// Allow positioning anywhere on screen - no viewport bounds
|
||||
setPosition({
|
||||
x: newX,
|
||||
y: newY
|
||||
});
|
||||
}
|
||||
}, [isDragging, dragOffset]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
// Prevent text selection while dragging
|
||||
document.body.style.userSelect = 'none';
|
||||
document.body.style.cursor = 'grabbing';
|
||||
} else {
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.cursor = '';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.cursor = '';
|
||||
};
|
||||
}, [isDragging, handleMouseMove, handleMouseUp]);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
// Only allow dragging from the header or drag handle
|
||||
if (feedbackRef.current && (e.target as HTMLElement).closest('[data-draggable]')) {
|
||||
const rect = feedbackRef.current.getBoundingClientRect();
|
||||
setDragOffset({
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top
|
||||
});
|
||||
setIsDragging(true);
|
||||
e.preventDefault(); // Prevent text selection
|
||||
}
|
||||
};
|
||||
|
||||
// Touch event handlers for mobile devices
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
if (feedbackRef.current && (e.target as HTMLElement).closest('[data-draggable]')) {
|
||||
const touch = e.touches[0];
|
||||
const rect = feedbackRef.current.getBoundingClientRect();
|
||||
setDragOffset({
|
||||
x: touch.clientX - rect.left,
|
||||
y: touch.clientY - rect.top
|
||||
});
|
||||
setIsDragging(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = useCallback((e: TouchEvent) => {
|
||||
if (isDragging && feedbackRef.current) {
|
||||
e.preventDefault(); // Prevent scrolling while dragging
|
||||
const touch = e.touches[0];
|
||||
const newX = touch.clientX - dragOffset.x;
|
||||
const newY = touch.clientY - dragOffset.y;
|
||||
|
||||
setPosition({
|
||||
x: newX,
|
||||
y: newY
|
||||
});
|
||||
}
|
||||
}, [isDragging, dragOffset]);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
};
|
||||
}, [isDragging, handleTouchMove, handleTouchEnd]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!feedback.trim()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// Here you would typically send the feedback to your backend
|
||||
console.log('Feedback submitted:', { feedback, email, rating });
|
||||
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
setIsSubmitted(true);
|
||||
|
||||
// Reset form after 2 seconds
|
||||
setTimeout(() => {
|
||||
setIsSubmitted(false);
|
||||
setFeedback('');
|
||||
setEmail('');
|
||||
setRating(0);
|
||||
setIsSubmitting(false);
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error('Error submitting feedback:', error);
|
||||
setError('Failed to submit feedback. Please try again.');
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// If minimized, show only the header
|
||||
if (isMinimized) {
|
||||
return (
|
||||
<div
|
||||
ref={feedbackRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
zIndex: 9999,
|
||||
width: '300px'
|
||||
}}
|
||||
className="bg-white dark:bg-slate-800 rounded-lg shadow-lg border border-slate-200 dark:border-slate-700 overflow-hidden"
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
>
|
||||
<div
|
||||
className="bg-emerald-600 text-white p-3 cursor-move flex items-center justify-between"
|
||||
data-draggable="true"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<MessageCircle className="w-5 h-5" />
|
||||
<span className="font-medium">Feedback</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<button
|
||||
onClick={() => setIsMinimized(false)}
|
||||
className="p-1 hover:bg-emerald-700 rounded transition-colors"
|
||||
title="Expand"
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</button>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-emerald-700 rounded transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
.feedback-scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.feedback-scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.feedback-dragging {
|
||||
user-select: none;
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div
|
||||
ref={feedbackRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
zIndex: 9999,
|
||||
width: '300px',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
className={`bg-white dark:bg-slate-800 rounded-lg shadow-lg border border-slate-200 dark:border-slate-700 overflow-hidden ${isDragging ? 'feedback-dragging' : ''}`}
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
>
|
||||
{/* Header - Draggable area */}
|
||||
<div
|
||||
className="bg-emerald-600 text-white p-3 cursor-move flex items-center justify-between"
|
||||
data-draggable="true"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Move className="w-4 h-4 opacity-80" />
|
||||
<MessageCircle className="w-5 h-5" />
|
||||
<span className="font-medium">Feedback</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<button
|
||||
onClick={() => setIsMinimized(true)}
|
||||
className="p-1 hover:bg-emerald-700 rounded transition-colors"
|
||||
title="Minimize"
|
||||
>
|
||||
<Minimize2 className="w-4 h-4" />
|
||||
</button>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-emerald-700 rounded transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className="p-4 overflow-y-auto feedback-scrollbar-hide"
|
||||
style={{
|
||||
maxHeight: 'calc(80vh - 60px)'
|
||||
}}
|
||||
>
|
||||
{isSubmitted ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="w-12 h-12 bg-emerald-100 dark:bg-emerald-900 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<MessageCircle className="w-6 h-6 text-emerald-600" />
|
||||
</div>
|
||||
<h3 className="font-medium text-slate-900 dark:text-white mb-1">Thank you!</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Your feedback has been submitted successfully.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
|
||||
<p className="text-red-800 dark:text-red-200 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Rating */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
How would you rate your experience?
|
||||
</label>
|
||||
<div className="flex space-x-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
onClick={() => setRating(star)}
|
||||
className={`text-2xl ${
|
||||
star <= rating ? 'text-yellow-400' : 'text-slate-300'
|
||||
} hover:text-yellow-400 transition-colors`}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Email (optional)
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-md bg-white dark:bg-slate-700 text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
placeholder="your@email.com (optional)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Your feedback
|
||||
</label>
|
||||
<textarea
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-md bg-white dark:bg-slate-700 text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent resize-none"
|
||||
placeholder="Tell us what you think..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!feedback.trim() || isSubmitting}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700 disabled:bg-slate-300 text-white font-medium py-2 px-4 rounded-md transition-colors disabled:cursor-not-allowed flex items-center justify-center"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
'Submit Feedback'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DraggableFeedback;
|
||||
234
src/components/InactiveUserModal.tsx
Normal file
234
src/components/InactiveUserModal.tsx
Normal file
@ -0,0 +1,234 @@
|
||||
import React from 'react';
|
||||
import { X, AlertTriangle, Mail, Phone, MessageCircle, Clock, UserCheck, ExternalLink, RefreshCw } from 'lucide-react';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
interface InactiveUserModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
userEmail: string;
|
||||
userStatus: string;
|
||||
}
|
||||
|
||||
const InactiveUserModal: React.FC<InactiveUserModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
userEmail,
|
||||
userStatus
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getStatusMessage = () => {
|
||||
switch (userStatus) {
|
||||
case 'inactive':
|
||||
return {
|
||||
title: 'Account Deactivated',
|
||||
message: 'Your account has been deactivated by the administrator. This usually happens when your account has been inactive for an extended period or when requested by you.',
|
||||
icon: <AlertTriangle className="w-12 h-12 text-red-500" />,
|
||||
color: 'red',
|
||||
bgColor: 'bg-red-50 dark:bg-red-900/20',
|
||||
borderColor: 'border-red-200 dark:border-red-800',
|
||||
textColor: 'text-red-800 dark:text-red-200',
|
||||
actionMessage: 'To reactivate your account, please contact the administrator with your request.'
|
||||
};
|
||||
case 'suspended':
|
||||
return {
|
||||
title: 'Account Suspended',
|
||||
message: 'Your account has been suspended due to policy violations or security concerns. This is a temporary measure to protect our platform and users.',
|
||||
icon: <AlertTriangle className="w-12 h-12 text-orange-500" />,
|
||||
color: 'orange',
|
||||
bgColor: 'bg-orange-50 dark:bg-orange-900/20',
|
||||
borderColor: 'border-orange-200 dark:border-orange-800',
|
||||
textColor: 'text-orange-800 dark:text-orange-200',
|
||||
actionMessage: 'Please contact support to understand the reason and resolve the issue.'
|
||||
};
|
||||
case 'pending':
|
||||
return {
|
||||
title: 'Account Pending Approval',
|
||||
message: 'Your account is currently pending approval from the administrator. This is normal for new registrations and helps us maintain platform security.',
|
||||
icon: <Clock className="w-12 h-12 text-yellow-500" />,
|
||||
color: 'yellow',
|
||||
bgColor: 'bg-yellow-50 dark:bg-yellow-900/20',
|
||||
borderColor: 'border-yellow-200 dark:border-yellow-800',
|
||||
textColor: 'text-yellow-800 dark:text-yellow-200',
|
||||
actionMessage: 'You will receive an email notification once your account is approved.'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: 'Account Inactive',
|
||||
message: 'Your account is currently inactive and cannot be used to access the system.',
|
||||
icon: <AlertTriangle className="w-12 h-12 text-gray-500" />,
|
||||
color: 'gray',
|
||||
bgColor: 'bg-gray-50 dark:bg-gray-900/20',
|
||||
borderColor: 'border-gray-200 dark:border-gray-800',
|
||||
textColor: 'text-gray-800 dark:text-gray-200',
|
||||
actionMessage: 'Please contact the administrator to resolve this issue.'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const statusInfo = getStatusMessage();
|
||||
|
||||
const handleContactAdmin = () => {
|
||||
const subject = encodeURIComponent(`Account Status Inquiry - ${statusInfo.title}`);
|
||||
const body = encodeURIComponent(
|
||||
`Hello,\n\nI am inquiring about my account status.\n\nEmail: ${userEmail}\nCurrent Status: ${userStatus}\n\nPlease let me know what additional information you need to resolve this issue.\n\nThank you.`
|
||||
);
|
||||
window.open(`mailto:admin@cloudtopiaa.com?subject=${subject}&body=${body}`, '_blank');
|
||||
};
|
||||
|
||||
const handleContactSupport = () => {
|
||||
const subject = encodeURIComponent(`Account Status Support - ${statusInfo.title}`);
|
||||
const body = encodeURIComponent(
|
||||
`Hello Support Team,\n\nI need assistance with my account status.\n\nEmail: ${userEmail}\nCurrent Status: ${userStatus}\n\nPlease help me understand what I need to do to resolve this.\n\nThank you.`
|
||||
);
|
||||
window.open(`mailto:support@cloudtopiaa.com?subject=${subject}&body=${body}`, '_blank');
|
||||
};
|
||||
|
||||
const handleRefreshPage = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<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-lg shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center space-x-3">
|
||||
{statusInfo.icon}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{statusInfo.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Account: {userEmail}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="Close"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{/* Status Message */}
|
||||
<div className={cn(
|
||||
"p-4 rounded-lg mb-6",
|
||||
statusInfo.bgColor,
|
||||
statusInfo.borderColor
|
||||
)}>
|
||||
<p className={cn("text-sm leading-relaxed", statusInfo.textColor)}>
|
||||
{statusInfo.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Message */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-6">
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2 flex items-center">
|
||||
<UserCheck className="w-4 h-4 mr-2" />
|
||||
What You Can Do
|
||||
</h4>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
{statusInfo.actionMessage}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Contact Information */}
|
||||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 mb-6">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-3">
|
||||
Contact Information
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Mail className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Administrator
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href="mailto:admin@cloudtopiaa.com"
|
||||
className="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 flex items-center"
|
||||
>
|
||||
admin@cloudtopiaa.com
|
||||
<ExternalLink className="w-3 h-3 ml-1" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<MessageCircle className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Support Team
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href="mailto:support@cloudtopiaa.com"
|
||||
className="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 flex items-center"
|
||||
>
|
||||
support@cloudtopiaa.com
|
||||
<ExternalLink className="w-3 h-3 ml-1" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Phone className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Phone: +1 (555) 123-4567
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleContactAdmin}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors flex items-center justify-center"
|
||||
>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
Contact Admin
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleContactSupport}
|
||||
className="flex-1 px-4 py-2 bg-emerald-600 text-white rounded-md hover:bg-emerald-700 transition-colors flex items-center justify-center"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 mr-2" />
|
||||
Support
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Additional Actions */}
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
onClick={handleRefreshPage}
|
||||
className="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 flex items-center justify-center mx-auto hover:underline"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
Refresh Page
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Help Text */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
If you believe this is an error, please contact our support team immediately.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InactiveUserModal;
|
||||
@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import AdminSidebar from './AdminSidebar';
|
||||
import NotificationBell from '../NotificationBell';
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@ -38,11 +39,7 @@ const AdminLayout: React.FC<AdminLayoutProps> = ({ children }) => {
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Notifications */}
|
||||
<button className="p-2 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-5 5v-5zM4.19 4.19A4 4 0 004 6v6a4 4 0 004 4h6a4 4 0 004-4V6a4 4 0 00-4-4H6a4 4 0 00-2.81 1.19z" />
|
||||
</svg>
|
||||
</button>
|
||||
<NotificationBell />
|
||||
|
||||
{/* User Menu */}
|
||||
<div className="flex items-center space-x-3">
|
||||
@ -60,7 +57,7 @@ const AdminLayout: React.FC<AdminLayoutProps> = ({ children }) => {
|
||||
</div>
|
||||
|
||||
{/* Page Content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -4,7 +4,7 @@ import { useAppSelector, useAppDispatch } from '../../store/hooks';
|
||||
import { toggleTheme } from '../../store/slices/themeSlice';
|
||||
import {
|
||||
Home, Users, Building, Clock, Settings, Menu, X, Sun, Moon, LogOut,
|
||||
Shield, BarChart3, FileText, UserCheck, UserX, TrendingUp, Activity
|
||||
Shield, BarChart3, FileText, UserCheck, UserX, TrendingUp, Activity, Package, MessageSquare
|
||||
} from 'lucide-react';
|
||||
import { logout } from '../../store/slices/authSlice';
|
||||
import { cn } from '../../utils/cn';
|
||||
@ -12,10 +12,13 @@ import { cn } from '../../utils/cn';
|
||||
const adminNavigation = [
|
||||
{ name: 'Dashboard', href: '/admin', icon: Home },
|
||||
{ name: 'Vendor Requests', href: '/admin/vendor-requests', icon: Clock },
|
||||
{ name: 'Channel Partners', href: '/admin/channel-partners', icon: Building },
|
||||
{ name: 'Registered Vendors', href: '/admin/registered-vendors', icon: Building },
|
||||
{ name: 'Resellers', href: '/admin/resellers', icon: Users },
|
||||
{ name: 'System Users', href: '/admin/users', icon: Users },
|
||||
{ name: 'Products', href: '/admin/products', icon: Package },
|
||||
{ name: 'Analytics', href: '/admin/analytics', icon: BarChart3 },
|
||||
{ name: 'Reports', href: '/admin/reports', icon: FileText },
|
||||
{ name: 'Feedback', href: '/admin/feedback', icon: MessageSquare },
|
||||
{ name: 'Settings', href: '/admin/settings', icon: Settings },
|
||||
];
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useAppSelector } from '../../store/hooks';
|
||||
import Sidebar from './Sidebar';
|
||||
|
||||
interface LayoutProps {
|
||||
@ -6,15 +7,20 @@ interface LayoutProps {
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
const { user } = useAppSelector((state) => state.auth);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="p-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -22,7 +22,8 @@ import {
|
||||
Building,
|
||||
Target,
|
||||
TrendingUp,
|
||||
Package
|
||||
Package,
|
||||
FileText
|
||||
} from 'lucide-react';
|
||||
import { RootState } from '../../store';
|
||||
import { toggleTheme } from '../../store/slices/themeSlice';
|
||||
@ -36,6 +37,7 @@ const resellerNavigation = [
|
||||
{ name: 'Billing', href: '/reseller-dashboard/billing', icon: CreditCard },
|
||||
{ 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: 'Marketplace', href: '/reseller-dashboard/marketplace', icon: ShoppingBag },
|
||||
|
||||
@ -35,9 +35,9 @@ import toast from 'react-hot-toast';
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: Home },
|
||||
{ name: 'Product Management', href: '/product-management', icon: Package },
|
||||
{ name: 'Resellers', href: '/resellers', icon: Users },
|
||||
{ name: 'Partnerships', href: '/partnerships', icon: Handshake },
|
||||
{ name: 'Deals', href: '/deals', icon: Briefcase },
|
||||
{ name: 'Reseller Requests', href: '/resellers', icon: Users },
|
||||
{ name: 'Approved Resellers', href: '/approved-resellers', icon: Handshake },
|
||||
// { name: 'Deals', href: '/deals', icon: Briefcase },
|
||||
{ name: 'Commissions', href: '/commissions', icon: Wallet },
|
||||
{ name: 'Training', href: '/training', icon: BookOpen },
|
||||
{ name: 'Support', href: '/support', icon: Headphones },
|
||||
|
||||
@ -39,11 +39,23 @@ const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, size =
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center p-4 pt-8 overflow-y-auto">
|
||||
<div className="fixed inset-0 z-[9999] flex items-start justify-center p-4 pt-8 overflow-y-auto modal-scrollbar-hide">
|
||||
<style>
|
||||
{`
|
||||
.modal-scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.modal-scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
style={{ backdropFilter: 'blur(4px)' }}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
@ -65,7 +77,7 @@ const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, size =
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(100vh-200px)] scrollbar-thin">
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(100vh-200px)] modal-scrollbar-hide">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
91
src/components/NotificationBell.tsx
Normal file
91
src/components/NotificationBell.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Bell } from 'lucide-react';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
import NotificationPanel from './NotificationPanel';
|
||||
|
||||
const NotificationBell: React.FC = () => {
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { user } = useAppSelector((state) => state.auth);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUnreadCount();
|
||||
|
||||
// Set up polling for new notifications
|
||||
const interval = setInterval(fetchUnreadCount, 30000); // Check every 30 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [user]);
|
||||
|
||||
const fetchUnreadCount = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Determine the appropriate endpoint based on user role
|
||||
let endpoint = '/admin/notifications/stats';
|
||||
if (user?.role && user.role.startsWith('channel_partner_')) {
|
||||
endpoint = '/vendors/notifications/stats';
|
||||
} else if (user?.role && user.role.startsWith('reseller_')) {
|
||||
endpoint = '/resellers/notifications/stats';
|
||||
}
|
||||
|
||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}${endpoint}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUnreadCount(data.data?.unreadNotifications || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching notification count:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBellClick = () => {
|
||||
setIsPanelOpen(true);
|
||||
};
|
||||
|
||||
const handlePanelClose = () => {
|
||||
setIsPanelOpen(false);
|
||||
// Refresh count when panel closes
|
||||
fetchUnreadCount();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={handleBellClick}
|
||||
className="relative p-2 text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||||
disabled={loading}
|
||||
>
|
||||
<Bell className="w-6 h-6" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs font-bold rounded-full h-5 w-5 flex items-center justify-center min-w-[20px]">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<NotificationPanel
|
||||
isOpen={isPanelOpen}
|
||||
onClose={handlePanelClose}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationBell;
|
||||
394
src/components/NotificationPanel.tsx
Normal file
394
src/components/NotificationPanel.tsx
Normal file
@ -0,0 +1,394 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Bell, X, Check, Trash2, Clock, AlertCircle, CheckCircle, XCircle, Users, UserPlus } from 'lucide-react';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: 'NEW_VENDOR_REQUEST' | 'NEW_RESELLER_REQUEST' | 'VENDOR_APPROVED' | 'VENDOR_REJECTED' | 'RESELLER_APPROVED' | 'RESELLER_REJECTED' | 'RESELLER_CREATED' | 'RESELLER_ACCOUNT_CREATED' | 'SYSTEM_ALERT' | 'GENERAL';
|
||||
title: string;
|
||||
message: string;
|
||||
data?: any;
|
||||
isRead: boolean;
|
||||
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||
createdAt: string;
|
||||
sender?: {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface NotificationPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const NotificationPanel: React.FC<NotificationPanelProps> = ({ isOpen, onClose }) => {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'all' | 'unread'>('all');
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [deletingNotificationId, setDeletingNotificationId] = useState<string | null>(null);
|
||||
const { user } = useAppSelector((state) => state.auth);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchNotifications();
|
||||
}
|
||||
}, [isOpen, activeTab, user]);
|
||||
|
||||
const getApiEndpoint = () => {
|
||||
if (user?.role && user.role.startsWith('channel_partner_')) {
|
||||
return '/vendors/notifications';
|
||||
} else if (user?.role && user.role.startsWith('reseller_')) {
|
||||
return '/resellers/notifications';
|
||||
} else {
|
||||
return '/admin/notifications';
|
||||
}
|
||||
};
|
||||
|
||||
const getMarkAsReadEndpoint = (notificationId: string) => {
|
||||
if (user?.role && user.role.startsWith('channel_partner_')) {
|
||||
return `/vendors/notifications/${notificationId}/read`;
|
||||
} else if (user?.role && user.role.startsWith('reseller_')) {
|
||||
return `/resellers/notifications/${notificationId}/read`;
|
||||
} else {
|
||||
return `/admin/notifications/${notificationId}/read`;
|
||||
}
|
||||
};
|
||||
|
||||
const getMarkAllAsReadEndpoint = () => {
|
||||
if (user?.role && user.role.startsWith('channel_partner_')) {
|
||||
return '/vendors/notifications/read-all';
|
||||
} else if (user?.role && user.role.startsWith('reseller_')) {
|
||||
return '/resellers/notifications/read-all';
|
||||
} else {
|
||||
return '/admin/notifications/mark-all-read';
|
||||
}
|
||||
};
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const endpoint = getApiEndpoint();
|
||||
const params = new URLSearchParams({
|
||||
page: '1',
|
||||
limit: '50',
|
||||
...(activeTab === 'unread' && { unreadOnly: 'true' })
|
||||
});
|
||||
|
||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}${endpoint}?${params}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setNotifications(data.data.notifications);
|
||||
setUnreadCount(data.data.notifications.filter((n: Notification) => !n.isRead).length);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching notifications:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const markAsRead = async (notificationId: string) => {
|
||||
try {
|
||||
const endpoint = getMarkAsReadEndpoint(notificationId);
|
||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}${endpoint}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
setNotifications(prev =>
|
||||
prev.map(notif =>
|
||||
notif.id === notificationId
|
||||
? { ...notif, isRead: true }
|
||||
: notif
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error marking notification as read:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const markAllAsRead = async () => {
|
||||
try {
|
||||
const endpoint = getMarkAllAsReadEndpoint();
|
||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}${endpoint}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setNotifications(prev => prev.map(notif => ({ ...notif, isRead: true })));
|
||||
setUnreadCount(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error marking all notifications as read:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteNotification = (notificationId: string) => {
|
||||
setDeletingNotificationId(notificationId);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingNotificationId) return;
|
||||
|
||||
try {
|
||||
const endpoint = getApiEndpoint();
|
||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}${endpoint}/${deletingNotificationId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setNotifications(prev => prev.filter(notif => notif.id !== deletingNotificationId));
|
||||
setIsDeleteModalOpen(false);
|
||||
setDeletingNotificationId(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting notification:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getNotificationIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'NEW_VENDOR_REQUEST':
|
||||
return <AlertCircle className="w-5 h-5 text-blue-500" />;
|
||||
case 'NEW_RESELLER_REQUEST':
|
||||
return <AlertCircle className="w-5 h-5 text-green-500" />;
|
||||
case 'VENDOR_APPROVED':
|
||||
return <CheckCircle className="w-5 h-5 text-green-600" />;
|
||||
case 'VENDOR_REJECTED':
|
||||
return <XCircle className="w-5 h-5 text-red-600" />;
|
||||
case 'RESELLER_APPROVED':
|
||||
return <CheckCircle className="w-5 h-5 text-green-600" />;
|
||||
case 'RESELLER_REJECTED':
|
||||
return <XCircle className="w-5 h-5 text-red-600" />;
|
||||
case 'RESELLER_CREATED':
|
||||
return <Users className="w-5 h-5 text-blue-600" />;
|
||||
case 'RESELLER_ACCOUNT_CREATED':
|
||||
return <UserPlus className="w-5 h-5 text-green-600" />;
|
||||
case 'SYSTEM_ALERT':
|
||||
return <AlertCircle className="w-5 h-5 text-orange-500" />;
|
||||
default:
|
||||
return <Bell className="w-5 h-5 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'critical':
|
||||
return 'border-red-500 bg-red-50';
|
||||
case 'high':
|
||||
return 'border-orange-500 bg-orange-50';
|
||||
case 'medium':
|
||||
return 'border-yellow-500 bg-yellow-50';
|
||||
case 'low':
|
||||
return 'border-gray-300 bg-gray-50';
|
||||
default:
|
||||
return 'border-gray-300 bg-gray-50';
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / (1000 * 60));
|
||||
|
||||
if (diffInMinutes < 1) return 'Just now';
|
||||
if (diffInMinutes < 60) return `${diffInMinutes}m ago`;
|
||||
if (diffInMinutes < 1440) return `${Math.floor(diffInMinutes / 60)}h ago`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-start justify-center z-[9999] p-4 pt-8" style={{ backdropFilter: 'blur(4px)' }}>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-2xl w-full max-h-[85vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center space-x-3">
|
||||
<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">
|
||||
Notifications
|
||||
</h2>
|
||||
{unreadCount > 0 && (
|
||||
<span className="bg-red-500 text-white text-xs font-bold px-2 py-1 rounded-full">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllAsRead}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setActiveTab('all')}
|
||||
className={`flex-1 py-3 px-4 text-sm font-medium ${
|
||||
activeTab === 'all'
|
||||
? 'text-blue-600 border-b-2 border-blue-600'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('unread')}
|
||||
className={`flex-1 py-3 px-4 text-sm font-medium ${
|
||||
activeTab === 'unread'
|
||||
? 'text-blue-600 border-b-2 border-blue-600'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
Unread ({unreadCount})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Notifications List */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<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" />
|
||||
<p className="text-lg font-medium">No notifications</p>
|
||||
<p className="text-sm">You're all caught up!</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`p-4 rounded-lg border-l-4 ${getPriorityColor(notification.priority)} ${
|
||||
!notification.isRead ? 'bg-blue-50 dark:bg-blue-900/20' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
{getNotificationIcon(notification.type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className={`text-sm font-medium ${
|
||||
!notification.isRead
|
||||
? 'text-gray-900 dark:text-white'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
{notification.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{notification.message}
|
||||
</p>
|
||||
<div className="flex items-center space-x-4 mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="flex items-center">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
{formatTime(notification.createdAt)}
|
||||
</span>
|
||||
{notification.sender && (
|
||||
<span>
|
||||
From: {notification.sender.firstName} {notification.sender.lastName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1 ml-2">
|
||||
{!notification.isRead && (
|
||||
<button
|
||||
onClick={() => markAsRead(notification.id)}
|
||||
className="p-1 text-gray-400 hover:text-green-600 dark:hover:text-green-400"
|
||||
title="Mark as read"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => deleteNotification(notification.id)}
|
||||
className="p-1 text-gray-400 hover:text-red-600 dark:hover:text-red-400"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{isDeleteModalOpen && (
|
||||
<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">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Confirm Delete</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
Are you sure you want to delete this notification? This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setDeletingNotificationId(null);
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmDelete}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
Delete Notification
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationPanel;
|
||||
@ -1,7 +1,9 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAppSelector, useAppDispatch } from '../store/hooks';
|
||||
import { getCurrentUser } from '../store/slices/authThunks';
|
||||
import { logout } from '../store/slices/authSlice';
|
||||
import BlockedAccountModal from './BlockedAccountModal';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
@ -20,10 +22,11 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user is authenticated but no user data
|
||||
if (isAuthenticated && !user) {
|
||||
if (isAuthenticated && !user && !isLoading) {
|
||||
console.log('ProtectedRoute: User authenticated but no user data, fetching user...');
|
||||
dispatch(getCurrentUser());
|
||||
}
|
||||
}, [isAuthenticated, user, dispatch]);
|
||||
}, [isAuthenticated, user, isLoading, dispatch]);
|
||||
|
||||
// Show loading while checking authentication
|
||||
if (isLoading) {
|
||||
@ -51,10 +54,21 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
hasRequiredRole = user.role === requiredRole;
|
||||
} else {
|
||||
console.warn('User roles not properly loaded:', user);
|
||||
// For system_admin, allow access even if roles are not loaded properly
|
||||
if (requiredRole === 'system_admin' && user.role === 'system_admin') {
|
||||
hasRequiredRole = true;
|
||||
} else {
|
||||
return <Navigate to="/unauthorized" replace />;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasRequiredRole) {
|
||||
console.log('User does not have required role:', {
|
||||
userRole: user.role,
|
||||
requiredRole,
|
||||
userRoles: user.roles,
|
||||
primaryRole: user.roles?.[0]?.name
|
||||
});
|
||||
return <Navigate to="/unauthorized" replace />;
|
||||
}
|
||||
}
|
||||
@ -65,9 +79,20 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
// return <Navigate to="/verify-email" replace />;
|
||||
// }
|
||||
|
||||
// Check if account is active
|
||||
if (user && user.status !== 'active') {
|
||||
return <Navigate to="/account-pending" replace />;
|
||||
// Check if account is blocked (inactive, pending, or suspended)
|
||||
if (user && ['inactive', 'pending', 'suspended'].includes(user.status)) {
|
||||
// Show blocked account modal instead of redirecting
|
||||
return (
|
||||
<BlockedAccountModal
|
||||
isOpen={true}
|
||||
onClose={() => {
|
||||
// Force logout when modal is closed
|
||||
dispatch(logout());
|
||||
}}
|
||||
userEmail={user.email}
|
||||
userStatus={user.status}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
358
src/components/VendorDetailsModal.tsx
Normal file
358
src/components/VendorDetailsModal.tsx
Normal file
@ -0,0 +1,358 @@
|
||||
import React from 'react';
|
||||
import { X, Building, Mail, Phone, MapPin, Globe, FileText, DollarSign, Users, Calendar } from 'lucide-react';
|
||||
import { VendorModalProps } from '../types/vendor';
|
||||
|
||||
const VendorDetailsModal: React.FC<VendorModalProps> = ({
|
||||
vendor,
|
||||
isOpen,
|
||||
onClose,
|
||||
onApprove,
|
||||
onReject
|
||||
}) => {
|
||||
console.log('VendorDetailsModal props:', { isOpen, vendor: vendor?.id, vendorName: vendor ? `${vendor.firstName} ${vendor.lastName}` : null });
|
||||
|
||||
if (!isOpen || !vendor) return null;
|
||||
|
||||
const formatRoleName = (role: string) => {
|
||||
if (role.startsWith('channel_partner_')) {
|
||||
return 'Vendor';
|
||||
} else if (role.startsWith('reseller_')) {
|
||||
return 'Reseller';
|
||||
} else if (role.startsWith('system_')) {
|
||||
return 'System Admin';
|
||||
}
|
||||
return role.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
};
|
||||
|
||||
const formatUserType = (userType: string) => {
|
||||
if (userType === 'channel_partner') {
|
||||
return 'Vendor';
|
||||
} else if (userType === 'reseller') {
|
||||
return 'Reseller';
|
||||
}
|
||||
return userType.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
return (
|
||||
<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 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Building className="w-6 h-6 text-blue-600" />
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Vendor Details
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Basic Information */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2">
|
||||
Basic Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Full Name
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white">
|
||||
{vendor.firstName} {vendor.lastName}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white flex items-center">
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
{vendor.email}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Phone
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white flex items-center">
|
||||
<Phone className="w-4 h-4 mr-2" />
|
||||
{vendor.phone || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Company
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white flex items-center">
|
||||
<Building className="w-4 h-4 mr-2" />
|
||||
{vendor.company}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Role
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white">
|
||||
{formatRoleName(vendor.role)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
User Type
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white">
|
||||
{formatUserType(vendor.userType)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Business Information */}
|
||||
{(vendor.companyType || vendor.registrationNumber || vendor.gstNumber || vendor.panNumber) && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2">
|
||||
Business Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{vendor.companyType && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Company Type
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white">
|
||||
{vendor.companyType.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{vendor.registrationNumber && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Registration Number
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white">
|
||||
{vendor.registrationNumber}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{vendor.gstNumber && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
GST Number
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white">
|
||||
{vendor.gstNumber}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{vendor.panNumber && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
PAN Number
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white">
|
||||
{vendor.panNumber}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Financial Information */}
|
||||
{(vendor.annualRevenue || vendor.employeeCount || vendor.yearsInBusiness) && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2">
|
||||
Financial Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{vendor.annualRevenue && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Annual Revenue
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white flex items-center">
|
||||
<DollarSign className="w-4 h-4 mr-2" />
|
||||
{formatCurrency(vendor.annualRevenue)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{vendor.employeeCount && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Employee Count
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white flex items-center">
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
{vendor.employeeCount} employees
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{vendor.yearsInBusiness && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Years in Business
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-2" />
|
||||
{vendor.yearsInBusiness} years
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Address Information */}
|
||||
{vendor.address && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2">
|
||||
Address Information
|
||||
</h3>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Address
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white flex items-start">
|
||||
<MapPin className="w-4 h-4 mr-2 mt-0.5 flex-shrink-0" />
|
||||
{vendor.address}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Additional Information */}
|
||||
{(vendor.website || vendor.businessLicense || vendor.taxId || vendor.industry) && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2">
|
||||
Additional Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{vendor.website && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Website
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white flex items-center">
|
||||
<Globe className="w-4 h-4 mr-2" />
|
||||
<a href={vendor.website} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:text-blue-800">
|
||||
{vendor.website}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{vendor.businessLicense && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Business License
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white flex items-center">
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
{vendor.businessLicense}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{vendor.taxId && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Tax ID
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white">
|
||||
{vendor.taxId}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{vendor.industry && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Industry
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white">
|
||||
{vendor.industry}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rejection Information */}
|
||||
{vendor.rejectionReason && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2">
|
||||
Rejection Information
|
||||
</h3>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Rejection Reason
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white bg-red-50 dark:bg-red-900/20 p-3 rounded-lg">
|
||||
{vendor.rejectionReason}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2">
|
||||
Timestamp
|
||||
</h3>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Created At
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-2" />
|
||||
{new Date(vendor.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end space-x-3 p-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
{vendor.status === 'pending' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onReject(vendor.id, '')}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onApprove(vendor.id)}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-green-600 border border-transparent rounded-md hover:bg-green-700"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VendorDetailsModal;
|
||||
96
src/components/VendorRejectionModal.tsx
Normal file
96
src/components/VendorRejectionModal.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, AlertTriangle } from 'lucide-react';
|
||||
import { RejectionModalProps } from '../types/vendor';
|
||||
|
||||
const VendorRejectionModal: React.FC<RejectionModalProps> = ({
|
||||
vendor,
|
||||
isOpen,
|
||||
onClose,
|
||||
onReject
|
||||
}) => {
|
||||
const [rejectionReason, setRejectionReason] = useState('');
|
||||
|
||||
console.log('VendorRejectionModal props:', { isOpen, vendor: vendor?.id, vendorName: vendor ? `${vendor.firstName} ${vendor.lastName}` : null });
|
||||
|
||||
if (!isOpen || !vendor) return null;
|
||||
|
||||
const handleReject = () => {
|
||||
if (rejectionReason.trim()) {
|
||||
onReject(vendor.id, rejectionReason.trim());
|
||||
setRejectionReason('');
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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 max-w-md w-full mx-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center space-x-3">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Reject Vendor Request
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<div className="mb-4">
|
||||
<p className="text-gray-700 dark:text-gray-300 mb-4">
|
||||
Are you sure you want to reject the vendor request for{' '}
|
||||
<span className="font-semibold text-gray-900 dark:text-white">
|
||||
{vendor.firstName} {vendor.lastName}
|
||||
</span>
|
||||
?
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Please provide a reason for rejection. This will be communicated to the vendor.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label htmlFor="rejectionReason" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Rejection Reason *
|
||||
</label>
|
||||
<textarea
|
||||
id="rejectionReason"
|
||||
value={rejectionReason}
|
||||
onChange={(e) => setRejectionReason(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
rows={4}
|
||||
placeholder="Please provide a detailed reason for rejection..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end space-x-3 p-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReject}
|
||||
disabled={!rejectionReason.trim()}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Reject Request
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VendorRejectionModal;
|
||||
@ -106,7 +106,7 @@ const CommissionTrendsChart: React.FC = () => {
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 12, fill: '#6b7280' }}
|
||||
tickFormatter={(value) => `${(value / 1000).toFixed(0)}k`}
|
||||
tickFormatter={(value) => `${((Number(value) || 0) / 1000).toFixed(0)}k`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Area
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { User, Mail, Phone, Building, MapPin, DollarSign, Award } from 'lucide-react';
|
||||
import { User, Mail, Phone, Building, MapPin, Briefcase, Globe } from 'lucide-react';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
interface AddResellerFormProps {
|
||||
@ -9,28 +9,51 @@ interface AddResellerFormProps {
|
||||
|
||||
const AddResellerForm: React.FC<AddResellerFormProps> = ({ onSubmit, onCancel }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
companyName: '',
|
||||
contactPerson: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
company: '',
|
||||
userType: '' as 'reseller_admin' | 'reseller_sales' | 'reseller_support' | 'read_only',
|
||||
region: '',
|
||||
commissionRate: '',
|
||||
tier: 'silver',
|
||||
businessType: '',
|
||||
address: '',
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showUserTypeDropdown, setShowUserTypeDropdown] = useState(false);
|
||||
const [showRegionDropdown, setShowRegionDropdown] = useState(false);
|
||||
const [showBusinessTypeDropdown, setShowBusinessTypeDropdown] = useState(false);
|
||||
|
||||
const userTypes = [
|
||||
{ value: 'reseller_admin', label: 'Reseller Admin', description: 'Full access to reseller dashboard and management' },
|
||||
{ value: 'reseller_sales', label: 'Sales Representative', description: 'Access to sales tools and customer management' },
|
||||
{ value: 'reseller_support', label: 'Support Representative', description: 'Access to support tools and ticket management' },
|
||||
{ value: 'read_only', label: 'Read Only', description: 'View-only access to reports and data' }
|
||||
];
|
||||
|
||||
const regions = [
|
||||
'North America', 'South America', 'Europe', 'Asia Pacific',
|
||||
'Middle East', 'Africa', 'India', 'Australia'
|
||||
];
|
||||
|
||||
const businessTypes = [
|
||||
'Technology Services', 'IT Consulting', 'Cloud Services',
|
||||
'Software Development', 'Digital Marketing', 'E-commerce',
|
||||
'Healthcare IT', 'Financial Services', 'Education Technology',
|
||||
'Manufacturing', 'Retail', 'Other'
|
||||
];
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.companyName.trim()) {
|
||||
newErrors.companyName = 'Company name is required';
|
||||
if (!formData.firstName.trim()) {
|
||||
newErrors.firstName = 'First name is required';
|
||||
}
|
||||
|
||||
if (!formData.contactPerson.trim()) {
|
||||
newErrors.contactPerson = 'Contact person is required';
|
||||
if (!formData.lastName.trim()) {
|
||||
newErrors.lastName = 'Last name is required';
|
||||
}
|
||||
|
||||
if (!formData.email.trim()) {
|
||||
@ -45,17 +68,20 @@ const AddResellerForm: React.FC<AddResellerFormProps> = ({ onSubmit, onCancel })
|
||||
newErrors.phone = 'Please enter a valid phone number';
|
||||
}
|
||||
|
||||
if (!formData.region.trim()) {
|
||||
if (!formData.company.trim()) {
|
||||
newErrors.company = 'Company name is required';
|
||||
}
|
||||
|
||||
if (!formData.userType) {
|
||||
newErrors.userType = 'User type is required';
|
||||
}
|
||||
|
||||
if (!formData.region) {
|
||||
newErrors.region = 'Region is required';
|
||||
}
|
||||
|
||||
if (!formData.commissionRate) {
|
||||
newErrors.commissionRate = 'Commission rate is required';
|
||||
} else {
|
||||
const rate = parseFloat(formData.commissionRate);
|
||||
if (isNaN(rate) || rate < 5 || rate > 20) {
|
||||
newErrors.commissionRate = 'Commission rate must be between 5% and 20%';
|
||||
}
|
||||
if (!formData.businessType) {
|
||||
newErrors.businessType = 'Business type is required';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
@ -77,7 +103,6 @@ const AddResellerForm: React.FC<AddResellerFormProps> = ({ onSubmit, onCancel })
|
||||
|
||||
onSubmit({
|
||||
...formData,
|
||||
commissionRate: parseFloat(formData.commissionRate),
|
||||
id: Date.now().toString(),
|
||||
status: 'pending',
|
||||
createdAt: new Date().toISOString(),
|
||||
@ -99,49 +124,49 @@ const AddResellerForm: React.FC<AddResellerFormProps> = ({ onSubmit, onCancel })
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Company Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<Building className="inline w-4 h-4 mr-2" />
|
||||
Company Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.companyName}
|
||||
onChange={(e) => handleChange('companyName', e.target.value)}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500",
|
||||
errors.companyName
|
||||
? "border-red-500 focus:ring-red-500"
|
||||
: "border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
)}
|
||||
placeholder="Enter company name"
|
||||
/>
|
||||
{errors.companyName && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.companyName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contact Person */}
|
||||
{/* First Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<User className="inline w-4 h-4 mr-2" />
|
||||
Contact Person *
|
||||
First Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.contactPerson}
|
||||
onChange={(e) => handleChange('contactPerson', e.target.value)}
|
||||
value={formData.firstName}
|
||||
onChange={(e) => handleChange('firstName', e.target.value)}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500",
|
||||
errors.contactPerson
|
||||
errors.firstName
|
||||
? "border-red-500 focus:ring-red-500"
|
||||
: "border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
)}
|
||||
placeholder="Enter contact person name"
|
||||
placeholder="Enter first name"
|
||||
/>
|
||||
{errors.contactPerson && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.contactPerson}</p>
|
||||
{errors.firstName && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.firstName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Last Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<User className="inline w-4 h-4 mr-2" />
|
||||
Last Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.lastName}
|
||||
onChange={(e) => handleChange('lastName', e.target.value)}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500",
|
||||
errors.lastName
|
||||
? "border-red-500 focus:ring-red-500"
|
||||
: "border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
)}
|
||||
placeholder="Enter last name"
|
||||
/>
|
||||
{errors.lastName && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.lastName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -191,75 +216,160 @@ const AddResellerForm: React.FC<AddResellerFormProps> = ({ onSubmit, onCancel })
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Region */}
|
||||
{/* Company */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<Building className="inline w-4 h-4 mr-2" />
|
||||
Company Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.company}
|
||||
onChange={(e) => handleChange('company', e.target.value)}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500",
|
||||
errors.company
|
||||
? "border-red-500 focus:ring-red-500"
|
||||
: "border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
)}
|
||||
placeholder="Enter company name"
|
||||
/>
|
||||
{errors.company && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.company}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Type */}
|
||||
<div className="relative">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<User className="inline w-4 h-4 mr-2" />
|
||||
User Type *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUserTypeDropdown(!showUserTypeDropdown)}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-left flex items-center justify-between",
|
||||
errors.userType
|
||||
? "border-red-500 focus:ring-red-500"
|
||||
: "border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
)}
|
||||
>
|
||||
{formData.userType ? userTypes.find(t => t.value === formData.userType)?.label : 'Select user type'}
|
||||
<Globe className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{showUserTypeDropdown && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto">
|
||||
{userTypes.map((type) => (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleChange('userType', type.value);
|
||||
setShowUserTypeDropdown(false);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-600 border-b border-gray-200 dark:border-gray-600 last:border-b-0"
|
||||
>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{type.label}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">{type.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{errors.userType && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.userType}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Region */}
|
||||
<div className="relative">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<MapPin className="inline w-4 h-4 mr-2" />
|
||||
Region *
|
||||
</label>
|
||||
<select
|
||||
value={formData.region}
|
||||
onChange={(e) => handleChange('region', e.target.value)}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRegionDropdown(!showRegionDropdown)}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500",
|
||||
"w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-left flex items-center justify-between",
|
||||
errors.region
|
||||
? "border-red-500 focus:ring-red-500"
|
||||
: "border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
)}
|
||||
>
|
||||
<option value="">Select region</option>
|
||||
<option value="North India">North India</option>
|
||||
<option value="South India">South India</option>
|
||||
<option value="East India">East India</option>
|
||||
<option value="West India">West India</option>
|
||||
<option value="Central India">Central India</option>
|
||||
</select>
|
||||
{formData.region || 'Select region'}
|
||||
<MapPin className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{showRegionDropdown && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto">
|
||||
{regions.map((region) => (
|
||||
<button
|
||||
key={region}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleChange('region', region);
|
||||
setShowRegionDropdown(false);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-600 border-b border-gray-200 dark:border-gray-600 last:border-b-0 text-gray-900 dark:text-white"
|
||||
>
|
||||
{region}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{errors.region && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.region}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Commission Rate */}
|
||||
<div>
|
||||
{/* Business Type */}
|
||||
<div className="relative">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<DollarSign className="inline w-4 h-4 mr-2" />
|
||||
Commission Rate (%) *
|
||||
<Briefcase className="inline w-4 h-4 mr-2" />
|
||||
Business Type *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="5"
|
||||
max="20"
|
||||
step="0.5"
|
||||
value={formData.commissionRate}
|
||||
onChange={(e) => handleChange('commissionRate', e.target.value)}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowBusinessTypeDropdown(!showBusinessTypeDropdown)}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500",
|
||||
errors.commissionRate
|
||||
"w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-left flex items-center justify-between",
|
||||
errors.businessType
|
||||
? "border-red-500 focus:ring-red-500"
|
||||
: "border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
)}
|
||||
placeholder="Enter commission rate"
|
||||
/>
|
||||
{errors.commissionRate && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.commissionRate}</p>
|
||||
>
|
||||
{formData.businessType || 'Select business type'}
|
||||
<Briefcase className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{showBusinessTypeDropdown && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto">
|
||||
{businessTypes.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleChange('businessType', type);
|
||||
setShowBusinessTypeDropdown(false);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-600 border-b border-gray-200 dark:border-gray-600 last:border-b-0 text-gray-900 dark:text-white"
|
||||
>
|
||||
{type}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tier */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<Award className="inline w-4 h-4 mr-2" />
|
||||
Tier
|
||||
</label>
|
||||
<select
|
||||
value={formData.tier}
|
||||
onChange={(e) => handleChange('tier', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="silver">Silver</option>
|
||||
<option value="gold">Gold</option>
|
||||
<option value="platinum">Platinum</option>
|
||||
</select>
|
||||
{errors.businessType && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.businessType}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
614
src/components/forms/ProductForm.tsx
Normal file
614
src/components/forms/ProductForm.tsx
Normal file
@ -0,0 +1,614 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '../../store/hooks';
|
||||
import { createProduct, updateProductById } from '../../store/slices/productThunks';
|
||||
import { Product } from '../../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import {
|
||||
Package,
|
||||
DollarSign,
|
||||
Tag,
|
||||
FileText,
|
||||
Image,
|
||||
X,
|
||||
Plus,
|
||||
Trash2,
|
||||
HelpCircle
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ProductFormProps {
|
||||
product?: Product | null;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const ProductForm: React.FC<ProductFormProps> = ({ product, onClose, onSuccess }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { isLoading } = useAppSelector((state) => state.product);
|
||||
const { user } = useAppSelector((state) => state.auth);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
category: 'cloud_storage' as Product['category'],
|
||||
subcategory: '',
|
||||
price: 0,
|
||||
currency: 'USD',
|
||||
commissionRate: 10,
|
||||
features: [] as string[],
|
||||
specifications: {} as Record<string, any>,
|
||||
images: [] as string[],
|
||||
documents: [] as string[],
|
||||
status: 'draft' as Product['status'],
|
||||
availability: 'available' as Product['availability'],
|
||||
stockQuantity: -1,
|
||||
sku: '',
|
||||
tags: [] as string[],
|
||||
metadata: {} as Record<string, any>,
|
||||
purchaseUrl: '',
|
||||
});
|
||||
|
||||
const [newFeature, setNewFeature] = useState('');
|
||||
const [newTag, setNewTag] = useState('');
|
||||
const [newSpecKey, setNewSpecKey] = useState('');
|
||||
const [newSpecValue, setNewSpecValue] = useState('');
|
||||
const [showSkuHelp, setShowSkuHelp] = useState(false);
|
||||
|
||||
const categories = [
|
||||
{ value: 'cloud_storage', label: 'Cloud Storage' },
|
||||
{ value: 'cloud_computing', label: 'Cloud Computing' },
|
||||
{ value: 'cybersecurity', label: 'Cybersecurity' },
|
||||
{ value: 'data_analytics', label: 'Data Analytics' },
|
||||
{ value: 'ai_ml', label: 'AI & Machine Learning' },
|
||||
{ value: 'iot', label: 'Internet of Things' },
|
||||
{ value: 'blockchain', label: 'Blockchain' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'inactive', label: 'Inactive' },
|
||||
{ value: 'discontinued', label: 'Discontinued' },
|
||||
];
|
||||
|
||||
const availabilityOptions = [
|
||||
{ value: 'available', label: 'Available' },
|
||||
{ value: 'out_of_stock', label: 'Out of Stock' },
|
||||
{ value: 'coming_soon', label: 'Coming Soon' },
|
||||
{ value: 'discontinued', label: 'Discontinued' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (product) {
|
||||
setFormData({
|
||||
name: product.name,
|
||||
description: product.description || '',
|
||||
category: product.category,
|
||||
subcategory: product.subcategory || '',
|
||||
price: product.price,
|
||||
currency: product.currency,
|
||||
commissionRate: product.commissionRate,
|
||||
features: product.features || [],
|
||||
specifications: product.specifications || {},
|
||||
images: product.images || [],
|
||||
documents: product.documents || [],
|
||||
status: product.status,
|
||||
availability: product.availability,
|
||||
stockQuantity: product.stockQuantity,
|
||||
sku: product.sku,
|
||||
tags: product.tags || [],
|
||||
metadata: product.metadata || {},
|
||||
purchaseUrl: product.purchaseUrl || '',
|
||||
});
|
||||
}
|
||||
}, [product]);
|
||||
|
||||
const handleInputChange = (field: string, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const addFeature = () => {
|
||||
if (newFeature.trim()) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
features: [...prev.features, newFeature.trim()]
|
||||
}));
|
||||
setNewFeature('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeFeature = (index: number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
features: prev.features.filter((_, i) => i !== index)
|
||||
}));
|
||||
};
|
||||
|
||||
const addTag = () => {
|
||||
if (newTag.trim()) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
tags: [...prev.tags, newTag.trim()]
|
||||
}));
|
||||
setNewTag('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (index: number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
tags: prev.tags.filter((_, i) => i !== index)
|
||||
}));
|
||||
};
|
||||
|
||||
const addSpecification = () => {
|
||||
if (newSpecKey.trim() && newSpecValue.trim()) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
specifications: {
|
||||
...prev.specifications,
|
||||
[newSpecKey.trim()]: newSpecValue.trim()
|
||||
}
|
||||
}));
|
||||
setNewSpecKey('');
|
||||
setNewSpecValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeSpecification = (key: string) => {
|
||||
setFormData(prev => {
|
||||
const newSpecs = { ...prev.specifications };
|
||||
delete newSpecs[key];
|
||||
return { ...prev, specifications: newSpecs };
|
||||
});
|
||||
};
|
||||
|
||||
// Form validation
|
||||
const isFormValid = () => {
|
||||
return (
|
||||
formData.name.trim() !== '' &&
|
||||
formData.price > 0 &&
|
||||
formData.sku.trim() !== '' &&
|
||||
formData.commissionRate >= 0 &&
|
||||
formData.commissionRate <= 100
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
toast.error('Product name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.price <= 0) {
|
||||
toast.error('Price must be greater than 0');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.sku.trim()) {
|
||||
toast.error('SKU is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.commissionRate < 0 || formData.commissionRate > 100) {
|
||||
toast.error('Commission rate must be between 0% and 100%');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const productData = {
|
||||
...formData,
|
||||
createdBy: user?.id || 0,
|
||||
updatedBy: user?.id || 0,
|
||||
};
|
||||
|
||||
if (product) {
|
||||
await dispatch(updateProductById({ id: product.id, productData })).unwrap();
|
||||
toast.success('Product updated successfully');
|
||||
} else {
|
||||
await dispatch(createProduct(productData)).unwrap();
|
||||
toast.success('Product created successfully');
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to save product');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden">
|
||||
<div className="p-6 border-b border-slate-200 dark:border-slate-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
|
||||
{product ? 'Edit Product' : 'Create New Product'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto max-h-[calc(90vh-80px)] scrollbar-hide">
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{/* Basic Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Product Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange('name', 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"
|
||||
placeholder="Enter product name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
SKU *
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSkuHelp(!showSkuHelp)}
|
||||
className="text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.sku}
|
||||
onChange={(e) => handleInputChange('sku', 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"
|
||||
placeholder="e.g., CS-STORAGE-001"
|
||||
required
|
||||
/>
|
||||
{showSkuHelp && (
|
||||
<div className="mt-2 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<strong>SKU (Stock Keeping Unit):</strong> A unique identifier for your product.
|
||||
Use a consistent format like: <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">CATEGORY-TYPE-NUMBER</code>
|
||||
</p>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
|
||||
Examples: CS-STORAGE-001, CC-COMPUTE-002, CY-SECURITY-003
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
rows={3}
|
||||
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"
|
||||
placeholder="Enter product description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category and Subcategory */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<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) => handleInputChange('category', 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
|
||||
>
|
||||
{categories.map(category => (
|
||||
<option key={category.value} value={category.value}>
|
||||
{category.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Subcategory
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.subcategory}
|
||||
onChange={(e) => handleInputChange('subcategory', 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"
|
||||
placeholder="Enter subcategory"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing */}
|
||||
<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">
|
||||
Price *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.price}
|
||||
onChange={(e) => handleInputChange('price', parseFloat(e.target.value) || 0)}
|
||||
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"
|
||||
placeholder="0.00"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Currency
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.currency}
|
||||
onChange={(e) => handleInputChange('currency', 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"
|
||||
placeholder="USD"
|
||||
maxLength={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Commission Rate (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
value={formData.commissionRate}
|
||||
onChange={(e) => handleInputChange('commissionRate', parseFloat(e.target.value) || 0)}
|
||||
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"
|
||||
placeholder="10.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status and Availability */}
|
||||
<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={formData.status}
|
||||
onChange={(e) => handleInputChange('status', 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"
|
||||
>
|
||||
{statusOptions.map(status => (
|
||||
<option key={status.value} value={status.value}>
|
||||
{status.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Availability
|
||||
</label>
|
||||
<select
|
||||
value={formData.availability}
|
||||
onChange={(e) => handleInputChange('availability', 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"
|
||||
>
|
||||
{availabilityOptions.map(availability => (
|
||||
<option key={availability.value} value={availability.value}>
|
||||
{availability.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Stock Quantity
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="-1"
|
||||
value={formData.stockQuantity}
|
||||
onChange={(e) => handleInputChange('stockQuantity', 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-blue-500"
|
||||
placeholder="-1"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||
Use -1 for unlimited stock
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Features
|
||||
</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newFeature}
|
||||
onChange={(e) => setNewFeature(e.target.value)}
|
||||
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"
|
||||
placeholder="Add a feature"
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addFeature())}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addFeature}
|
||||
className="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"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.features.map((feature, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded-full text-sm"
|
||||
>
|
||||
{feature}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFeature(index)}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Tags
|
||||
</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
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"
|
||||
placeholder="Add a tag"
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addTag}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.tags.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1 px-3 py-1 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded-full text-sm"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(index)}
|
||||
className="text-green-600 hover:text-green-800"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Specifications */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Specifications
|
||||
</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newSpecKey}
|
||||
onChange={(e) => setNewSpecKey(e.target.value)}
|
||||
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"
|
||||
placeholder="Specification key"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newSpecValue}
|
||||
onChange={(e) => setNewSpecValue(e.target.value)}
|
||||
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"
|
||||
placeholder="Specification value"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addSpecification}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(formData.specifications).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between p-2 bg-slate-50 dark:bg-slate-700 rounded-lg"
|
||||
>
|
||||
<span className="text-sm">
|
||||
<strong>{key}:</strong> {value}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSpecification(key)}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Purchase URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Purchase URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.purchaseUrl}
|
||||
onChange={(e) => handleInputChange('purchaseUrl', 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"
|
||||
placeholder="https://example.com/product"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||
Direct link where customers can purchase this product
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex justify-end gap-3 pt-6 border-t border-slate-200 dark:border-slate-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
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 focus:outline-none focus:ring-2 focus:ring-slate-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !isFormValid()}
|
||||
className="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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Saving...' : (product ? 'Update Product' : 'Create Product')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductForm;
|
||||
@ -227,6 +227,15 @@
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgb(107 114 128);
|
||||
}
|
||||
|
||||
/* Hide scrollbar utility */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none; /* Internet Explorer 10+ */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none; /* Safari and Chrome */
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
|
||||
217
src/pages/AccountPending.tsx
Normal file
217
src/pages/AccountPending.tsx
Normal file
@ -0,0 +1,217 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Mail,
|
||||
Phone,
|
||||
MessageCircle,
|
||||
Clock,
|
||||
UserCheck,
|
||||
ArrowLeft,
|
||||
RefreshCw
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
const AccountPending: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user } = useAppSelector((state) => state.auth);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
// Get status from location state or user object
|
||||
const status = location.state?.status || user?.status || 'pending';
|
||||
const userEmail = location.state?.email || user?.email || '';
|
||||
|
||||
const getStatusInfo = () => {
|
||||
switch (status) {
|
||||
case 'inactive':
|
||||
return {
|
||||
title: 'Account Deactivated',
|
||||
message: 'Your account has been deactivated by the administrator.',
|
||||
icon: <AlertTriangle className="w-16 h-16 text-red-500" />,
|
||||
color: 'red',
|
||||
bgColor: 'bg-red-50 dark:bg-red-900/20',
|
||||
borderColor: 'border-red-200 dark:border-red-800',
|
||||
textColor: 'text-red-800 dark:text-red-200'
|
||||
};
|
||||
case 'suspended':
|
||||
return {
|
||||
title: 'Account Suspended',
|
||||
message: 'Your account has been suspended due to policy violations.',
|
||||
icon: <AlertTriangle className="w-16 h-16 text-orange-500" />,
|
||||
color: 'orange',
|
||||
bgColor: 'bg-orange-50 dark:bg-orange-900/20',
|
||||
borderColor: 'border-orange-200 dark:border-orange-800',
|
||||
textColor: 'text-orange-800 dark:text-orange-200'
|
||||
};
|
||||
case 'pending':
|
||||
return {
|
||||
title: 'Account Pending Approval',
|
||||
message: 'Your account is currently pending approval from the administrator.',
|
||||
icon: <Clock className="w-16 h-16 text-yellow-500" />,
|
||||
color: 'yellow',
|
||||
bgColor: 'bg-yellow-50 dark:bg-yellow-900/20',
|
||||
borderColor: 'border-yellow-200 dark:border-yellow-800',
|
||||
textColor: 'text-yellow-800 dark:text-yellow-200'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: 'Account Status Issue',
|
||||
message: 'There is an issue with your account status.',
|
||||
icon: <AlertTriangle className="w-16 h-16 text-gray-500" />,
|
||||
color: 'gray',
|
||||
bgColor: 'bg-gray-50 dark:bg-gray-900/20',
|
||||
borderColor: 'border-gray-200 dark:border-gray-800',
|
||||
textColor: 'text-gray-800 dark:text-gray-200'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const statusInfo = getStatusInfo();
|
||||
|
||||
const handleRefreshStatus = async () => {
|
||||
setIsRefreshing(true);
|
||||
// Simulate API call to refresh user status
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
setIsRefreshing(false);
|
||||
|
||||
// Redirect to login to re-authenticate
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
const handleContactAdmin = () => {
|
||||
const subject = encodeURIComponent('Account Status Inquiry');
|
||||
const body = encodeURIComponent(
|
||||
`Hello,\n\nI am inquiring about my account status.\n\nEmail: ${userEmail}\nCurrent Status: ${status}\n\nPlease let me know what additional information you need.\n\nThank you.`
|
||||
);
|
||||
window.open(`mailto:admin@cloudtopiaa.com?subject=${subject}&body=${body}`, '_blank');
|
||||
};
|
||||
|
||||
const handleBackToLogin = () => {
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
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 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-2xl">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-blue-600 via-indigo-600 to-purple-600 rounded-3xl shadow-2xl mb-6">
|
||||
<UserCheck className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">
|
||||
Account Status
|
||||
</h1>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
We need to verify your account before you can proceed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="bg-white/80 dark:bg-slate-800 backdrop-blur-sm rounded-2xl shadow-2xl border border-white/20 dark:border-slate-700 p-8">
|
||||
{/* Status Display */}
|
||||
<div className={cn(
|
||||
"flex items-center justify-center p-6 rounded-xl mb-6",
|
||||
statusInfo.bgColor,
|
||||
statusInfo.borderColor
|
||||
)}>
|
||||
<div className="text-center">
|
||||
{statusInfo.icon}
|
||||
<h2 className="text-xl font-semibold mt-4 mb-2 text-slate-900 dark:text-white">
|
||||
{statusInfo.title}
|
||||
</h2>
|
||||
<p className={cn("text-sm", statusInfo.textColor)}>
|
||||
{statusInfo.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Information */}
|
||||
{userEmail && (
|
||||
<div className="bg-slate-50 dark:bg-slate-700 rounded-lg p-4 mb-6">
|
||||
<h3 className="font-medium text-slate-900 dark:text-white mb-2">
|
||||
Account Details
|
||||
</h3>
|
||||
<div className="text-sm text-slate-600 dark:text-slate-400">
|
||||
<p><strong>Email:</strong> {userEmail}</p>
|
||||
<p><strong>Status:</strong> <span className="capitalize">{status}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact Information */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-6">
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-3">
|
||||
Need Help? Contact Administrator
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Mail className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-sm text-blue-800 dark:text-blue-200">
|
||||
admin@cloudtopiaa.com
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Phone className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-sm text-blue-800 dark:text-blue-200">
|
||||
+1 (555) 123-4567
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<MessageCircle className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-sm text-blue-800 dark:text-blue-200">
|
||||
Support Chat Available
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
onClick={handleBackToLogin}
|
||||
className="flex-1 flex items-center justify-center px-4 py-3 border border-slate-300 dark:border-slate-600 rounded-xl font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-700 hover:bg-slate-50 dark:hover:bg-slate-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Login
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleContactAdmin}
|
||||
className="flex-1 flex items-center justify-center px-4 py-3 bg-blue-600 text-white rounded-xl font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200"
|
||||
>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
Contact Admin
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleRefreshStatus}
|
||||
disabled={isRefreshing}
|
||||
className="flex-1 flex items-center justify-center px-4 py-3 bg-emerald-600 text-white rounded-xl font-medium hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RefreshCw className={cn("w-4 h-4 mr-2", isRefreshing && "animate-spin")} />
|
||||
{isRefreshing ? 'Checking...' : 'Check Status'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Additional Information */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
If you believe this is an error, please contact our support team immediately.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
© 2025 Cloudtopiaa. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountPending;
|
||||
@ -117,7 +117,7 @@ const Analytics: React.FC = () => {
|
||||
<div>
|
||||
<p className="text-sm font-medium text-secondary-600 dark:text-secondary-400">Total Revenue</p>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
${(analyticsData.totalRevenue / 1000).toFixed(0)}K
|
||||
${((Number(analyticsData.totalRevenue) || 0) / 1000).toFixed(0)}K
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-primary-100 dark:bg-primary-900 rounded-lg">
|
||||
@ -200,7 +200,7 @@ const Analytics: React.FC = () => {
|
||||
{analyticsData.revenueByMonth.slice(-4).map((item, index) => (
|
||||
<div key={index} className="text-center">
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">{item.month}</p>
|
||||
<p className="font-semibold text-secondary-900 dark:text-white">${(item.revenue / 1000).toFixed(0)}K</p>
|
||||
<p className="font-semibold text-secondary-900 dark:text-white">${((Number(item.revenue) || 0) / 1000).toFixed(0)}K</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -271,7 +271,7 @@ const Analytics: React.FC = () => {
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-secondary-900 dark:text-white">
|
||||
${(reseller.revenue / 1000).toFixed(0)}K
|
||||
${((Number(reseller.revenue) || 0) / 1000).toFixed(0)}K
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
{reseller.deals}
|
||||
@ -312,7 +312,7 @@ const Analytics: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
${(region.revenue / 1000).toFixed(0)}K
|
||||
${((Number(region.revenue) || 0) / 1000).toFixed(0)}K
|
||||
</p>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">Revenue</p>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { mockPartnerships } from '../../data/mockData';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { mockResellers } from '../../data/mockData';
|
||||
import { formatCurrency, formatNumber, formatDate } from '../../utils/format';
|
||||
import Modal from '../../components/Modal';
|
||||
import AddPartnershipForm from '../../components/forms/AddPartnershipForm';
|
||||
@ -22,22 +22,31 @@ import {
|
||||
AlertCircle,
|
||||
Download,
|
||||
Mail,
|
||||
MapPin
|
||||
MapPin,
|
||||
Building2
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
const PartnershipsPage: React.FC = () => {
|
||||
const ApprovedResellersPage: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [tierFilter, setTierFilter] = useState('all');
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
||||
const [selectedPartnership, setSelectedPartnership] = useState<any>(null);
|
||||
const [selectedReseller, setSelectedReseller] = useState<any>(null);
|
||||
|
||||
const filteredPartnerships = mockPartnerships.filter(partnership => {
|
||||
const matchesSearch = partnership.reseller.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = statusFilter === 'all' || partnership.status === statusFilter;
|
||||
const matchesTier = tierFilter === 'all' || partnership.tier === tierFilter;
|
||||
// Set page title
|
||||
useEffect(() => {
|
||||
document.title = 'Approved Resellers - Cloudtopiaa';
|
||||
}, []);
|
||||
|
||||
// Filter only approved (active) resellers
|
||||
const approvedResellers = mockResellers.filter(reseller => reseller.status === 'active');
|
||||
|
||||
const filteredResellers = approvedResellers.filter(reseller => {
|
||||
const matchesSearch = reseller.name.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = statusFilter === 'all' || reseller.status === statusFilter;
|
||||
const matchesTier = tierFilter === 'all' || reseller.tier === tierFilter;
|
||||
|
||||
return matchesSearch && matchesStatus && matchesTier;
|
||||
});
|
||||
@ -68,42 +77,42 @@ const PartnershipsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddPartnership = (data: any) => {
|
||||
console.log('New partnership data:', data);
|
||||
// Here you would typically make an API call to add the partnership
|
||||
const handleAddReseller = (data: any) => {
|
||||
console.log('New reseller data:', data);
|
||||
// Here you would typically make an API call to add the reseller
|
||||
// For now, we'll just close the modal
|
||||
setIsAddModalOpen(false);
|
||||
// You could also show a success notification here
|
||||
};
|
||||
|
||||
const handleViewPartnership = (partnership: any) => {
|
||||
setSelectedPartnership(partnership);
|
||||
const handleViewReseller = (reseller: any) => {
|
||||
setSelectedReseller(reseller);
|
||||
setIsDetailModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEditPartnership = (partnership: any) => {
|
||||
console.log('Edit partnership:', partnership);
|
||||
alert(`Edit functionality for ${partnership.reseller} partnership - This would open an edit form`);
|
||||
const handleEditReseller = (reseller: any) => {
|
||||
console.log('Edit reseller:', reseller);
|
||||
alert(`Edit functionality for ${reseller.name} - This would open an edit form`);
|
||||
};
|
||||
|
||||
const handleMailPartnership = (partnership: any) => {
|
||||
console.log('Mail partnership:', partnership);
|
||||
const mailtoLink = `mailto:${partnership.contactEmail}?subject=Cloudtopiaa Partnership Update`;
|
||||
const handleMailReseller = (reseller: any) => {
|
||||
console.log('Mail reseller:', reseller);
|
||||
const mailtoLink = `mailto:${reseller.email}?subject=Cloudtopiaa Partnership Update`;
|
||||
window.open(mailtoLink, '_blank');
|
||||
};
|
||||
|
||||
const handleMoreOptions = (partnership: any) => {
|
||||
console.log('More options for partnership:', partnership);
|
||||
const handleMoreOptions = (reseller: any) => {
|
||||
console.log('More options for reseller:', reseller);
|
||||
const options = [
|
||||
'View Performance',
|
||||
'Download Report',
|
||||
'Send Notification',
|
||||
'Change Terms',
|
||||
'Terminate Partnership'
|
||||
'Suspend Partnership'
|
||||
];
|
||||
const selectedOption = prompt(`Select an option for ${partnership.reseller}:\n${options.join('\n')}`);
|
||||
const selectedOption = prompt(`Select an option for ${reseller.name}:\n${options.join('\n')}`);
|
||||
if (selectedOption) {
|
||||
alert(`Selected: ${selectedOption} for ${partnership.reseller}`);
|
||||
alert(`Selected: ${selectedOption} for ${reseller.name}`);
|
||||
}
|
||||
};
|
||||
|
||||
@ -113,10 +122,10 @@ const PartnershipsPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-secondary-900 dark:text-white">
|
||||
Partnerships
|
||||
Approved Resellers
|
||||
</h1>
|
||||
<p className="text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
Manage reseller partnerships and approval workflows
|
||||
Manage approved reseller partnerships and their performance
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
@ -129,7 +138,7 @@ const PartnershipsPage: React.FC = () => {
|
||||
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" />
|
||||
New Partnership
|
||||
Add Reseller
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -140,14 +149,14 @@ const PartnershipsPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-secondary-600 dark:text-secondary-400">
|
||||
Total Partnerships
|
||||
Total Approved Resellers
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
{mockPartnerships.length}
|
||||
{approvedResellers.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center">
|
||||
<Handshake 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>
|
||||
@ -156,30 +165,30 @@ const PartnershipsPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-secondary-600 dark:text-secondary-400">
|
||||
Active Partnerships
|
||||
Platinum Tier
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
{mockPartnerships.filter(p => p.status === 'active').length}
|
||||
{approvedResellers.filter(r => r.tier === 'platinum').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center">
|
||||
<TrendingUp className="w-6 h-6 text-yellow-600 dark:text-yellow-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 bg-white dark:bg-gray-800 hover:shadow-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-secondary-600 dark:text-secondary-400">
|
||||
Total Customers
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
{approvedResellers.reduce((sum, r) => sum + r.customers, 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-success-100 dark:bg-success-900 rounded-full flex items-center justify-center">
|
||||
<CheckCircle className="w-6 h-6 text-success-600 dark:text-success-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 bg-white dark:bg-gray-800 hover:shadow-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-secondary-600 dark:text-secondary-400">
|
||||
Pending Approvals
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
{mockPartnerships.filter(p => p.status === 'pending').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-warning-100 dark:bg-warning-900 rounded-full flex items-center justify-center">
|
||||
<Clock className="w-6 h-6 text-warning-600 dark:text-warning-400" />
|
||||
<Users className="w-6 h-6 text-success-600 dark:text-success-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -191,7 +200,7 @@ const PartnershipsPage: React.FC = () => {
|
||||
Total Revenue
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
{formatCurrency(mockPartnerships.reduce((sum, p) => sum + p.totalRevenue, 0))}
|
||||
{formatCurrency(approvedResellers.reduce((sum, r) => sum + r.totalRevenue, 0))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-success-100 dark:bg-success-900 rounded-full flex items-center justify-center">
|
||||
@ -201,61 +210,6 @@ const PartnershipsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pending Approvals Section */}
|
||||
{mockPartnerships.filter(p => p.status === 'pending').length > 0 && (
|
||||
<div className="card p-6 bg-white dark:bg-gray-800">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Pending Approvals
|
||||
</h3>
|
||||
<span className="badge badge-warning">
|
||||
{mockPartnerships.filter(p => p.status === 'pending').length} pending
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{mockPartnerships.filter(p => p.status === 'pending').map((partnership) => (
|
||||
<div key={partnership.id} className="border border-warning-200 dark:border-warning-700 rounded-lg p-4 bg-warning-50 dark:bg-warning-900/20">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-medium text-secondary-900 dark:text-white">
|
||||
{partnership.reseller}
|
||||
</h4>
|
||||
<span className={cn(
|
||||
"inline-flex items-center px-2 py-1 rounded-full text-xs font-medium",
|
||||
getTierColor(partnership.tier)
|
||||
)}>
|
||||
{partnership.tier.charAt(0).toUpperCase() + partnership.tier.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<div className="flex items-center">
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
{partnership.customers} customers
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<DollarSign className="w-4 h-4 mr-2" />
|
||||
{partnership.commissionRate}% commission
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<MapPin className="w-4 h-4 mr-2" />
|
||||
{partnership.region}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2 mt-4">
|
||||
<button className="flex-1 inline-flex items-center justify-center px-3 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-all duration-200 transform hover:scale-105">
|
||||
<CheckCircle className="w-4 h-4 mr-1" />
|
||||
Approve
|
||||
</button>
|
||||
<button className="flex-1 inline-flex items-center justify-center px-3 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-all duration-200 transform hover:scale-105">
|
||||
<XCircle className="w-4 h-4 mr-1" />
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters and Search */}
|
||||
<div className="card p-6">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
@ -264,7 +218,7 @@ const PartnershipsPage: React.FC = () => {
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-secondary-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search partnerships..."
|
||||
placeholder="Search resellers..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="input pl-10 w-full focus:ring-2 focus:ring-primary-500 transition-all duration-200"
|
||||
@ -303,12 +257,12 @@ const PartnershipsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Partnerships Table */}
|
||||
{/* Resellers Table */}
|
||||
<div className="card">
|
||||
<div className="p-6 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
All Partnerships
|
||||
All Approved Resellers
|
||||
</h3>
|
||||
<div className="flex space-x-2">
|
||||
<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">
|
||||
@ -342,7 +296,7 @@ const PartnershipsPage: React.FC = () => {
|
||||
Commission
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
||||
Start Date
|
||||
Last Active
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
||||
Actions
|
||||
@ -350,74 +304,81 @@ const PartnershipsPage: React.FC = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-900 divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
{filteredPartnerships.map((partnership) => (
|
||||
<tr key={partnership.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-800">
|
||||
{filteredResellers.map((reseller) => (
|
||||
<tr key={reseller.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-800">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
className="h-10 w-10 rounded-full mr-3"
|
||||
src={reseller.avatar}
|
||||
alt={reseller.name}
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{partnership.reseller}
|
||||
{reseller.name}
|
||||
</div>
|
||||
<div className="text-sm text-secondary-500 dark:text-secondary-400">
|
||||
{partnership.contactPerson}
|
||||
{reseller.email}
|
||||
</div>
|
||||
<div className="text-xs text-secondary-400 dark:text-secondary-500">
|
||||
{partnership.contactEmail}
|
||||
{reseller.phone}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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(partnership.status)
|
||||
getStatusColor(reseller.status)
|
||||
)}>
|
||||
{partnership.status.charAt(0).toUpperCase() + partnership.status.slice(1)}
|
||||
{reseller.status.charAt(0).toUpperCase() + reseller.status.slice(1)}
|
||||
</span>
|
||||
</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",
|
||||
getTierColor(partnership.tier)
|
||||
getTierColor(reseller.tier)
|
||||
)}>
|
||||
{partnership.tier.charAt(0).toUpperCase() + partnership.tier.slice(1)}
|
||||
{reseller.tier.charAt(0).toUpperCase() + reseller.tier.slice(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
{formatCurrency(partnership.totalRevenue)}
|
||||
{formatCurrency(reseller.totalRevenue)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
{formatNumber(partnership.customers)}
|
||||
{formatNumber(reseller.customers)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
{partnership.commissionRate}%
|
||||
{reseller.commissionRate}%
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-400">
|
||||
{formatDate(partnership.startDate)}
|
||||
{formatDate(reseller.lastActive)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<button
|
||||
onClick={() => handleViewPartnership(partnership)}
|
||||
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={() => handleEditPartnership(partnership)}
|
||||
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 Partnership"
|
||||
title="Edit Reseller"
|
||||
>
|
||||
<Edit className="w-4 h-4 text-secondary-600 dark:text-secondary-400" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMailPartnership(partnership)}
|
||||
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(partnership)}
|
||||
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"
|
||||
>
|
||||
@ -432,30 +393,30 @@ const PartnershipsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Partnership Modal */}
|
||||
{/* Add Reseller Modal */}
|
||||
<Modal
|
||||
isOpen={isAddModalOpen}
|
||||
onClose={() => setIsAddModalOpen(false)}
|
||||
title="Add New Partnership"
|
||||
title="Add New Reseller"
|
||||
size="lg"
|
||||
>
|
||||
<AddPartnershipForm
|
||||
onSubmit={handleAddPartnership}
|
||||
onSubmit={handleAddReseller}
|
||||
onCancel={() => setIsAddModalOpen(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* Partnership Detail Modal */}
|
||||
{/* Reseller Detail Modal */}
|
||||
<Modal
|
||||
isOpen={isDetailModalOpen}
|
||||
onClose={() => setIsDetailModalOpen(false)}
|
||||
title="Partnership Details"
|
||||
title="Reseller Details"
|
||||
size="lg"
|
||||
>
|
||||
{selectedPartnership && (
|
||||
{selectedReseller && (
|
||||
<DetailView
|
||||
type="partnership"
|
||||
data={selectedPartnership}
|
||||
type="reseller"
|
||||
data={selectedReseller}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
@ -463,4 +424,4 @@ const PartnershipsPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default PartnershipsPage;
|
||||
export default ApprovedResellersPage;
|
||||
@ -8,6 +8,8 @@ import { formatCurrency, formatCurrencyDual, formatNumber, formatRelativeTime, f
|
||||
import RevenueChart from '../components/charts/RevenueChart';
|
||||
import ResellerPerformanceChart from '../components/charts/ResellerPerformanceChart';
|
||||
import DualCurrencyDisplay from '../components/DualCurrencyDisplay';
|
||||
import NotificationBell from '../components/NotificationBell';
|
||||
import DraggableFeedback from '../components/DraggableFeedback';
|
||||
import {
|
||||
TrendingUp,
|
||||
Users,
|
||||
@ -22,7 +24,8 @@ import {
|
||||
Target,
|
||||
Briefcase as BriefcaseIcon,
|
||||
FileText as FileTextIcon,
|
||||
Package
|
||||
Package,
|
||||
MessageCircle
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
@ -31,6 +34,8 @@ const Dashboard: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { stats, recentActivities, quickActions } = useAppSelector((state) => state.dashboard);
|
||||
const { user } = useAppSelector((state) => state.auth);
|
||||
const [showFeedback, setShowFeedback] = React.useState(false);
|
||||
const [feedbackKey, setFeedbackKey] = React.useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize dashboard data
|
||||
@ -114,6 +119,7 @@ const Dashboard: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<NotificationBell />
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">Commission Earned</p>
|
||||
<DualCurrencyDisplay
|
||||
@ -186,7 +192,7 @@ const Dashboard: React.FC = () => {
|
||||
<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">
|
||||
Active Partnerships
|
||||
Active Resellers
|
||||
</p>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-500 mb-1">
|
||||
Approved business agreements
|
||||
@ -202,7 +208,7 @@ const Dashboard: React.FC = () => {
|
||||
<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" />
|
||||
<span className="text-xs sm:text-sm text-success-600 ml-1 truncate">
|
||||
+3 new partnerships
|
||||
+3 new resellers
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -396,6 +402,31 @@ const Dashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Draggable Feedback Component */}
|
||||
{showFeedback && (
|
||||
<DraggableFeedback
|
||||
key={feedbackKey}
|
||||
onClose={() => {
|
||||
setShowFeedback(false);
|
||||
setFeedbackKey(prev => prev + 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Feedback Trigger Button */}
|
||||
{!showFeedback && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowFeedback(true);
|
||||
setFeedbackKey(prev => prev + 1);
|
||||
}}
|
||||
className="fixed bottom-6 right-6 bg-emerald-600 hover:bg-emerald-700 text-white p-3 rounded-full shadow-lg hover:shadow-xl transition-all duration-200 z-50"
|
||||
title="Send Feedback"
|
||||
>
|
||||
<MessageCircle className="w-6 h-6" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -20,6 +20,8 @@ import {
|
||||
import { RootState } from '../store';
|
||||
import { toggleTheme } from '../store/slices/themeSlice';
|
||||
import { cn } from '../utils/cn';
|
||||
import ApprovalStatusModal from '../components/ApprovalStatusModal';
|
||||
import BlockedAccountModal from '../components/BlockedAccountModal';
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
@ -28,6 +30,12 @@ const Login: React.FC = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [showApprovalModal, setShowApprovalModal] = useState(false);
|
||||
const [pendingUserEmail, setPendingUserEmail] = useState('');
|
||||
const [pendingUserCompany, setPendingUserCompany] = useState('');
|
||||
const [showInactiveModal, setShowInactiveModal] = useState(false);
|
||||
const [inactiveUserEmail, setInactiveUserEmail] = useState('');
|
||||
const [inactiveUserStatus, setInactiveUserStatus] = useState('');
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@ -80,8 +88,30 @@ const Login: React.FC = () => {
|
||||
navigate(redirectPath, { replace: true });
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.message || 'Invalid email or password. Please try again.';
|
||||
|
||||
// Check if the error is related to account status issues
|
||||
if (errorMessage.includes('Account access blocked') || errorMessage.includes('Status: inactive') || errorMessage.includes('Status: suspended') || errorMessage.includes('Status: pending')) {
|
||||
// Extract status from error message
|
||||
let status = 'inactive';
|
||||
if (errorMessage.includes('Status: suspended')) {
|
||||
status = 'suspended';
|
||||
} else if (errorMessage.includes('Status: pending')) {
|
||||
status = 'pending';
|
||||
}
|
||||
|
||||
setInactiveUserEmail(email);
|
||||
setInactiveUserStatus(status);
|
||||
setShowInactiveModal(true);
|
||||
setError('');
|
||||
} else if (errorMessage.includes('pending approval') || errorMessage.includes('under review')) {
|
||||
setPendingUserEmail(email);
|
||||
setPendingUserCompany(''); // You might want to extract this from the error response
|
||||
setShowApprovalModal(true);
|
||||
setError('');
|
||||
} else {
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@ -242,30 +272,7 @@ const Login: React.FC = () => {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-slate-300 dark:border-slate-600"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social Login Buttons */}
|
||||
<div className="space-y-3">
|
||||
<button className="w-full flex items-center justify-center px-4 py-3 border border-slate-300 dark:border-slate-600 rounded-xl font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-700 hover:bg-slate-50 dark:hover:bg-slate-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200">
|
||||
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
Continue with Google
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sign Up Link */}
|
||||
<div className="mt-6 text-center">
|
||||
@ -288,6 +295,22 @@ const Login: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Approval Status Modal */}
|
||||
<ApprovalStatusModal
|
||||
isOpen={showApprovalModal}
|
||||
onClose={() => setShowApprovalModal(false)}
|
||||
userEmail={pendingUserEmail}
|
||||
companyName={pendingUserCompany}
|
||||
/>
|
||||
|
||||
{/* Blocked Account Modal */}
|
||||
<BlockedAccountModal
|
||||
isOpen={showInactiveModal}
|
||||
onClose={() => setShowInactiveModal(false)}
|
||||
userEmail={inactiveUserEmail}
|
||||
userStatus={inactiveUserStatus}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,4 +1,15 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import {
|
||||
fetchProducts,
|
||||
fetchProductCategories,
|
||||
fetchProductStats,
|
||||
deleteProductById
|
||||
} from '../store/slices/productThunks';
|
||||
import { setFilters } from '../store/slices/productSlice';
|
||||
import { Product } from '../services/api';
|
||||
import ProductForm from '../components/forms/ProductForm';
|
||||
import toast from 'react-hot-toast';
|
||||
import {
|
||||
Package,
|
||||
DollarSign,
|
||||
@ -16,530 +27,492 @@ import {
|
||||
Star,
|
||||
Zap,
|
||||
Shield,
|
||||
Globe
|
||||
Globe,
|
||||
Trash2,
|
||||
MoreVertical,
|
||||
Grid3X3,
|
||||
List,
|
||||
Database,
|
||||
Cpu,
|
||||
Lock,
|
||||
BarChart3,
|
||||
Brain,
|
||||
Wifi,
|
||||
Link
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
basePrice: number;
|
||||
currentPrice: number;
|
||||
margin: number;
|
||||
marginType: 'percentage' | 'fixed';
|
||||
stock: number;
|
||||
status: 'active' | 'inactive';
|
||||
description: string;
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
const mockProducts: Product[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Cloud Hosting Basic',
|
||||
category: 'Hosting',
|
||||
basePrice: 29.99,
|
||||
currentPrice: 39.99,
|
||||
margin: 33.34,
|
||||
marginType: 'percentage',
|
||||
stock: 100,
|
||||
status: 'active',
|
||||
description: 'Basic cloud hosting package with 10GB storage and 99.9% uptime guarantee',
|
||||
featured: true
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Cloud Hosting Pro',
|
||||
category: 'Hosting',
|
||||
basePrice: 59.99,
|
||||
currentPrice: 79.99,
|
||||
margin: 33.34,
|
||||
marginType: 'percentage',
|
||||
stock: 50,
|
||||
status: 'active',
|
||||
description: 'Professional cloud hosting with 50GB storage and advanced features',
|
||||
featured: true
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'SSL Certificate',
|
||||
category: 'Security',
|
||||
basePrice: 49.99,
|
||||
currentPrice: 69.99,
|
||||
margin: 40.01,
|
||||
marginType: 'percentage',
|
||||
stock: 200,
|
||||
status: 'active',
|
||||
description: 'Standard SSL certificate for website security and trust'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Domain Registration',
|
||||
category: 'Domains',
|
||||
basePrice: 12.99,
|
||||
currentPrice: 19.99,
|
||||
margin: 53.89,
|
||||
marginType: 'percentage',
|
||||
stock: 1000,
|
||||
status: 'active',
|
||||
description: 'Annual domain registration service with free privacy protection'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Backup Service',
|
||||
category: 'Storage',
|
||||
basePrice: 19.99,
|
||||
currentPrice: 29.99,
|
||||
margin: 50.03,
|
||||
marginType: 'percentage',
|
||||
stock: 75,
|
||||
status: 'active',
|
||||
description: 'Automated backup service with 100GB storage and encryption'
|
||||
}
|
||||
];
|
||||
|
||||
const ProductManagement: React.FC = () => {
|
||||
const [products, setProducts] = useState<Product[]>(mockProducts);
|
||||
const dispatch = useAppDispatch();
|
||||
const {
|
||||
products,
|
||||
pagination,
|
||||
filters,
|
||||
isLoading,
|
||||
error
|
||||
} = useAppSelector((state) => state.product);
|
||||
|
||||
const [showProductForm, setShowProductForm] = useState(false);
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||
const [sortBy, setSortBy] = useState<'name' | 'price' | 'margin'>('name');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||
const [showPricingModal, setShowPricingModal] = useState(false);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [deletingProductId, setDeletingProductId] = useState<number | null>(null);
|
||||
|
||||
const categories = ['all', ...Array.from(new Set(products.map(p => p.category)))];
|
||||
useEffect(() => {
|
||||
dispatch(fetchProducts({}));
|
||||
dispatch(fetchProductCategories());
|
||||
dispatch(fetchProductStats());
|
||||
}, [dispatch]);
|
||||
|
||||
const filteredAndSortedProducts = products
|
||||
.filter(product => {
|
||||
const matchesSearch = product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
product.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesCategory = categoryFilter === 'all' || product.category === categoryFilter;
|
||||
return matchesSearch && matchesCategory;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
let aValue: string | number;
|
||||
let bValue: string | number;
|
||||
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
aValue = a.name;
|
||||
bValue = b.name;
|
||||
break;
|
||||
case 'price':
|
||||
aValue = a.currentPrice;
|
||||
bValue = b.currentPrice;
|
||||
break;
|
||||
case 'margin':
|
||||
aValue = a.margin;
|
||||
bValue = b.margin;
|
||||
break;
|
||||
default:
|
||||
aValue = a.name;
|
||||
bValue = b.name;
|
||||
}
|
||||
|
||||
if (sortOrder === 'asc') {
|
||||
return aValue > bValue ? 1 : -1;
|
||||
} else {
|
||||
return aValue < bValue ? 1 : -1;
|
||||
}
|
||||
});
|
||||
|
||||
const handlePricingUpdate = (productId: string, newMargin: number, marginType: 'percentage' | 'fixed') => {
|
||||
setProducts(prev => prev.map(product => {
|
||||
if (product.id === productId) {
|
||||
const newPrice = marginType === 'percentage'
|
||||
? product.basePrice * (1 + newMargin / 100)
|
||||
: product.basePrice + newMargin;
|
||||
|
||||
return {
|
||||
...product,
|
||||
currentPrice: Math.round(newPrice * 100) / 100,
|
||||
margin: newMargin,
|
||||
marginType
|
||||
};
|
||||
}
|
||||
return product;
|
||||
}));
|
||||
const handleFilterChange = (key: string, value: string) => {
|
||||
dispatch(setFilters({ [key]: value }));
|
||||
dispatch(fetchProducts({ ...filters, [key]: value, page: 1 }));
|
||||
};
|
||||
|
||||
const getMarginColor = (margin: number) => {
|
||||
if (margin >= 50) return 'text-emerald-600 bg-emerald-50 dark:bg-emerald-900/20 dark:text-emerald-400 border-emerald-200 dark:border-emerald-800';
|
||||
if (margin >= 30) return 'text-blue-600 bg-blue-50 dark:bg-blue-900/20 dark:text-blue-400 border-blue-200 dark:border-blue-800';
|
||||
if (margin >= 20) return 'text-amber-600 bg-amber-50 dark:bg-amber-900/20 dark:text-amber-400 border-amber-200 dark:border-amber-800';
|
||||
return 'text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-400 border-red-200 dark:border-red-800';
|
||||
const handlePageChange = (page: number) => {
|
||||
dispatch(fetchProducts({ ...filters, page }));
|
||||
};
|
||||
|
||||
const handleSort = (sortBy: string) => {
|
||||
const sortOrder = filters.sortBy === sortBy && filters.sortOrder === 'ASC' ? 'DESC' : 'ASC';
|
||||
dispatch(setFilters({ sortBy, sortOrder }));
|
||||
dispatch(fetchProducts({ ...filters, sortBy, sortOrder }));
|
||||
};
|
||||
|
||||
const handleDeleteProduct = async (productId: number) => {
|
||||
setDeletingProductId(productId);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingProductId) return;
|
||||
|
||||
try {
|
||||
await dispatch(deleteProductById(deletingProductId)).unwrap();
|
||||
toast.success('Product deleted successfully');
|
||||
setIsDeleteModalOpen(false);
|
||||
setDeletingProductId(null);
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to delete product');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditProduct = (product: Product) => {
|
||||
setSelectedProduct(product);
|
||||
setShowProductForm(true);
|
||||
};
|
||||
|
||||
const handleCreateProduct = () => {
|
||||
setSelectedProduct(null);
|
||||
setShowProductForm(true);
|
||||
};
|
||||
|
||||
const handleProductFormSuccess = () => {
|
||||
dispatch(fetchProducts(filters));
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch (category.toLowerCase()) {
|
||||
case 'hosting':
|
||||
return <Zap className="w-4 h-4 text-blue-600 dark:text-blue-400" />;
|
||||
case 'security':
|
||||
return <Shield className="w-4 h-4 text-amber-600 dark:text-amber-400" />;
|
||||
case 'domains':
|
||||
return <Globe className="w-4 h-4 text-purple-600 dark:text-purple-400" />;
|
||||
case 'storage':
|
||||
return <Package className="w-4 h-4 text-red-600 dark:text-red-400" />;
|
||||
switch (category) {
|
||||
case 'cloud_storage':
|
||||
return <Database className="h-5 w-5" />;
|
||||
case 'cloud_computing':
|
||||
return <Cpu className="h-5 w-5" />;
|
||||
case 'cybersecurity':
|
||||
return <Shield className="h-5 w-5" />;
|
||||
case 'data_analytics':
|
||||
return <BarChart3 className="h-5 w-5" />;
|
||||
case 'ai_ml':
|
||||
return <Brain className="h-5 w-5" />;
|
||||
case 'iot':
|
||||
return <Wifi className="h-5 w-5" />;
|
||||
case 'blockchain':
|
||||
return <Link className="h-5 w-5" />;
|
||||
default:
|
||||
return <Package className="w-4 h-4 text-gray-600 dark:text-gray-400" />;
|
||||
return <Package className="h-5 w-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
switch (category.toLowerCase()) {
|
||||
case 'hosting':
|
||||
return 'bg-gradient-to-r from-blue-500 to-cyan-500';
|
||||
case 'security':
|
||||
return 'bg-gradient-to-r from-amber-500 to-orange-500';
|
||||
case 'domains':
|
||||
return 'bg-gradient-to-r from-purple-500 to-pink-500';
|
||||
case 'storage':
|
||||
return 'bg-gradient-to-r from-red-500 to-pink-500';
|
||||
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 'bg-gradient-to-r from-gray-500 to-gray-600';
|
||||
return 'text-gray-600 bg-gray-100 dark:bg-gray-900';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'text-green-600 bg-green-100 dark:bg-green-900';
|
||||
case 'inactive':
|
||||
return 'text-red-600 bg-red-100 dark:bg-red-900';
|
||||
case 'draft':
|
||||
return 'text-yellow-600 bg-yellow-100 dark:bg-yellow-900';
|
||||
case 'discontinued':
|
||||
return 'text-gray-600 bg-gray-100 dark:bg-gray-900';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-100 dark:bg-gray-900';
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<p className="text-red-800 dark:text-red-200">{error}</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 flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-secondary-900 dark:text-white">
|
||||
Product & Pricing Management
|
||||
</h1>
|
||||
<p className="text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
Manage your product catalog and customize pricing strategies
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">Product Management</h1>
|
||||
<p className="text-slate-600 dark:text-slate-400">Manage your product catalog and pricing</p>
|
||||
</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">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Product
|
||||
<div className="flex items-center gap-3">
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex items-center bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-1">
|
||||
<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>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6">
|
||||
<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">
|
||||
Total Products
|
||||
</p>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-500 mb-1">
|
||||
Available in catalog
|
||||
</p>
|
||||
<p className="text-lg sm:text-2xl font-bold text-secondary-900 dark:text-white truncate">
|
||||
{products.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center flex-shrink-0 ml-2">
|
||||
<Package className="w-5 h-5 sm:w-6 sm:h-6 text-blue-600 dark:text-blue-400" />
|
||||
</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">
|
||||
Avg. Margin
|
||||
</p>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-500 mb-1">
|
||||
Average profit margin
|
||||
</p>
|
||||
<p className="text-lg sm:text-2xl font-bold text-secondary-900 dark:text-white truncate">
|
||||
{Math.round(products.reduce((acc, p) => acc + p.margin, 0) / products.length)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 sm:w-12 sm:h-12 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 sm:w-6 sm:h-6 text-success-600 dark:text-success-400" />
|
||||
</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">
|
||||
Active Products
|
||||
</p>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-500 mb-1">
|
||||
Currently available
|
||||
</p>
|
||||
<p className="text-lg sm:text-2xl font-bold text-secondary-900 dark:text-white truncate">
|
||||
{products.filter(p => p.status === 'active').length}
|
||||
</p>
|
||||
</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">
|
||||
<TrendingUp className="w-5 h-5 sm:w-6 sm:h-6 text-primary-600 dark:text-primary-400" />
|
||||
</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">
|
||||
Categories
|
||||
</p>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-500 mb-1">
|
||||
Product categories
|
||||
</p>
|
||||
<p className="text-lg sm:text-2xl font-bold text-secondary-900 dark:text-white truncate">
|
||||
{categories.length - 1}
|
||||
</p>
|
||||
</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">
|
||||
<Tag className="w-5 h-5 sm:w-6 sm:h-6 text-warning-600 dark:text-warning-400" />
|
||||
</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>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<div className="card p-4 sm:p-6">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 p-4">
|
||||
<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-secondary-400" />
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search products by name or description..."
|
||||
value={searchTerm}
|
||||
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-800 text-secondary-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="Search products..."
|
||||
value={filters.search}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{/* Category Filter */}
|
||||
<div className="lg:w-48">
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
value={filters.category}
|
||||
onChange={(e) => handleFilterChange('category', 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"
|
||||
>
|
||||
{categories.map(category => (
|
||||
<option key={category} value={category}>
|
||||
{category === 'all' ? 'All Categories' : category}
|
||||
</option>
|
||||
))}
|
||||
<option value="">All Categories</option>
|
||||
<option value="cloud_storage">Cloud Storage</option>
|
||||
<option value="cloud_computing">Cloud Computing</option>
|
||||
<option value="cybersecurity">Cybersecurity</option>
|
||||
<option value="data_analytics">Data Analytics</option>
|
||||
<option value="ai_ml">AI & ML</option>
|
||||
<option value="iot">IoT</option>
|
||||
<option value="blockchain">Blockchain</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="lg:w-48">
|
||||
<select
|
||||
value={`${sortBy}-${sortOrder}`}
|
||||
onChange={(e) => {
|
||||
const [field, order] = e.target.value.split('-');
|
||||
setSortBy(field as 'name' | 'price' | 'margin');
|
||||
setSortOrder(order as 'asc' | 'desc');
|
||||
}}
|
||||
className="px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
value={filters.status}
|
||||
onChange={(e) => handleFilterChange('status', 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"
|
||||
>
|
||||
<option value="name-asc">Name A-Z</option>
|
||||
<option value="name-desc">Name Z-A</option>
|
||||
<option value="price-asc">Price Low-High</option>
|
||||
<option value="price-desc">Price High-Low</option>
|
||||
<option value="margin-asc">Margin Low-High</option>
|
||||
<option value="margin-desc">Margin High-Low</option>
|
||||
<option value="">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="discontinued">Discontinued</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-6">
|
||||
{filteredAndSortedProducts.map((product) => (
|
||||
<div key={product.id} className={`card p-4 sm:p-6 group hover:shadow-lg transition-all duration-300 ${product.featured ? 'ring-2 ring-primary-500/20' : ''}`}>
|
||||
{/* Products Display */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<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>
|
||||
<p className="mt-2 text-slate-600 dark:text-slate-400">Loading products...</p>
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<Package className="h-12 w-12 text-slate-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-slate-900 dark:text-white mb-2">No products found</h3>
|
||||
<p className="text-slate-600 dark:text-slate-400">Get started by creating your first product.</p>
|
||||
</div>
|
||||
) : viewMode === 'grid' ? (
|
||||
/* Grid View */
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{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">
|
||||
{/* Product Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`relative ${getCategoryColor(product.category)} p-2 rounded-lg shadow-md`}>
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center bg-white/20 backdrop-blur-sm">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`p-2 rounded-lg ${getCategoryColor(product.category)}`}>
|
||||
{getCategoryIcon(product.category)}
|
||||
</div>
|
||||
{product.featured && (
|
||||
<div className="absolute -top-1 -right-1 bg-gradient-to-r from-yellow-400 to-orange-500 rounded-full p-0.5 shadow-md">
|
||||
<Star className="w-2.5 h-2.5 text-white fill-current" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-base text-secondary-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
|
||||
{product.name}
|
||||
</h3>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<span className="text-xs text-secondary-600 dark:text-secondary-400">
|
||||
{product.category}
|
||||
<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 className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleEditProduct(product)}
|
||||
className="p-1 text-slate-400 hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteProduct(product.id)}
|
||||
className="p-1 text-slate-400 hover:text-red-600 dark:hover:text-red-400"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Details */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">Price:</span>
|
||||
<span className="font-medium text-slate-900 dark:text-white">
|
||||
${(Number(product.price) || 0).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">Commission:</span>
|
||||
<span className="font-medium text-slate-900 dark:text-white">
|
||||
{product.commissionRate}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">Stock:</span>
|
||||
<span className="font-medium text-slate-900 dark:text-white">
|
||||
{product.stockQuantity === -1 ? 'Unlimited' : product.stockQuantity}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${product.status === 'active' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-400' : 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-400'}`}>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="mt-3 pt-3 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)}`}>
|
||||
{product.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Product Description */}
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-4 line-clamp-2">
|
||||
{product.description}
|
||||
</p>
|
||||
|
||||
{/* Pricing Information */}
|
||||
<div className="space-y-3 mb-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-secondary-600 dark:text-secondary-400">Base Price:</span>
|
||||
<span className="font-medium text-secondary-900 dark:text-white">${product.basePrice}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center p-2 bg-primary-50 dark:bg-primary-900/20 rounded-lg">
|
||||
<span className="text-sm font-medium text-primary-700 dark:text-primary-300">Your Price:</span>
|
||||
<span className="font-bold text-lg text-primary-600 dark:text-primary-400">
|
||||
${product.currentPrice}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-secondary-600 dark:text-secondary-400">Profit Margin:</span>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getMarginColor(product.margin)}`}>
|
||||
{product.marginType === 'percentage' ? `${product.margin}%` : `$${product.margin}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-secondary-600 dark:text-secondary-400">Available Stock:</span>
|
||||
<span className="font-medium text-secondary-900 dark:text-white">{product.stock}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-3 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedProduct(product);
|
||||
setShowPricingModal(true);
|
||||
}}
|
||||
className="flex-1 btn btn-outline btn-sm"
|
||||
>
|
||||
<Edit className="w-3 h-3 mr-1" />
|
||||
Edit Pricing
|
||||
</button>
|
||||
<button className="flex-1 btn btn-outline btn-sm">
|
||||
<Eye className="w-3 h-3 mr-1" />
|
||||
View Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pricing Modal */}
|
||||
{showPricingModal && selectedProduct && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-2xl p-8 w-full max-w-lg mx-4 shadow-2xl border border-secondary-200/50 dark:border-secondary-700/50">
|
||||
<div className="flex items-center space-x-4 mb-6">
|
||||
<div className={`${getCategoryColor(selectedProduct.category)} p-3 rounded-xl`}>
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center bg-white/20 backdrop-blur-sm">
|
||||
{getCategoryIcon(selectedProduct.category)}
|
||||
</div>
|
||||
) : (
|
||||
/* List View */
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-50 dark:bg-slate-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
Product
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
Category
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
Price
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
Commission
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
Stock
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-slate-800 divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{products.map((product) => (
|
||||
<tr key={product.id} className="hover:bg-slate-50 dark:hover:bg-slate-700">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<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">
|
||||
{getCategoryIcon(product.category)}
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-white">
|
||||
{product.name}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{product.sku}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-secondary-900 dark:text-white">
|
||||
Edit Pricing
|
||||
</h3>
|
||||
<p className="text-secondary-600 dark:text-secondary-400">
|
||||
{selectedProduct.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-secondary-700 dark:text-secondary-300 mb-3">
|
||||
Margin Type
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center p-3 border border-secondary-300 dark:border-secondary-600 rounded-xl cursor-pointer hover:bg-secondary-50 dark:hover:bg-secondary-800/50 transition-colors">
|
||||
<input
|
||||
type="radio"
|
||||
name="marginType"
|
||||
value="percentage"
|
||||
checked={selectedProduct.marginType === 'percentage'}
|
||||
onChange={() => setSelectedProduct({...selectedProduct, marginType: 'percentage'})}
|
||||
className="mr-3"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-secondary-900 dark:text-white">Percentage</div>
|
||||
<div className="text-xs text-secondary-500 dark:text-secondary-400">Add margin as %</div>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center p-3 border border-secondary-300 dark:border-secondary-600 rounded-xl cursor-pointer hover:bg-secondary-50 dark:hover:bg-secondary-800/50 transition-colors">
|
||||
<input
|
||||
type="radio"
|
||||
name="marginType"
|
||||
value="fixed"
|
||||
checked={selectedProduct.marginType === 'fixed'}
|
||||
onChange={() => setSelectedProduct({...selectedProduct, marginType: 'fixed'})}
|
||||
className="mr-3"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-secondary-900 dark:text-white">Fixed Amount</div>
|
||||
<div className="text-xs text-secondary-500 dark:text-secondary-400">Add fixed $ amount</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-secondary-700 dark:text-secondary-300 mb-3">
|
||||
Margin Value
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
value={selectedProduct.margin}
|
||||
onChange={(e) => setSelectedProduct({...selectedProduct, margin: parseFloat(e.target.value) || 0})}
|
||||
className="w-full px-4 py-3 border border-secondary-300 dark:border-secondary-600 rounded-xl bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all duration-300"
|
||||
step={selectedProduct.marginType === 'percentage' ? 0.01 : 0.01}
|
||||
min="0"
|
||||
/>
|
||||
<span className="absolute right-4 top-1/2 transform -translate-y-1/2 text-secondary-500 font-medium">
|
||||
{selectedProduct.marginType === 'percentage' ? '%' : '$'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-semibold rounded-full ${getCategoryColor(product.category)}`}>
|
||||
{getCategoryIcon(product.category)}
|
||||
{product.category.replace('_', ' ')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-slate-900 dark:text-white">
|
||||
${(Number(product.price) || 0).toFixed(2)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{product.currency}
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-r from-secondary-50 to-secondary-100 dark:from-secondary-800/50 dark:to-secondary-700/50 p-4 rounded-xl border border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium text-secondary-600 dark:text-secondary-400">Base Price:</span>
|
||||
<span className="font-semibold text-secondary-900 dark:text-white">${selectedProduct.basePrice}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-slate-900 dark:text-white">
|
||||
{product.commissionRate}%
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-secondary-600 dark:text-secondary-400">New Price:</span>
|
||||
<span className="font-bold text-lg text-primary-600 dark:text-primary-400">
|
||||
${selectedProduct.marginType === 'percentage'
|
||||
? (selectedProduct.basePrice * (1 + selectedProduct.margin / 100)).toFixed(2)
|
||||
: (selectedProduct.basePrice + selectedProduct.margin).toFixed(2)
|
||||
}
|
||||
</td>
|
||||
<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)}`}>
|
||||
{product.status.charAt(0).toUpperCase() + product.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mt-8">
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-500 dark:text-slate-400">
|
||||
{product.stockQuantity === -1 ? 'Unlimited' : product.stockQuantity}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setShowPricingModal(false)}
|
||||
className="flex-1 btn btn-outline btn-lg"
|
||||
onClick={() => handleEditProduct(product)}
|
||||
className="text-blue-600 hover:text-blue-900 dark:hover:text-blue-400"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteProduct(product.id)}
|
||||
className="text-red-600 hover:text-red-900 dark:hover:text-red-400"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="px-6 py-3 border-t 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 gap-2">
|
||||
<button
|
||||
onClick={() => handlePageChange(pagination.currentPage - 1)}
|
||||
disabled={pagination.currentPage === 1}
|
||||
className="px-3 py-1 text-sm border border-slate-300 dark:border-slate-600 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="px-3 py-1 text-sm text-slate-700 dark:text-slate-300">
|
||||
Page {pagination.currentPage} of {pagination.totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handlePageChange(pagination.currentPage + 1)}
|
||||
disabled={pagination.currentPage === pagination.totalPages}
|
||||
className="px-3 py-1 text-sm border border-slate-300 dark:border-slate-600 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Product Form Modal */}
|
||||
{showProductForm && (
|
||||
<ProductForm
|
||||
product={selectedProduct}
|
||||
onClose={() => setShowProductForm(false)}
|
||||
onSuccess={handleProductFormSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{isDeleteModalOpen && (
|
||||
<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">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Confirm Delete</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
Are you sure you want to delete this product? This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setDeletingProductId(null);
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
handlePricingUpdate(selectedProduct.id, selectedProduct.margin, selectedProduct.marginType);
|
||||
setShowPricingModal(false);
|
||||
}}
|
||||
className="flex-1 btn btn-primary btn-lg shadow-lg hover:shadow-xl transition-all duration-300"
|
||||
onClick={confirmDelete}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
Update Pricing
|
||||
Delete Product
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAppDispatch } from '../store/hooks';
|
||||
import { registerUser } from '../store/slices/authThunks';
|
||||
@ -15,94 +15,278 @@ import {
|
||||
Sun,
|
||||
Moon,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
AlertCircle,
|
||||
Cloud,
|
||||
Zap
|
||||
Zap,
|
||||
MapPin,
|
||||
Globe,
|
||||
FileText,
|
||||
Briefcase,
|
||||
Users,
|
||||
TrendingUp,
|
||||
Calendar,
|
||||
Hash,
|
||||
CheckCircle,
|
||||
Circle
|
||||
} from 'lucide-react';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
import { RootState } from '../store';
|
||||
import { toggleTheme } from '../store/slices/themeSlice';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
interface FormData {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
phone: string;
|
||||
company: string;
|
||||
companyType: 'corporation' | 'llc' | 'partnership' | 'sole_proprietorship' | 'other' | '';
|
||||
registrationNumber: string;
|
||||
gstNumber: string;
|
||||
panNumber: string;
|
||||
address: string;
|
||||
website: string;
|
||||
businessLicense: string;
|
||||
taxId: string;
|
||||
industry: string;
|
||||
yearsInBusiness: string;
|
||||
annualRevenue: string;
|
||||
employeeCount: string;
|
||||
}
|
||||
|
||||
const Signup: React.FC = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
phone: '',
|
||||
company: ''
|
||||
company: '',
|
||||
companyType: '',
|
||||
registrationNumber: '',
|
||||
gstNumber: '',
|
||||
panNumber: '',
|
||||
address: '',
|
||||
website: '',
|
||||
businessLicense: '',
|
||||
taxId: '',
|
||||
industry: '',
|
||||
yearsInBusiness: '',
|
||||
annualRevenue: '',
|
||||
employeeCount: ''
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
const [showCompanyTypeDropdown, setShowCompanyTypeDropdown] = useState(false);
|
||||
const [showIndustryDropdown, setShowIndustryDropdown] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const { theme } = useAppSelector((state: RootState) => state.theme);
|
||||
|
||||
const totalSteps = 4;
|
||||
|
||||
const companyTypes = [
|
||||
{ value: 'corporation', label: 'Corporation' },
|
||||
{ value: 'llc', label: 'LLC' },
|
||||
{ value: 'partnership', label: 'Partnership' },
|
||||
{ value: 'sole_proprietorship', label: 'Sole Proprietorship' },
|
||||
{ value: 'other', label: 'Other' }
|
||||
];
|
||||
|
||||
const industries = [
|
||||
'Technology Services', 'IT Consulting', 'Cloud Services',
|
||||
'Software Development', 'Digital Marketing', 'E-commerce',
|
||||
'Healthcare IT', 'Financial Services', 'Education Technology',
|
||||
'Manufacturing', 'Retail', 'Telecommunications',
|
||||
'Energy', 'Transportation', 'Real Estate',
|
||||
'Media & Entertainment', 'Professional Services', 'Other'
|
||||
];
|
||||
|
||||
// Close dropdowns when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Element;
|
||||
if (!target.closest('.dropdown-container')) {
|
||||
setShowCompanyTypeDropdown(false);
|
||||
setShowIndustryDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
setError(''); // Clear error when user starts typing
|
||||
};
|
||||
|
||||
const validateStep = (step: number): boolean => {
|
||||
setError('');
|
||||
|
||||
switch (step) {
|
||||
case 1:
|
||||
if (!validateName(formData.firstName)) {
|
||||
setError('First name must be between 2 and 50 characters');
|
||||
return false;
|
||||
}
|
||||
if (!validateName(formData.lastName)) {
|
||||
setError('Last name must be between 2 and 50 characters');
|
||||
return false;
|
||||
}
|
||||
if (!validateEmail(formData.email)) {
|
||||
setError('Please enter a valid email address');
|
||||
return false;
|
||||
}
|
||||
if (!validatePhoneNumber(formData.phone)) {
|
||||
setError('Please enter a valid phone number');
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
|
||||
case 2:
|
||||
if (!validatePassword(formData.password)) {
|
||||
setError('Password must be at least 8 characters with uppercase, lowercase, number, and special character');
|
||||
return false;
|
||||
}
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return false;
|
||||
}
|
||||
if (!formData.company || formData.company.trim().length === 0) {
|
||||
setError('Company name is required');
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
|
||||
case 3:
|
||||
if (!formData.companyType) {
|
||||
setError('Company type is required');
|
||||
return false;
|
||||
}
|
||||
if (!formData.registrationNumber || formData.registrationNumber.trim().length === 0) {
|
||||
setError('Registration number is required');
|
||||
return false;
|
||||
}
|
||||
if (!formData.address || formData.address.trim().length === 0) {
|
||||
setError('Business address is mandatory for vendor registration');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (formData.address.trim().length < 10) {
|
||||
setError(`Business address must be at least 10 characters long. Current length: ${formData.address.trim().length} characters`);
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
|
||||
case 4:
|
||||
if (!formData.industry || formData.industry.trim().length === 0) {
|
||||
setError('Industry is required');
|
||||
return false;
|
||||
}
|
||||
if (!formData.yearsInBusiness || formData.yearsInBusiness.trim().length === 0) {
|
||||
setError('Years in business is required');
|
||||
return false;
|
||||
}
|
||||
if (!formData.employeeCount || formData.employeeCount.trim().length === 0) {
|
||||
setError('Number of employees is required');
|
||||
return false;
|
||||
}
|
||||
if (isNaN(Number(formData.yearsInBusiness)) || Number(formData.yearsInBusiness) < 0) {
|
||||
setError('Years in business must be a valid number');
|
||||
return false;
|
||||
}
|
||||
if (isNaN(Number(formData.employeeCount)) || Number(formData.employeeCount) < 1) {
|
||||
setError('Number of employees must be at least 1');
|
||||
return false;
|
||||
}
|
||||
if (formData.annualRevenue && (isNaN(Number(formData.annualRevenue)) || Number(formData.annualRevenue) < 0)) {
|
||||
setError('Annual revenue must be a valid number');
|
||||
return false;
|
||||
}
|
||||
if (!agreedToTerms) {
|
||||
setError('Please agree to the terms and conditions');
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const nextStep = () => {
|
||||
if (validateStep(currentStep)) {
|
||||
setCurrentStep(prev => Math.min(prev + 1, totalSteps));
|
||||
}
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
setCurrentStep(prev => Math.max(prev - 1, 1));
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// Validation
|
||||
if (!validateName(formData.firstName)) {
|
||||
setError('First name must be between 2 and 50 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateName(formData.lastName)) {
|
||||
setError('Last name must be between 2 and 50 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateEmail(formData.email)) {
|
||||
setError('Please enter a valid email address');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validatePassword(formData.password)) {
|
||||
setError('Password must be at least 8 characters with uppercase, lowercase, number, and special character');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.phone && !validatePhoneNumber(formData.phone)) {
|
||||
setError('Please enter a valid phone number (7-15 digits, optional + prefix)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!agreedToTerms) {
|
||||
setError('Please agree to the terms and conditions');
|
||||
if (!validateStep(currentStep)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await dispatch(registerUser({
|
||||
// Prepare the registration data, only including fields with actual values
|
||||
const registrationData: any = {
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
phone: formData.phone,
|
||||
company: formData.company,
|
||||
// Vendor-specific fields
|
||||
companyType: formData.companyType || undefined,
|
||||
registrationNumber: formData.registrationNumber,
|
||||
address: formData.address,
|
||||
industry: formData.industry,
|
||||
yearsInBusiness: formData.yearsInBusiness,
|
||||
employeeCount: formData.employeeCount,
|
||||
role: 'channel_partner_admin',
|
||||
userType: 'channel_partner'
|
||||
})).unwrap();
|
||||
};
|
||||
|
||||
// Only add optional fields if they have values
|
||||
if (formData.gstNumber && formData.gstNumber.trim()) {
|
||||
registrationData.gstNumber = formData.gstNumber;
|
||||
}
|
||||
if (formData.panNumber && formData.panNumber.trim()) {
|
||||
registrationData.panNumber = formData.panNumber;
|
||||
}
|
||||
if (formData.website && formData.website.trim()) {
|
||||
registrationData.website = formData.website;
|
||||
}
|
||||
if (formData.businessLicense && formData.businessLicense.trim()) {
|
||||
registrationData.businessLicense = formData.businessLicense;
|
||||
}
|
||||
if (formData.taxId && formData.taxId.trim()) {
|
||||
registrationData.taxId = formData.taxId;
|
||||
}
|
||||
if (formData.annualRevenue && formData.annualRevenue.trim()) {
|
||||
registrationData.annualRevenue = formData.annualRevenue;
|
||||
}
|
||||
|
||||
await dispatch(registerUser(registrationData)).unwrap();
|
||||
|
||||
// Navigate to login page with success message
|
||||
navigate('/login', {
|
||||
state: {
|
||||
message: 'Registration successful! You can now login.'
|
||||
@ -121,45 +305,51 @@ const Signup: React.FC = () => {
|
||||
dispatch(toggleTheme());
|
||||
};
|
||||
|
||||
const renderStepIndicator = () => {
|
||||
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 flex items-center justify-center p-4">
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={handleThemeToggle}
|
||||
className="fixed top-6 right-6 p-3 rounded-full bg-white/80 dark:bg-slate-800 backdrop-blur-sm border border-slate-200 dark:border-slate-700 shadow-lg hover:shadow-xl transition-all duration-300 z-50"
|
||||
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="w-5 h-5 text-amber-500" />
|
||||
<div className="flex items-center justify-center mb-8">
|
||||
{Array.from({ length: totalSteps }, (_, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<div className={cn(
|
||||
"flex items-center justify-center w-8 h-8 rounded-full border-2 transition-all duration-200",
|
||||
currentStep > index + 1
|
||||
? "bg-green-500 border-green-500 text-white"
|
||||
: currentStep === index + 1
|
||||
? "bg-blue-500 border-blue-500 text-white"
|
||||
: "bg-white dark:bg-slate-700 border-slate-300 dark:border-slate-600 text-slate-500"
|
||||
)}>
|
||||
{currentStep > index + 1 ? (
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5 text-slate-600" />
|
||||
<span className="text-sm font-medium">{index + 1}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{index < totalSteps - 1 && (
|
||||
<div className={cn(
|
||||
"w-12 h-0.5 mx-2 transition-all duration-200",
|
||||
currentStep > index + 1 ? "bg-green-500" : "bg-slate-300 dark:bg-slate-600"
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
<div className="w-full max-w-2xl">
|
||||
{/* Logo and Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="relative inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-blue-600 via-indigo-600 to-purple-600 rounded-3xl shadow-2xl mb-6 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/20 to-transparent"></div>
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="relative">
|
||||
<Cloud className="w-10 h-10 text-white drop-shadow-lg" />
|
||||
<Zap className="w-5 h-5 text-yellow-300 absolute -top-1 -right-1 drop-shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">
|
||||
Join Cloudtopiaa Connect
|
||||
</h1>
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white mb-2">
|
||||
Personal Information
|
||||
</h2>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
Partner with us to offer next-gen cloud services.
|
||||
Let's start with your basic information
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Signup Form */}
|
||||
<div className="bg-white/80 dark:bg-slate-800 backdrop-blur-sm rounded-2xl shadow-2xl border border-white/20 dark:border-slate-700 p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Name Fields */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="firstName" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
@ -202,7 +392,6 @@ const Signup: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email and Phone */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
@ -244,29 +433,21 @@ const Signup: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Company */}
|
||||
case 2:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="company" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Company Name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Building className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
id="company"
|
||||
type="text"
|
||||
value={formData.company}
|
||||
onChange={(e) => handleInputChange('company', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="Enter company name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white mb-2">
|
||||
Account Security
|
||||
</h2>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
Create a secure password and provide company information
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Password Fields */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
@ -331,6 +512,317 @@ const Signup: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="company" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Company Name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Building className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
id="company"
|
||||
type="text"
|
||||
value={formData.company}
|
||||
onChange={(e) => handleInputChange('company', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="Enter company name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 3:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white mb-2">
|
||||
Business Details
|
||||
</h2>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
Provide your business registration and contact information
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Company Type
|
||||
</label>
|
||||
<div className="relative dropdown-container">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCompanyTypeDropdown(!showCompanyTypeDropdown)}
|
||||
className="w-full flex items-center justify-between px-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Briefcase className="h-5 w-5 text-slate-400 mr-3" />
|
||||
<span className={formData.companyType ? 'text-slate-900 dark:text-white' : 'text-slate-500 dark:text-slate-400'}>
|
||||
{formData.companyType ? companyTypes.find(t => t.value === formData.companyType)?.label : 'Select company type'}
|
||||
</span>
|
||||
</div>
|
||||
<svg className="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showCompanyTypeDropdown && (
|
||||
<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">
|
||||
{companyTypes.map((type) => (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleInputChange('companyType', type.value);
|
||||
setShowCompanyTypeDropdown(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"
|
||||
>
|
||||
{type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="registrationNumber" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Registration Number
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<FileText className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
id="registrationNumber"
|
||||
type="text"
|
||||
value={formData.registrationNumber}
|
||||
onChange={(e) => handleInputChange('registrationNumber', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="e.g., 1234567890"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="gstNumber" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
GST Number (Optional)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Hash className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
id="gstNumber"
|
||||
type="text"
|
||||
value={formData.gstNumber}
|
||||
onChange={(e) => handleInputChange('gstNumber', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="e.g., 24ABCDE1234F1Z9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="panNumber" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
PAN Number (Optional)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Hash className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
id="panNumber"
|
||||
type="text"
|
||||
value={formData.panNumber}
|
||||
onChange={(e) => handleInputChange('panNumber', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="e.g., ABCDE1234F"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="address" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Business Address
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MapPin className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
id="address"
|
||||
type="text"
|
||||
value={formData.address}
|
||||
onChange={(e) => handleInputChange('address', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="Enter complete business address (min. 10 characters)"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Minimum 10 characters required
|
||||
</span>
|
||||
<span className={cn(
|
||||
"text-xs font-medium",
|
||||
formData.address.length < 10
|
||||
? "text-red-500"
|
||||
: formData.address.length >= 10
|
||||
? "text-green-500"
|
||||
: "text-slate-500 dark:text-slate-400"
|
||||
)}>
|
||||
{formData.address.length}/10
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="website" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Website (Optional)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Globe className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
id="website"
|
||||
type="url"
|
||||
value={formData.website}
|
||||
onChange={(e) => handleInputChange('website', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 4:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white mb-2">
|
||||
Business Profile
|
||||
</h2>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
Complete your business profile and agree to terms
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Industry
|
||||
</label>
|
||||
<div className="relative dropdown-container">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowIndustryDropdown(!showIndustryDropdown)}
|
||||
className="w-full flex items-center justify-between px-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<TrendingUp className="h-5 w-5 text-slate-400 mr-3" />
|
||||
<span className={formData.industry ? 'text-slate-900 dark:text-white' : 'text-slate-500 dark:text-slate-400'}>
|
||||
{formData.industry || 'Select industry'}
|
||||
</span>
|
||||
</div>
|
||||
<svg className="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showIndustryDropdown && (
|
||||
<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">
|
||||
{industries.map((industry) => (
|
||||
<button
|
||||
key={industry}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleInputChange('industry', industry);
|
||||
setShowIndustryDropdown(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"
|
||||
>
|
||||
{industry}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="yearsInBusiness" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Years in Business
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Calendar className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
id="yearsInBusiness"
|
||||
type="number"
|
||||
value={formData.yearsInBusiness}
|
||||
onChange={(e) => handleInputChange('yearsInBusiness', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="e.g., 5"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="annualRevenue" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Annual Revenue (Optional)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<TrendingUp className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
id="annualRevenue"
|
||||
type="number"
|
||||
value={formData.annualRevenue}
|
||||
onChange={(e) => handleInputChange('annualRevenue', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="e.g., 1000000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="employeeCount" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Number of Employees
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Users className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
id="employeeCount"
|
||||
type="number"
|
||||
value={formData.employeeCount}
|
||||
onChange={(e) => handleInputChange('employeeCount', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="e.g., 50"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terms and Conditions */}
|
||||
<div className="flex items-start">
|
||||
<input
|
||||
@ -351,27 +843,101 @@ const Signup: React.FC = () => {
|
||||
</Link>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
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 flex items-center justify-center p-4">
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={handleThemeToggle}
|
||||
className="fixed top-6 right-6 p-3 rounded-full bg-white/80 dark:bg-slate-800 backdrop-blur-sm border border-slate-200 dark:border-slate-700 shadow-lg hover:shadow-xl transition-all duration-300 z-50"
|
||||
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="w-5 h-5 text-amber-500" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5 text-slate-600" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="w-full max-w-2xl">
|
||||
{/* Logo and Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="relative inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-blue-600 via-indigo-600 to-purple-600 rounded-3xl shadow-2xl mb-6 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/20 to-transparent"></div>
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="relative">
|
||||
<Cloud className="w-10 h-10 text-white drop-shadow-lg" />
|
||||
<Zap className="w-5 h-5 text-yellow-300 absolute -top-1 -right-1 drop-shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">
|
||||
Join Cloudtopiaa Connect
|
||||
</h1>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
Partner with us to offer next-gen cloud services.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
{renderStepIndicator()}
|
||||
|
||||
{/* Signup Form */}
|
||||
<div className="bg-white/80 dark:bg-slate-800 backdrop-blur-sm rounded-2xl shadow-2xl border border-white/20 dark:border-slate-700 p-8">
|
||||
<form onSubmit={handleSubmit}>
|
||||
{renderStepContent()}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="flex items-center p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<div className="flex items-center p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg mt-6">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 mr-2 flex-shrink-0" />
|
||||
<span className="text-sm text-red-700 dark:text-red-400">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex items-center justify-between mt-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={prevStep}
|
||||
disabled={currentStep === 1}
|
||||
className={cn(
|
||||
"flex items-center px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200",
|
||||
currentStep === 1 && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Previous
|
||||
</button>
|
||||
|
||||
{currentStep < totalSteps ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={nextStep}
|
||||
className="flex items-center px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200"
|
||||
>
|
||||
Next
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-center px-4 py-3 border border-transparent rounded-xl font-medium text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 shadow-lg hover:shadow-xl",
|
||||
"flex items-center px-6 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-lg font-medium hover:from-blue-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 shadow-lg hover:shadow-xl",
|
||||
isLoading && "opacity-75 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Creating account...
|
||||
</div>
|
||||
) : (
|
||||
@ -381,6 +947,8 @@ const Signup: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Sign In Link */}
|
||||
|
||||
989
src/pages/SignupStepwise.tsx
Normal file
989
src/pages/SignupStepwise.tsx
Normal file
@ -0,0 +1,989 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAppDispatch } from '../store/hooks';
|
||||
import { registerUser } from '../store/slices/authThunks';
|
||||
import toast from 'react-hot-toast';
|
||||
import { validatePhoneNumber, validateEmail, validatePassword, validateName } from '../utils/validation';
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
Mail,
|
||||
Lock,
|
||||
User,
|
||||
Building,
|
||||
Phone,
|
||||
Sun,
|
||||
Moon,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
AlertCircle,
|
||||
Cloud,
|
||||
Zap,
|
||||
MapPin,
|
||||
Globe,
|
||||
FileText,
|
||||
Briefcase,
|
||||
Users,
|
||||
TrendingUp,
|
||||
Calendar,
|
||||
Hash,
|
||||
CheckCircle
|
||||
} from 'lucide-react';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
import { RootState } from '../store';
|
||||
import { toggleTheme } from '../store/slices/themeSlice';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
interface FormData {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
phone: string;
|
||||
company: string;
|
||||
companyType: 'corporation' | 'llc' | 'partnership' | 'sole_proprietorship' | 'other' | '';
|
||||
registrationNumber: string;
|
||||
gstNumber: string;
|
||||
panNumber: string;
|
||||
address: string;
|
||||
website: string;
|
||||
businessLicense: string;
|
||||
taxId: string;
|
||||
industry: string;
|
||||
yearsInBusiness: string;
|
||||
annualRevenue: string;
|
||||
employeeCount: string;
|
||||
}
|
||||
|
||||
const SignupStepwise: React.FC = () => {
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
phone: '',
|
||||
company: '',
|
||||
companyType: '',
|
||||
registrationNumber: '',
|
||||
gstNumber: '',
|
||||
panNumber: '',
|
||||
address: '',
|
||||
website: '',
|
||||
businessLicense: '',
|
||||
taxId: '',
|
||||
industry: '',
|
||||
yearsInBusiness: '',
|
||||
annualRevenue: '',
|
||||
employeeCount: ''
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
const [showCompanyTypeDropdown, setShowCompanyTypeDropdown] = useState(false);
|
||||
const [showIndustryDropdown, setShowIndustryDropdown] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const { theme } = useAppSelector((state: RootState) => state.theme);
|
||||
|
||||
const totalSteps = 4;
|
||||
|
||||
const companyTypes = [
|
||||
{ value: 'corporation', label: 'Corporation' },
|
||||
{ value: 'llc', label: 'LLC' },
|
||||
{ value: 'partnership', label: 'Partnership' },
|
||||
{ value: 'sole_proprietorship', label: 'Sole Proprietorship' },
|
||||
{ value: 'other', label: 'Other' }
|
||||
];
|
||||
|
||||
const industries = [
|
||||
'Technology Services', 'IT Consulting', 'Cloud Services',
|
||||
'Software Development', 'Digital Marketing', 'E-commerce',
|
||||
'Healthcare IT', 'Financial Services', 'Education Technology',
|
||||
'Manufacturing', 'Retail', 'Telecommunications',
|
||||
'Energy', 'Transportation', 'Real Estate',
|
||||
'Media & Entertainment', 'Professional Services', 'Other'
|
||||
];
|
||||
|
||||
// Close dropdowns when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Element;
|
||||
if (!target.closest('.dropdown-container')) {
|
||||
setShowCompanyTypeDropdown(false);
|
||||
setShowIndustryDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
setError(''); // Clear error when user starts typing
|
||||
};
|
||||
|
||||
const validateStep = (step: number): boolean => {
|
||||
setError('');
|
||||
|
||||
switch (step) {
|
||||
case 1:
|
||||
if (!validateName(formData.firstName)) {
|
||||
setError('First name must be between 2 and 50 characters');
|
||||
return false;
|
||||
}
|
||||
if (!validateName(formData.lastName)) {
|
||||
setError('Last name must be between 2 and 50 characters');
|
||||
return false;
|
||||
}
|
||||
if (!validateEmail(formData.email)) {
|
||||
setError('Please enter a valid email address');
|
||||
return false;
|
||||
}
|
||||
if (!validatePhoneNumber(formData.phone)) {
|
||||
setError('Please enter a valid phone number');
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
|
||||
case 2:
|
||||
if (!validatePassword(formData.password)) {
|
||||
setError('Password must be at least 8 characters with uppercase, lowercase, number, and special character');
|
||||
return false;
|
||||
}
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return false;
|
||||
}
|
||||
if (!formData.company || formData.company.trim().length === 0) {
|
||||
setError('Company name is required');
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
|
||||
case 3:
|
||||
if (!formData.companyType) {
|
||||
setError('Company type is required');
|
||||
return false;
|
||||
}
|
||||
if (!formData.registrationNumber || formData.registrationNumber.trim().length === 0) {
|
||||
setError('Registration number is required');
|
||||
return false;
|
||||
}
|
||||
if (!formData.address || formData.address.trim().length === 0) {
|
||||
setError('Business address is required');
|
||||
return false;
|
||||
}
|
||||
if (formData.address.trim().length < 10) {
|
||||
setError(`Business address must be at least 10 characters long. Current length: ${formData.address.trim().length} characters`);
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
|
||||
case 4:
|
||||
if (!formData.industry || formData.industry.trim().length === 0) {
|
||||
setError('Industry is required');
|
||||
return false;
|
||||
}
|
||||
if (!formData.yearsInBusiness || formData.yearsInBusiness.trim().length === 0) {
|
||||
setError('Years in business is required');
|
||||
return false;
|
||||
}
|
||||
if (!formData.employeeCount || formData.employeeCount.trim().length === 0) {
|
||||
setError('Number of employees is required');
|
||||
return false;
|
||||
}
|
||||
if (isNaN(Number(formData.yearsInBusiness)) || Number(formData.yearsInBusiness) < 0) {
|
||||
setError('Years in business must be a valid number');
|
||||
return false;
|
||||
}
|
||||
if (isNaN(Number(formData.employeeCount)) || Number(formData.employeeCount) < 1) {
|
||||
setError('Number of employees must be at least 1');
|
||||
return false;
|
||||
}
|
||||
if (formData.annualRevenue && (isNaN(Number(formData.annualRevenue)) || Number(formData.annualRevenue) < 0)) {
|
||||
setError('Annual revenue must be a valid number');
|
||||
return false;
|
||||
}
|
||||
if (!agreedToTerms) {
|
||||
setError('Please agree to the terms and conditions');
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const nextStep = () => {
|
||||
if (validateStep(currentStep)) {
|
||||
setCurrentStep(prev => Math.min(prev + 1, totalSteps));
|
||||
}
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
setCurrentStep(prev => Math.max(prev - 1, 1));
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateStep(currentStep)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Prepare the registration data, only including fields with actual values
|
||||
const registrationData: any = {
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
phone: formData.phone,
|
||||
company: formData.company,
|
||||
companyType: formData.companyType || undefined,
|
||||
registrationNumber: formData.registrationNumber,
|
||||
address: formData.address,
|
||||
industry: formData.industry,
|
||||
yearsInBusiness: formData.yearsInBusiness,
|
||||
employeeCount: formData.employeeCount,
|
||||
role: 'channel_partner_admin',
|
||||
userType: 'channel_partner'
|
||||
};
|
||||
|
||||
// Only add optional fields if they have values
|
||||
if (formData.gstNumber && formData.gstNumber.trim()) {
|
||||
registrationData.gstNumber = formData.gstNumber;
|
||||
}
|
||||
if (formData.panNumber && formData.panNumber.trim()) {
|
||||
registrationData.panNumber = formData.panNumber;
|
||||
}
|
||||
if (formData.website && formData.website.trim()) {
|
||||
registrationData.website = formData.website;
|
||||
}
|
||||
if (formData.businessLicense && formData.businessLicense.trim()) {
|
||||
registrationData.businessLicense = formData.businessLicense;
|
||||
}
|
||||
if (formData.taxId && formData.taxId.trim()) {
|
||||
registrationData.taxId = formData.taxId;
|
||||
}
|
||||
if (formData.annualRevenue && formData.annualRevenue.trim()) {
|
||||
registrationData.annualRevenue = formData.annualRevenue;
|
||||
}
|
||||
|
||||
await dispatch(registerUser(registrationData)).unwrap();
|
||||
|
||||
navigate('/login', {
|
||||
state: {
|
||||
message: 'Registration successful! You can now login.'
|
||||
}
|
||||
});
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.message || 'An error occurred during signup. Please try again.';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleThemeToggle = () => {
|
||||
dispatch(toggleTheme());
|
||||
};
|
||||
|
||||
const renderStepIndicator = () => {
|
||||
return (
|
||||
<div className="flex items-center justify-center mb-8">
|
||||
{Array.from({ length: totalSteps }, (_, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<div className={cn(
|
||||
"flex items-center justify-center w-8 h-8 rounded-full border-2 transition-all duration-200",
|
||||
currentStep > index + 1
|
||||
? "bg-green-500 border-green-500 text-white"
|
||||
: currentStep === index + 1
|
||||
? "bg-blue-500 border-blue-500 text-white"
|
||||
: "bg-white dark:bg-slate-700 border-slate-300 dark:border-slate-600 text-slate-500"
|
||||
)}>
|
||||
{currentStep > index + 1 ? (
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
) : (
|
||||
<span className="text-sm font-medium">{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
{index < totalSteps - 1 && (
|
||||
<div className={cn(
|
||||
"w-12 h-0.5 mx-2 transition-all duration-200",
|
||||
currentStep > index + 1 ? "bg-green-500" : "bg-slate-300 dark:bg-slate-600"
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white mb-2">
|
||||
Personal Information
|
||||
</h2>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
Let's start with your basic information
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="firstName" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
First Name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
id="firstName"
|
||||
type="text"
|
||||
value={formData.firstName}
|
||||
onChange={(e) => handleInputChange('firstName', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="Enter first name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="lastName" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Last Name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
id="lastName"
|
||||
type="text"
|
||||
value={formData.lastName}
|
||||
onChange={(e) => handleInputChange('lastName', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="Enter last name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="Enter email address"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="phone" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Phone Number
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Phone className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleInputChange('phone', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="Enter phone number"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 2:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white mb-2">
|
||||
Account Security
|
||||
</h2>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
Create a secure password and provide company information
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={formData.password}
|
||||
onChange={(e) => handleInputChange('password', e.target.value)}
|
||||
className="block w-full pl-10 pr-12 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="Create password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Confirm Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
|
||||
className="block w-full pl-10 pr-12 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="Confirm password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="company" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Company Name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Building className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
id="company"
|
||||
type="text"
|
||||
value={formData.company}
|
||||
onChange={(e) => handleInputChange('company', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="Enter company name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 3:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white mb-2">
|
||||
Business Details
|
||||
</h2>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
Provide your business registration and contact information
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Company Type
|
||||
</label>
|
||||
<div className="relative dropdown-container">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCompanyTypeDropdown(!showCompanyTypeDropdown)}
|
||||
className="w-full flex items-center justify-between px-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Briefcase className="h-5 w-5 text-slate-400 mr-3" />
|
||||
<span className={formData.companyType ? 'text-slate-900 dark:text-white' : 'text-slate-500 dark:text-slate-400'}>
|
||||
{formData.companyType ? companyTypes.find(t => t.value === formData.companyType)?.label : 'Select company type'}
|
||||
</span>
|
||||
</div>
|
||||
<svg className="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showCompanyTypeDropdown && (
|
||||
<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">
|
||||
{companyTypes.map((type) => (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleInputChange('companyType', type.value);
|
||||
setShowCompanyTypeDropdown(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"
|
||||
>
|
||||
{type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="registrationNumber" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Registration Number
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<FileText className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
id="registrationNumber"
|
||||
type="text"
|
||||
value={formData.registrationNumber}
|
||||
onChange={(e) => handleInputChange('registrationNumber', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="e.g., 1234567890"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="gstNumber" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
GST Number (Optional)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Hash className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
id="gstNumber"
|
||||
type="text"
|
||||
value={formData.gstNumber}
|
||||
onChange={(e) => handleInputChange('gstNumber', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="e.g., 24ABCDE1234F1Z9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="panNumber" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
PAN Number (Optional)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Hash className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
id="panNumber"
|
||||
type="text"
|
||||
value={formData.panNumber}
|
||||
onChange={(e) => handleInputChange('panNumber', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="e.g., ABCDE1234F"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="address" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Business Address
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MapPin className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
id="address"
|
||||
type="text"
|
||||
value={formData.address}
|
||||
onChange={(e) => handleInputChange('address', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="Enter complete business address (min. 10 characters)"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Minimum 10 characters required
|
||||
</span>
|
||||
<span className={cn(
|
||||
"text-xs font-medium",
|
||||
formData.address.length < 10
|
||||
? "text-red-500"
|
||||
: formData.address.length >= 10
|
||||
? "text-green-500"
|
||||
: "text-slate-500 dark:text-slate-400"
|
||||
)}>
|
||||
{formData.address.length}/10
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="website" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Website (Optional)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Globe className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
id="website"
|
||||
type="url"
|
||||
value={formData.website}
|
||||
onChange={(e) => handleInputChange('website', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 4:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white mb-2">
|
||||
Business Profile
|
||||
</h2>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
Complete your business profile and agree to terms
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Industry
|
||||
</label>
|
||||
<div className="relative dropdown-container">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowIndustryDropdown(!showIndustryDropdown)}
|
||||
className="w-full flex items-center justify-between px-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<TrendingUp className="h-5 w-5 text-slate-400 mr-3" />
|
||||
<span className={formData.industry ? 'text-slate-900 dark:text-white' : 'text-slate-500 dark:text-slate-400'}>
|
||||
{formData.industry || 'Select industry'}
|
||||
</span>
|
||||
</div>
|
||||
<svg className="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showIndustryDropdown && (
|
||||
<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">
|
||||
{industries.map((industry) => (
|
||||
<button
|
||||
key={industry}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleInputChange('industry', industry);
|
||||
setShowIndustryDropdown(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"
|
||||
>
|
||||
{industry}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="yearsInBusiness" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Years in Business
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Calendar className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
id="yearsInBusiness"
|
||||
type="number"
|
||||
value={formData.yearsInBusiness}
|
||||
onChange={(e) => handleInputChange('yearsInBusiness', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="e.g., 5"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="annualRevenue" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Annual Revenue (Optional)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<TrendingUp className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
id="annualRevenue"
|
||||
type="number"
|
||||
value={formData.annualRevenue}
|
||||
onChange={(e) => handleInputChange('annualRevenue', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="e.g., 1000000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="employeeCount" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Number of Employees
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Users className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
id="employeeCount"
|
||||
type="number"
|
||||
value={formData.employeeCount}
|
||||
onChange={(e) => handleInputChange('employeeCount', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-3 border border-slate-300 dark:border-slate-600 rounded-xl bg-white dark:bg-slate-700 text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="e.g., 50"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terms and Conditions */}
|
||||
<div className="flex items-start">
|
||||
<input
|
||||
id="terms"
|
||||
type="checkbox"
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-slate-300 dark:border-slate-600 rounded mt-1"
|
||||
/>
|
||||
<label htmlFor="terms" className="ml-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
I agree to the{' '}
|
||||
<Link to="/terms" className="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 font-medium">
|
||||
Terms and Conditions
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link to="/privacy" className="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 font-medium">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
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 flex items-center justify-center p-4">
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={handleThemeToggle}
|
||||
className="fixed top-6 right-6 p-3 rounded-full bg-white/80 dark:bg-slate-800 backdrop-blur-sm border border-slate-200 dark:border-slate-700 shadow-lg hover:shadow-xl transition-all duration-300 z-50"
|
||||
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="w-5 h-5 text-amber-500" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5 text-slate-600" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="w-full max-w-2xl">
|
||||
{/* Logo and Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="relative inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-blue-600 via-indigo-600 to-purple-600 rounded-3xl shadow-2xl mb-6 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/20 to-transparent"></div>
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="relative">
|
||||
<Cloud className="w-10 h-10 text-white drop-shadow-lg" />
|
||||
<Zap className="w-5 h-5 text-yellow-300 absolute -top-1 -right-1 drop-shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">
|
||||
Join Cloudtopiaa Connect
|
||||
</h1>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
Partner with us to offer next-gen cloud services.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
{renderStepIndicator()}
|
||||
|
||||
{/* Signup Form */}
|
||||
<div className="bg-white/80 dark:bg-slate-800 backdrop-blur-sm rounded-2xl shadow-2xl border border-white/20 dark:border-slate-700 p-8">
|
||||
<form onSubmit={handleSubmit}>
|
||||
{renderStepContent()}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="flex items-center p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg mt-6">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 mr-2 flex-shrink-0" />
|
||||
<span className="text-sm text-red-700 dark:text-red-400">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex items-center justify-between mt-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={prevStep}
|
||||
disabled={currentStep === 1}
|
||||
className={cn(
|
||||
"flex items-center px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200",
|
||||
currentStep === 1 && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Previous
|
||||
</button>
|
||||
|
||||
{currentStep < totalSteps ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={nextStep}
|
||||
className="flex items-center px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200"
|
||||
>
|
||||
Next
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
"flex items-center px-6 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-lg font-medium hover:from-blue-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 shadow-lg hover:shadow-xl",
|
||||
isLoading && "opacity-75 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Creating account...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
Create Account
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Sign In Link */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 transition-colors duration-200"
|
||||
>
|
||||
Sign in here
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Switch to Reseller */}
|
||||
<div className="mt-4 text-center">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Are you a Reseller?{' '}
|
||||
<Link
|
||||
to="/reseller/signup"
|
||||
className="font-medium text-emerald-600 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300 transition-colors duration-200"
|
||||
>
|
||||
Sign up here
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
© 2025 Cloudtopiaa. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignupStepwise;
|
||||
@ -70,6 +70,8 @@ const Training: React.FC = () => {
|
||||
const [selectedModuleForAdd, setSelectedModuleForAdd] = useState<string>('');
|
||||
const [showAnalytics, setShowAnalytics] = useState(false);
|
||||
const [currentView, setCurrentView] = useState<'training' | 'admin' | 'analytics'>('training');
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [deletingItem, setDeletingItem] = useState<{ type: 'module' | 'video' | 'material', id: string, moduleId?: string } | null>(null);
|
||||
|
||||
const trainingModules: TrainingModule[] = [
|
||||
{
|
||||
@ -319,23 +321,43 @@ const Training: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleDeleteModule = (moduleId: string) => {
|
||||
if (window.confirm('Are you sure you want to delete this training module? This action cannot be undone.')) {
|
||||
// Handle module deletion
|
||||
console.log('Deleting module:', moduleId);
|
||||
}
|
||||
setDeletingItem({ type: 'module', id: moduleId });
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteVideo = (moduleId: string, videoId: string) => {
|
||||
if (window.confirm('Are you sure you want to delete this video?')) {
|
||||
// Handle video deletion
|
||||
console.log('Deleting video:', videoId, 'from module:', moduleId);
|
||||
}
|
||||
setDeletingItem({ type: 'video', id: videoId, moduleId });
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteMaterial = (moduleId: string, materialId: string) => {
|
||||
if (window.confirm('Are you sure you want to delete this material?')) {
|
||||
setDeletingItem({ type: 'material', id: materialId, moduleId });
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingItem) return;
|
||||
|
||||
try {
|
||||
if (deletingItem.type === 'module') {
|
||||
// Handle module deletion
|
||||
console.log('Deleting module:', deletingItem.id);
|
||||
// Here you would typically make an API call to delete the module
|
||||
} else if (deletingItem.type === 'video') {
|
||||
// Handle video deletion
|
||||
console.log('Deleting video:', deletingItem.id, 'from module:', deletingItem.moduleId);
|
||||
// Here you would typically make an API call to delete the video
|
||||
} else if (deletingItem.type === 'material') {
|
||||
// Handle material deletion
|
||||
console.log('Deleting material:', materialId, 'from module:', moduleId);
|
||||
console.log('Deleting material:', deletingItem.id, 'from module:', deletingItem.moduleId);
|
||||
// Here you would typically make an API call to delete the material
|
||||
}
|
||||
|
||||
// Close modal and reset state
|
||||
setIsDeleteModalOpen(false);
|
||||
setDeletingItem(null);
|
||||
} catch (error) {
|
||||
console.error('Error deleting item:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@ -358,10 +380,8 @@ const Training: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleModuleDelete = (id: string) => {
|
||||
if (window.confirm('Are you sure you want to delete this module? This action cannot be undone.')) {
|
||||
console.log('Deleting module:', id);
|
||||
// Here you would typically update your state or make an API call
|
||||
}
|
||||
setDeletingItem({ type: 'module', id });
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleVideoAdd = (moduleId: string, video: Omit<TrainingVideo, 'id'>) => {
|
||||
@ -380,10 +400,8 @@ const Training: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleVideoDelete = (moduleId: string, videoId: string) => {
|
||||
if (window.confirm('Are you sure you want to delete this video?')) {
|
||||
console.log('Deleting video:', moduleId, videoId);
|
||||
// Here you would typically update your state or make an API call
|
||||
}
|
||||
setDeletingItem({ type: 'video', id: videoId, moduleId });
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleMaterialAdd = (moduleId: string, material: Omit<TrainingMaterial, 'id'>) => {
|
||||
@ -402,10 +420,8 @@ const Training: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleMaterialDelete = (moduleId: string, materialId: string) => {
|
||||
if (window.confirm('Are you sure you want to delete this material?')) {
|
||||
console.log('Deleting material:', moduleId, materialId);
|
||||
// Here you would typically update your state or make an API call
|
||||
}
|
||||
setDeletingItem({ type: 'material', id: materialId, moduleId });
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleExportReport = (type: 'pdf' | 'excel' | 'csv') => {
|
||||
@ -1134,6 +1150,37 @@ const Training: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{isDeleteModalOpen && (
|
||||
<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">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Confirm Delete</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
{deletingItem?.type === 'module' && 'Are you sure you want to delete this training module? This action cannot be undone.'}
|
||||
{deletingItem?.type === 'video' && 'Are you sure you want to delete this video? This action cannot be undone.'}
|
||||
{deletingItem?.type === 'material' && 'Are you sure you want to delete this material? This action cannot be undone.'}
|
||||
</p>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setDeletingItem(null);
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmDelete}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
Delete {deletingItem?.type === 'module' ? 'Module' : deletingItem?.type === 'video' ? 'Video' : 'Material'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
289
src/pages/admin/Analytics.tsx
Normal file
289
src/pages/admin/Analytics.tsx
Normal file
@ -0,0 +1,289 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
BarChart3,
|
||||
Users,
|
||||
Building,
|
||||
Package,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
DollarSign,
|
||||
Activity,
|
||||
Calendar,
|
||||
Target
|
||||
} from 'lucide-react';
|
||||
|
||||
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 [analyticsData, setAnalyticsData] = useState<AnalyticsData>({
|
||||
userGrowth: { total: 0, growth: 0, trend: 'up' },
|
||||
revenue: { total: 0, growth: 0, trend: 'up' },
|
||||
vendorApprovals: { total: 0, pending: 0, approved: 0, rejected: 0 },
|
||||
productPerformance: { totalProducts: 0, activeProducts: 0, topPerforming: [] },
|
||||
systemMetrics: { uptime: 0, responseTime: 0, errorRate: 0 }
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [timeRange, setTimeRange] = useState('30d');
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnalyticsData();
|
||||
}, [timeRange]);
|
||||
|
||||
const fetchAnalyticsData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Mock data for now - replace with actual API call
|
||||
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);
|
||||
} catch (error) {
|
||||
console.error('Error fetching analytics data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const getTrendIcon = (trend: 'up' | 'down') => {
|
||||
return trend === 'up' ? (
|
||||
<TrendingUp className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4 text-red-600" />
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-full">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center space-y-4 sm:space-y-0">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Analytics Dashboard</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">System-wide performance and insights</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<select
|
||||
value={timeRange}
|
||||
onChange={(e) => setTimeRange(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md bg-white dark:bg-gray-800 dark:border-gray-600"
|
||||
>
|
||||
<option value="7d">Last 7 days</option>
|
||||
<option value="30d">Last 30 days</option>
|
||||
<option value="90d">Last 90 days</option>
|
||||
<option value="1y">Last year</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 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="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<h3 className="text-sm font-medium">Total Users</h3>
|
||||
<Users className="h-4 w-4 text-gray-500" />
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<div className="text-2xl font-bold">{(analyticsData.userGrowth.total || 0).toLocaleString()}</div>
|
||||
<div className="flex items-center text-xs text-gray-500">
|
||||
{getTrendIcon(analyticsData.userGrowth.trend)}
|
||||
<span className="ml-1">{analyticsData.userGrowth.growth}% from last month</span>
|
||||
</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 Revenue</h3>
|
||||
<DollarSign className="h-4 w-4 text-gray-500" />
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<div className="text-2xl font-bold">{formatCurrency(analyticsData.revenue.total)}</div>
|
||||
<div className="flex items-center text-xs text-gray-500">
|
||||
{getTrendIcon(analyticsData.revenue.trend)}
|
||||
<span className="ml-1">{analyticsData.revenue.growth}% from last month</span>
|
||||
</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">Active Products</h3>
|
||||
<Package className="h-4 w-4 text-gray-500" />
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<div className="text-2xl font-bold">{analyticsData.productPerformance.activeProducts}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
of {analyticsData.productPerformance.totalProducts} total products
|
||||
</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">System Uptime</h3>
|
||||
<Activity className="h-4 w-4 text-gray-500" />
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<div className="text-2xl font-bold">{analyticsData.systemMetrics.uptime}%</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Avg response: {analyticsData.systemMetrics.responseTime}ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vendor Approvals */}
|
||||
<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 p-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="flex items-center text-lg font-semibold">
|
||||
<Building className="h-5 w-5 mr-2" />
|
||||
Vendor Approval Status
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">Total Requests</span>
|
||||
<span className="font-semibold">{analyticsData.vendorApprovals.total}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-yellow-600">Pending</span>
|
||||
<span className="font-semibold">{analyticsData.vendorApprovals.pending}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-green-600">Approved</span>
|
||||
<span className="font-semibold">{analyticsData.vendorApprovals.approved}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-red-600">Rejected</span>
|
||||
<span className="font-semibold">{analyticsData.vendorApprovals.rejected}</span>
|
||||
</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="mb-4">
|
||||
<h3 className="flex items-center text-lg font-semibold">
|
||||
<BarChart3 className="h-5 w-5 mr-2" />
|
||||
Top Performing Products
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{analyticsData.productPerformance.topPerforming.map((product, index) => (
|
||||
<div key={index} className="flex justify-between items-center">
|
||||
<div>
|
||||
<div className="font-medium text-sm">{product.name}</div>
|
||||
<div className="text-xs text-gray-500">{product.sales} sales</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-semibold">{formatCurrency(product.revenue)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Health */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="flex items-center text-lg font-semibold">
|
||||
<Activity className="h-5 w-5 mr-2" />
|
||||
System Health Metrics
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 lg:gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{analyticsData.systemMetrics.uptime}%</div>
|
||||
<div className="text-sm text-gray-500">Uptime</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">{analyticsData.systemMetrics.responseTime}ms</div>
|
||||
<div className="text-sm text-gray-500">Avg Response Time</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-orange-600">{analyticsData.systemMetrics.errorRate}%</div>
|
||||
<div className="text-sm text-gray-500">Error Rate</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminAnalytics;
|
||||
@ -40,6 +40,8 @@ const ChannelPartners: React.FC = () => {
|
||||
const [selectedPartner, setSelectedPartner] = useState<ChannelPartner | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingPartner, setEditingPartner] = useState<ChannelPartner | null>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [deletingPartnerId, setDeletingPartnerId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchChannelPartners();
|
||||
@ -47,9 +49,10 @@ const ChannelPartners: React.FC = () => {
|
||||
|
||||
const fetchChannelPartners = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/channel-partners', {
|
||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/channel-partners`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
@ -65,31 +68,38 @@ const ChannelPartners: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleDelete = async (partnerId: string) => {
|
||||
if (window.confirm('Are you sure you want to delete this channel partner?')) {
|
||||
setDeletingPartnerId(partnerId);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingPartnerId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/channel-partners/${partnerId}`, {
|
||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/channel-partners/${deletingPartnerId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchChannelPartners();
|
||||
setIsDeleteModalOpen(false);
|
||||
setDeletingPartnerId(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting channel partner:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (partnerId: string, updateData: Partial<ChannelPartner>) => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/channel-partners/${partnerId}`, {
|
||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/channel-partners/${partnerId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`
|
||||
},
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
@ -318,7 +328,7 @@ const ChannelPartners: React.FC = () => {
|
||||
|
||||
{/* Partner Details Modal */}
|
||||
{selectedPartner && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<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-slate-800 rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-xl font-semibold text-slate-900 dark:text-white">
|
||||
@ -426,6 +436,35 @@ const ChannelPartners: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{isDeleteModalOpen && (
|
||||
<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">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Confirm Delete</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
Are you sure you want to delete this channel partner? This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setDeletingPartnerId(null);
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmDelete}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
Delete Partner
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -15,41 +15,69 @@ import {
|
||||
Bell
|
||||
} from 'lucide-react';
|
||||
import { useAppSelector } from '../../store/hooks';
|
||||
import { VendorRequest } from '../../types/vendor';
|
||||
import VendorDetailsModal from '../../components/VendorDetailsModal';
|
||||
import VendorRejectionModal from '../../components/VendorRejectionModal';
|
||||
|
||||
interface DashboardStats {
|
||||
totalUsers: number;
|
||||
pendingVendors: number;
|
||||
totalChannelPartners: number;
|
||||
totalRegisteredVendors: number;
|
||||
totalResellers: number;
|
||||
recentRequests: number;
|
||||
approvedToday: number;
|
||||
rejectedToday: number;
|
||||
revenue: number;
|
||||
userBreakdown: {
|
||||
systemAdmins: number;
|
||||
vendors: number;
|
||||
resellers: number;
|
||||
inactiveUsers: number;
|
||||
};
|
||||
todayStats: {
|
||||
approvedToday: number;
|
||||
rejectedToday: number;
|
||||
};
|
||||
recentActivity: Array<{
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
priority: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface PendingVendor {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
company: string;
|
||||
createdAt: string;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
}
|
||||
// Use the shared VendorRequest interface instead of PendingVendor
|
||||
|
||||
const AdminDashboard: React.FC = () => {
|
||||
const [stats, setStats] = useState<DashboardStats>({
|
||||
totalUsers: 0,
|
||||
pendingVendors: 0,
|
||||
totalChannelPartners: 0,
|
||||
totalRegisteredVendors: 0,
|
||||
totalResellers: 0,
|
||||
recentRequests: 0,
|
||||
approvedToday: 0,
|
||||
rejectedToday: 0,
|
||||
revenue: 0
|
||||
revenue: 0,
|
||||
userBreakdown: {
|
||||
systemAdmins: 0,
|
||||
vendors: 0,
|
||||
resellers: 0,
|
||||
inactiveUsers: 0
|
||||
},
|
||||
todayStats: {
|
||||
approvedToday: 0,
|
||||
rejectedToday: 0
|
||||
},
|
||||
recentActivity: []
|
||||
});
|
||||
const [pendingVendors, setPendingVendors] = useState<PendingVendor[]>([]);
|
||||
const [pendingVendors, setPendingVendors] = useState<VendorRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedVendor, setSelectedVendor] = useState<VendorRequest | null>(null);
|
||||
const [showVendorModal, setShowVendorModal] = useState(false);
|
||||
const [rejectionReason, setRejectionReason] = useState('');
|
||||
const [showRejectionModal, setShowRejectionModal] = useState(false);
|
||||
const { user } = useAppSelector((state) => state.auth);
|
||||
|
||||
useEffect(() => {
|
||||
@ -59,27 +87,28 @@ const AdminDashboard: React.FC = () => {
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
// Fetch dashboard stats
|
||||
const statsResponse = await fetch('/api/admin/dashboard', {
|
||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/dashboard`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
const statsData = await statsResponse.json();
|
||||
const statsData = await response.json();
|
||||
|
||||
if (statsData.success) {
|
||||
setStats(statsData.data);
|
||||
setStats(statsData.data.stats);
|
||||
}
|
||||
|
||||
// Fetch pending vendors
|
||||
const vendorsResponse = await fetch('/api/admin/pending-vendors', {
|
||||
const vendorsResponse = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/pending-vendors`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`
|
||||
}
|
||||
});
|
||||
const vendorsData = await vendorsResponse.json();
|
||||
|
||||
if (vendorsData.success) {
|
||||
setPendingVendors(vendorsData.data.slice(0, 5)); // Show only first 5
|
||||
setPendingVendors(vendorsData.data.pendingRequests?.slice(0, 5) || []); // Show only first 5
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard data:', error);
|
||||
@ -90,11 +119,11 @@ const AdminDashboard: React.FC = () => {
|
||||
|
||||
const handleApproveVendor = async (vendorId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/vendors/${vendorId}/approve`, {
|
||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/vendors/${vendorId}/approve`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`
|
||||
},
|
||||
body: JSON.stringify({ reason: 'Approved by admin' })
|
||||
});
|
||||
@ -107,25 +136,40 @@ const AdminDashboard: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectVendor = async (vendorId: string) => {
|
||||
const handleRejectVendor = async (vendorId: string, reason: string = 'Rejected by admin') => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/vendors/${vendorId}/reject`, {
|
||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/vendors/${vendorId}/reject`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`
|
||||
},
|
||||
body: JSON.stringify({ reason: 'Rejected by admin' })
|
||||
body: JSON.stringify({ reason })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchDashboardData(); // Refresh data
|
||||
setShowRejectionModal(false);
|
||||
setRejectionReason('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error rejecting vendor:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewVendorDetails = (vendor: VendorRequest) => {
|
||||
setSelectedVendor(vendor);
|
||||
setShowVendorModal(true);
|
||||
};
|
||||
|
||||
const handleRejectWithReason = (vendorId: string) => {
|
||||
const vendor = pendingVendors.find(v => v.id === vendorId);
|
||||
if (vendor) {
|
||||
setSelectedVendor(vendor);
|
||||
setShowRejectionModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
@ -151,12 +195,30 @@ const AdminDashboard: React.FC = () => {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{/* 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="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Total Users</p>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-white">{stats.totalUsers}</p>
|
||||
<div className="flex items-start justify-between">
|
||||
<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-3xl font-bold text-slate-900 dark:text-white mb-4">{stats.totalUsers || 0}</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center py-1 px-2 bg-slate-50 dark:bg-slate-700 rounded-md">
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400">System Admins</span>
|
||||
<span className="text-xs font-semibold text-blue-600 dark:text-blue-400">{stats.userBreakdown?.systemAdmins || 0}</span>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-100 dark:bg-blue-900 rounded-lg">
|
||||
<div className="flex justify-between items-center py-1 px-2 bg-slate-50 dark:bg-slate-700 rounded-md">
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400">Vendors</span>
|
||||
<span className="text-xs font-semibold text-green-600 dark:text-green-400">{stats.userBreakdown?.vendors || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 px-2 bg-slate-50 dark:bg-slate-700 rounded-md">
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400">Resellers</span>
|
||||
<span className="text-xs font-semibold text-purple-600 dark:text-purple-400">{stats.userBreakdown?.resellers || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 px-2 bg-red-50 dark:bg-red-900/20 rounded-md">
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400">Inactive Users</span>
|
||||
<span className="text-xs font-semibold text-red-600 dark:text-red-400">{stats.userBreakdown?.inactiveUsers || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-100 dark:bg-blue-900 rounded-lg ml-4">
|
||||
<Users className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
@ -167,7 +229,7 @@ const AdminDashboard: React.FC = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Pending Vendors</p>
|
||||
<p className="text-2xl font-bold text-amber-600 dark:text-amber-400">{stats.pendingVendors}</p>
|
||||
<p className="text-2xl font-bold text-amber-600 dark:text-amber-400">{stats.pendingVendors || 0}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-amber-100 dark:bg-amber-900 rounded-lg">
|
||||
<Clock className="w-6 h-6 text-amber-600 dark:text-amber-400" />
|
||||
@ -179,8 +241,8 @@ const AdminDashboard: React.FC = () => {
|
||||
<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>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Channel Partners</p>
|
||||
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">{stats.totalChannelPartners}</p>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Registered Vendors</p>
|
||||
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">{stats.totalRegisteredVendors || 0}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-emerald-100 dark:bg-emerald-900 rounded-lg">
|
||||
<Building className="w-6 h-6 text-emerald-600 dark:text-emerald-400" />
|
||||
@ -193,7 +255,7 @@ const AdminDashboard: React.FC = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Monthly Revenue</p>
|
||||
<p className="text-2xl font-bold text-green-600 dark:text-green-400">${stats.revenue.toLocaleString()}</p>
|
||||
<p className="text-2xl font-bold text-green-600 dark:text-green-400">${(stats.revenue || 0).toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-green-100 dark:bg-green-900 rounded-lg">
|
||||
<DollarSign className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||
@ -241,13 +303,14 @@ const AdminDashboard: React.FC = () => {
|
||||
<CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRejectVendor(vendor.id)}
|
||||
onClick={() => handleRejectWithReason(vendor.id)}
|
||||
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 text-red-600 dark:text-red-400" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleViewVendorDetails(vendor)}
|
||||
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"
|
||||
>
|
||||
@ -265,35 +328,71 @@ const AdminDashboard: React.FC = () => {
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white mb-6">Recent Activity</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center">
|
||||
<UserCheck className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-white">Vendor Approved</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">2 minutes ago</p>
|
||||
</div>
|
||||
</div>
|
||||
{stats.recentActivity && stats.recentActivity.length > 0 ? (
|
||||
stats.recentActivity.slice(0, 5).map((activity) => {
|
||||
const getActivityIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'NEW_VENDOR_REQUEST':
|
||||
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':
|
||||
return <Building className="w-4 h-4 text-purple-600 dark:text-purple-400" />;
|
||||
default:
|
||||
return <Activity className="w-4 h-4 text-gray-600 dark:text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center">
|
||||
<UserX className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-white">Vendor Rejected</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">15 minutes ago</p>
|
||||
</div>
|
||||
</div>
|
||||
const getActivityColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'NEW_VENDOR_REQUEST':
|
||||
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':
|
||||
return 'bg-purple-100 dark:bg-purple-900';
|
||||
default:
|
||||
return 'bg-gray-100 dark:bg-gray-900';
|
||||
}
|
||||
};
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<Activity className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
const formatTimeAgo = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / (1000 * 60));
|
||||
|
||||
if (diffInMinutes < 1) return 'Just now';
|
||||
if (diffInMinutes < 60) return `${diffInMinutes} minutes ago`;
|
||||
if (diffInMinutes < 1440) return `${Math.floor(diffInMinutes / 60)} hours ago`;
|
||||
return `${Math.floor(diffInMinutes / 1440)} days ago`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={activity.id} className="flex items-center space-x-3">
|
||||
<div className={`w-8 h-8 ${getActivityColor(activity.type)} rounded-full flex items-center justify-center`}>
|
||||
{getActivityIcon(activity.type)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-white">New Registration</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">1 hour ago</p>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-white truncate">
|
||||
{activity.title}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{formatTimeAgo(activity.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-center py-4">
|
||||
<Activity className="w-8 h-8 text-slate-400 mx-auto mb-2" />
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">No recent activity</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -307,7 +406,7 @@ const AdminDashboard: React.FC = () => {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Approved Today</p>
|
||||
<p className="text-2xl font-bold text-green-600 dark:text-green-400">{stats.approvedToday}</p>
|
||||
<p className="text-2xl font-bold text-green-600 dark:text-green-400">{stats.todayStats?.approvedToday || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -319,7 +418,7 @@ const AdminDashboard: React.FC = () => {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Rejected Today</p>
|
||||
<p className="text-2xl font-bold text-red-600 dark:text-red-400">{stats.rejectedToday}</p>
|
||||
<p className="text-2xl font-bold text-red-600 dark:text-red-400">{stats.todayStats?.rejectedToday || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -337,6 +436,246 @@ const AdminDashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vendor Details Modal */}
|
||||
{showVendorModal && selectedVendor && (
|
||||
<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-slate-800 rounded-lg shadow-2xl p-6 max-w-md w-full mx-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">Vendor Details</h3>
|
||||
<button
|
||||
onClick={() => setShowVendorModal(false)}
|
||||
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
|
||||
>
|
||||
<XCircle className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4 max-h-96 overflow-y-auto">
|
||||
{/* Basic Information */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-1">Name</p>
|
||||
<p className="text-slate-900 dark:text-white font-medium">{selectedVendor.firstName} {selectedVendor.lastName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-1">Email</p>
|
||||
<p className="text-slate-900 dark:text-white">{selectedVendor.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-1">Phone</p>
|
||||
<p className="text-slate-900 dark:text-white">{selectedVendor.phone || 'Not provided'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-1">Company</p>
|
||||
<p className="text-slate-900 dark:text-white font-medium">{selectedVendor.company}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-1">Role</p>
|
||||
<p className="text-slate-900 dark:text-white">{selectedVendor.role ? selectedVendor.role.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) : 'Vendor'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-1">Registration Date</p>
|
||||
<p className="text-slate-900 dark:text-white">{new Date(selectedVendor.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Business Information */}
|
||||
{(selectedVendor.companyType || selectedVendor.industry || selectedVendor.yearsInBusiness) && (
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
|
||||
<h4 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">Business Information</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{selectedVendor.companyType && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-1">Company Type</p>
|
||||
<p className="text-slate-900 dark:text-white">{selectedVendor.companyType}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedVendor.industry && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-1">Industry</p>
|
||||
<p className="text-slate-900 dark:text-white">{selectedVendor.industry}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedVendor.yearsInBusiness && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-1">Years in Business</p>
|
||||
<p className="text-slate-900 dark:text-white">{selectedVendor.yearsInBusiness} years</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedVendor.employeeCount && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-1">Employee Count</p>
|
||||
<p className="text-slate-900 dark:text-white">{selectedVendor.employeeCount}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Financial Information */}
|
||||
{(selectedVendor.annualRevenue || selectedVendor.taxId) && (
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
|
||||
<h4 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">Financial Information</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{selectedVendor.annualRevenue && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-1">Annual Revenue</p>
|
||||
<p className="text-slate-900 dark:text-white">${selectedVendor.annualRevenue.toLocaleString()}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedVendor.taxId && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-1">Tax ID</p>
|
||||
<p className="text-slate-900 dark:text-white">{selectedVendor.taxId}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legal Information */}
|
||||
{(selectedVendor.registrationNumber || selectedVendor.gstNumber || selectedVendor.panNumber || selectedVendor.businessLicense) && (
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
|
||||
<h4 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">Legal Information</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{selectedVendor.registrationNumber && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-1">Registration Number</p>
|
||||
<p className="text-slate-900 dark:text-white">{selectedVendor.registrationNumber}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedVendor.gstNumber && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-1">GST Number</p>
|
||||
<p className="text-slate-900 dark:text-white">{selectedVendor.gstNumber}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedVendor.panNumber && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-1">PAN Number</p>
|
||||
<p className="text-slate-900 dark:text-white">{selectedVendor.panNumber}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedVendor.businessLicense && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-1">Business License</p>
|
||||
<p className="text-slate-900 dark:text-white">{selectedVendor.businessLicense}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Address Information */}
|
||||
{selectedVendor.address && (
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
|
||||
<h4 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">Address Information</h4>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-1">Address</p>
|
||||
<p className="text-slate-900 dark:text-white">{selectedVendor.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rejection Reason */}
|
||||
{selectedVendor.rejectionReason && (
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
|
||||
<h4 className="text-sm font-semibold text-red-600 dark:text-red-400 mb-3">Rejection Information</h4>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-600 dark:text-red-400 mb-1">Rejection Reason</p>
|
||||
<p className="text-red-600 dark:text-red-400">{selectedVendor.rejectionReason}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex space-x-3 mt-6">
|
||||
<button
|
||||
onClick={() => handleApproveVendor(selectedVendor.id)}
|
||||
className="flex-1 bg-green-600 text-white py-2 px-4 rounded-md hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowVendorModal(false);
|
||||
handleRejectWithReason(selectedVendor.id);
|
||||
}}
|
||||
className="flex-1 bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rejection Reason Modal */}
|
||||
{showRejectionModal && selectedVendor && (
|
||||
<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-slate-800 rounded-lg shadow-2xl p-6 max-w-md w-full mx-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">Reject Vendor</h3>
|
||||
<button
|
||||
onClick={() => setShowRejectionModal(false)}
|
||||
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
|
||||
>
|
||||
<XCircle className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 mb-2">
|
||||
Rejecting: {selectedVendor.firstName} {selectedVendor.lastName} ({selectedVendor.company})
|
||||
</p>
|
||||
<label className="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-2">
|
||||
Reason for rejection
|
||||
</label>
|
||||
<textarea
|
||||
value={rejectionReason}
|
||||
onChange={(e) => setRejectionReason(e.target.value)}
|
||||
className="w-full p-3 border border-slate-300 dark:border-slate-600 rounded-md bg-white dark:bg-slate-700 text-slate-900 dark:text-white"
|
||||
rows={3}
|
||||
placeholder="Enter reason for rejection..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={() => setShowRejectionModal(false)}
|
||||
className="flex-1 bg-slate-300 dark:bg-slate-600 text-slate-700 dark:text-slate-300 py-2 px-4 rounded-md hover:bg-slate-400 dark:hover:bg-slate-500 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRejectVendor(selectedVendor.id, rejectionReason || 'Rejected by admin')}
|
||||
className="flex-1 bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shared Vendor Modals */}
|
||||
<VendorDetailsModal
|
||||
vendor={selectedVendor}
|
||||
isOpen={showVendorModal}
|
||||
onClose={() => setShowVendorModal(false)}
|
||||
onApprove={handleApproveVendor}
|
||||
onReject={handleRejectVendor}
|
||||
/>
|
||||
|
||||
<VendorRejectionModal
|
||||
vendor={selectedVendor}
|
||||
isOpen={showRejectionModal}
|
||||
onClose={() => setShowRejectionModal(false)}
|
||||
onReject={handleRejectVendor}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
433
src/pages/admin/Feedback.tsx
Normal file
433
src/pages/admin/Feedback.tsx
Normal file
@ -0,0 +1,433 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
MessageSquare,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Filter,
|
||||
Search,
|
||||
Eye,
|
||||
XCircle,
|
||||
Star,
|
||||
Bug,
|
||||
Lightbulb,
|
||||
HelpCircle
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Feedback {
|
||||
id: number;
|
||||
type: 'bug' | 'feature' | 'question' | 'general';
|
||||
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||
status: 'open' | 'in_progress' | 'resolved' | 'closed';
|
||||
title: string;
|
||||
description: string;
|
||||
submittedBy: string;
|
||||
submittedAt: string;
|
||||
assignedTo?: string;
|
||||
resolvedAt?: string;
|
||||
resolution?: string;
|
||||
systemInfo?: any;
|
||||
}
|
||||
|
||||
const AdminFeedback: React.FC = () => {
|
||||
const [feedbacks, setFeedbacks] = useState<Feedback[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedFeedback, setSelectedFeedback] = useState<Feedback | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'open' | 'in_progress' | 'resolved' | 'closed'>('all');
|
||||
const [typeFilter, setTypeFilter] = useState<'all' | 'bug' | 'feature' | 'question' | 'general'>('all');
|
||||
const [priorityFilter, setPriorityFilter] = useState<'all' | 'low' | 'medium' | 'high' | 'critical'>('all');
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeedbacks();
|
||||
}, []);
|
||||
|
||||
const fetchFeedbacks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/developer-feedback`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setFeedbacks(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching feedbacks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateFeedbackStatus = async (feedbackId: number, status: string, resolution?: string) => {
|
||||
try {
|
||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/developer-feedback/${feedbackId}/status`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ status, resolution })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchFeedbacks();
|
||||
setShowModal(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating feedback status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'bug':
|
||||
return <Bug className="w-4 h-4 text-red-500" />;
|
||||
case 'feature':
|
||||
return <Lightbulb className="w-4 h-4 text-blue-500" />;
|
||||
case 'question':
|
||||
return <HelpCircle className="w-4 h-4 text-purple-500" />;
|
||||
default:
|
||||
return <MessageSquare className="w-4 h-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'critical':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||
case 'high':
|
||||
return 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200';
|
||||
case 'medium':
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
|
||||
case 'low':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'open':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
|
||||
case 'in_progress':
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
|
||||
case 'resolved':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||
case 'closed':
|
||||
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 filteredFeedbacks = feedbacks.filter(feedback => {
|
||||
const matchesSearch = feedback.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
feedback.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
feedback.submittedBy.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesStatus = statusFilter === 'all' || feedback.status === statusFilter;
|
||||
const matchesType = typeFilter === 'all' || feedback.type === typeFilter;
|
||||
const matchesPriority = priorityFilter === 'all' || feedback.priority === priorityFilter;
|
||||
|
||||
return matchesSearch && matchesStatus && matchesType && matchesPriority;
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-full">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Developer Feedback & Issues
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Manage feedback, bug reports, and feature requests from developers
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
{/* Search */}
|
||||
<div className="lg:col-span-2">
|
||||
<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 feedback..."
|
||||
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 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as any)}
|
||||
className="w-full 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"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Type Filter */}
|
||||
<div>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value as any)}
|
||||
className="w-full 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"
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="bug">Bug</option>
|
||||
<option value="feature">Feature</option>
|
||||
<option value="question">Question</option>
|
||||
<option value="general">General</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Priority Filter */}
|
||||
<div>
|
||||
<select
|
||||
value={priorityFilter}
|
||||
onChange={(e) => setPriorityFilter(e.target.value as any)}
|
||||
className="w-full 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"
|
||||
>
|
||||
<option value="all">All Priorities</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feedback List */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<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">
|
||||
Feedback ({filteredFeedbacks.length})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<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-400 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Title
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Priority
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Submitted By
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filteredFeedbacks.map((feedback) => (
|
||||
<tr key={feedback.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
{getTypeIcon(feedback.type)}
|
||||
<span className="ml-2 text-sm text-gray-900 dark:text-white capitalize">
|
||||
{feedback.type}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{feedback.title}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 truncate max-w-xs">
|
||||
{feedback.description}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getPriorityColor(feedback.priority)}`}>
|
||||
{feedback.priority}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(feedback.status)}`}>
|
||||
{feedback.status === 'in_progress' ? 'In Progress' : feedback.status.replace('_', ' ')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||
{feedback.submittedBy}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{new Date(feedback.submittedAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedFeedback(feedback);
|
||||
setShowModal(true);
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-900 dark:hover:text-blue-400"
|
||||
title="View Details"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feedback Details Modal */}
|
||||
{showModal && selectedFeedback && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" style={{ backdropFilter: 'blur(4px)' }}>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Feedback Details
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<XCircle className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Type
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
{getTypeIcon(selectedFeedback.type)}
|
||||
<span className="ml-2 text-gray-900 dark:text-white capitalize">
|
||||
{selectedFeedback.type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Priority
|
||||
</label>
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getPriorityColor(selectedFeedback.priority)}`}>
|
||||
{selectedFeedback.priority}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Title
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white font-medium">
|
||||
{selectedFeedback.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white whitespace-pre-wrap">
|
||||
{selectedFeedback.description}
|
||||
</p>
|
||||
</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-1">
|
||||
Submitted By
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white">
|
||||
{selectedFeedback.submittedBy}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Submitted At
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white">
|
||||
{new Date(selectedFeedback.submittedAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedFeedback.systemInfo && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
System Information
|
||||
</label>
|
||||
<pre className="bg-gray-100 dark:bg-gray-700 p-3 rounded text-xs text-gray-900 dark:text-white overflow-x-auto">
|
||||
{JSON.stringify(selectedFeedback.systemInfo, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Update Status
|
||||
</label>
|
||||
<div className="flex space-x-2">
|
||||
<select
|
||||
className="flex-1 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"
|
||||
defaultValue={selectedFeedback.status}
|
||||
onChange={(e) => {
|
||||
updateFeedbackStatus(selectedFeedback.id, e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="open">Open</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminFeedback;
|
||||
920
src/pages/admin/Products.tsx
Normal file
920
src/pages/admin/Products.tsx
Normal file
@ -0,0 +1,920 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Plus, Search, Edit, Trash2, Eye, Pencil } from 'lucide-react';
|
||||
import { apiService, Product } from '../../services/api';
|
||||
|
||||
const Products: React.FC = () => {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [vendorFilter, setVendorFilter] = useState<string>('all');
|
||||
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 [viewMode, setViewMode] = useState<'grid' | 'list'>('list');
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [deletingProductId, setDeletingProductId] = useState<number | null>(null);
|
||||
const [editingProductId, setEditingProductId] = useState<number | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
const [totalItems, setTotalItems] = 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 = [
|
||||
{ value: 'cloud_storage', label: 'Cloud Storage' },
|
||||
{ value: 'cloud_computing', label: 'Cloud Computing' },
|
||||
{ value: 'cybersecurity', label: 'Cybersecurity' },
|
||||
{ value: 'data_analytics', label: 'Data Analytics' },
|
||||
{ value: 'ai_ml', label: 'AI & Machine Learning' },
|
||||
{ value: 'iot', label: 'Internet of Things' },
|
||||
{ value: 'blockchain', label: 'Blockchain' },
|
||||
{ value: 'other', label: 'Other' }
|
||||
];
|
||||
|
||||
const statuses = [
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'inactive', label: 'Inactive' },
|
||||
{ value: 'discontinued', label: 'Discontinued' }
|
||||
];
|
||||
|
||||
const availabilities = [
|
||||
{ value: 'available', label: 'Available' },
|
||||
{ value: 'out_of_stock', label: 'Out of Stock' },
|
||||
{ value: 'coming_soon', label: 'Coming Soon' },
|
||||
{ value: 'discontinued', label: 'Discontinued' }
|
||||
];
|
||||
|
||||
const fetchProducts = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await apiService.getAllProducts({
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
category: categoryFilter === 'all' ? undefined : categoryFilter,
|
||||
status: statusFilter === 'all' ? undefined : statusFilter,
|
||||
vendor: vendorFilter === 'all' ? undefined : vendorFilter,
|
||||
search: searchTerm || undefined,
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'desc'
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
setProducts(response.data.products);
|
||||
setTotalItems(response.data.pagination.totalItems);
|
||||
setTotalPages(response.data.pagination.totalPages);
|
||||
} else {
|
||||
setError('Failed to fetch products');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching products:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to fetch products');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentPage, itemsPerPage, categoryFilter, statusFilter, vendorFilter, searchTerm]);
|
||||
|
||||
const fetchVendors = useCallback(async () => {
|
||||
try {
|
||||
const response = await apiService.getActiveVendors();
|
||||
if (response.success) {
|
||||
setVendors(response.data);
|
||||
|
||||
// Build vendor map for quick lookup
|
||||
const vendorMapData: Record<number, { firstName: string; lastName: string; company?: string }> = {};
|
||||
response.data.forEach(vendor => {
|
||||
vendorMapData[vendor.id] = {
|
||||
firstName: vendor.firstName,
|
||||
lastName: vendor.lastName,
|
||||
company: vendor.company
|
||||
};
|
||||
});
|
||||
setVendorMap(vendorMapData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching vendors:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts();
|
||||
}, [fetchProducts]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
fetchVendors();
|
||||
}, [fetchVendors]);
|
||||
|
||||
useEffect(() => {
|
||||
if (categoryFilter !== '' || statusFilter !== '' || searchTerm !== '' || vendorFilter !== 'all') {
|
||||
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'
|
||||
});
|
||||
|
||||
alert('Product created successfully');
|
||||
setIsCreateModalOpen(false);
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
category: 'other',
|
||||
price: '',
|
||||
commissionRate: '',
|
||||
status: 'draft',
|
||||
availability: 'available',
|
||||
features: [],
|
||||
specifications: {},
|
||||
purchaseUrl: ''
|
||||
});
|
||||
fetchProducts();
|
||||
} catch (error) {
|
||||
alert('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'
|
||||
});
|
||||
|
||||
alert('Product updated successfully');
|
||||
setIsEditModalOpen(false);
|
||||
setEditingProductId(null);
|
||||
fetchProducts();
|
||||
} catch (error) {
|
||||
alert('Error updating product');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProduct = async (productId: number) => {
|
||||
setDeletingProductId(productId);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingProductId) return;
|
||||
|
||||
try {
|
||||
await apiService.deleteProduct(deletingProductId);
|
||||
alert('Product deleted successfully');
|
||||
fetchProducts();
|
||||
setIsDeleteModalOpen(false);
|
||||
setDeletingProductId(null);
|
||||
} catch (error) {
|
||||
alert('Error deleting product');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredProducts = products.filter(product => {
|
||||
const matchesSearch = product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(product.sku && product.sku.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
const matchesCategory = !categoryFilter || product.category === categoryFilter;
|
||||
const matchesStatus = !statusFilter || product.status === statusFilter;
|
||||
|
||||
return matchesSearch && matchesCategory && matchesStatus;
|
||||
});
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusConfig = {
|
||||
draft: { color: 'bg-gray-100 text-gray-800', label: 'Draft' },
|
||||
active: { color: 'bg-green-100 text-green-800', label: 'Active' },
|
||||
inactive: { color: 'bg-yellow-100 text-yellow-800', label: 'Inactive' },
|
||||
discontinued: { color: 'bg-red-100 text-red-800', label: 'Discontinued' }
|
||||
};
|
||||
|
||||
const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.draft;
|
||||
return <span className={`px-2 py-1 rounded-full text-xs font-medium ${config.color}`}>{config.label}</span>;
|
||||
};
|
||||
|
||||
const getAvailabilityBadge = (availability: string) => {
|
||||
const availabilityConfig = {
|
||||
available: { color: 'bg-green-100 text-green-800', label: 'Available' },
|
||||
out_of_stock: { color: 'bg-red-100 text-red-800', label: 'Out of Stock' },
|
||||
coming_soon: { color: 'bg-blue-100 text-blue-800', label: 'Coming Soon' },
|
||||
discontinued: { color: 'bg-gray-100 text-gray-800', label: 'Discontinued' }
|
||||
};
|
||||
|
||||
const config = availabilityConfig[availability as keyof typeof availabilityConfig] || availabilityConfig.available;
|
||||
return <span className={`px-2 py-1 rounded-full text-xs font-medium ${config.color}`}>{config.label}</span>;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-lg text-gray-900 dark:text-white">Loading products...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-full">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Products</h1>
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Add Product
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<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 flex-col sm:flex-row items-start sm:items-center gap-4 mb-4">
|
||||
<input
|
||||
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>
|
||||
|
||||
{/* View Toggle */}
|
||||
<div className="flex justify-end mt-4">
|
||||
<div className="flex border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`px-3 py-2 text-sm font-medium ${
|
||||
viewMode === 'list'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`px-3 py-2 text-sm font-medium ${
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{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="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<Trash2 className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">Error</h3>
|
||||
<div className="mt-2 text-sm text-red-700 dark:text-red-300">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Products Display */}
|
||||
{!loading && products.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-500 dark:text-gray-400">No products found</div>
|
||||
</div>
|
||||
) : viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{products.map((product) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white truncate">
|
||||
{product.name}
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
{(product.isAdminCreated || product.source === 'admin') && (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-400">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||
product.status === 'active'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400'
|
||||
: product.status === 'inactive'
|
||||
? 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400'
|
||||
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400'
|
||||
}`}
|
||||
>
|
||||
{product.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2">
|
||||
{product.description}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex 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">{product.category}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400">Price:</span>
|
||||
<span className="text-gray-900 dark:text-white font-medium">${product.price}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400">Vendor:</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{product.isAdminCreated || product.source === 'admin' ? (
|
||||
<span className="text-blue-600 dark:text-blue-400 font-medium">From Cloudtopiaa</span>
|
||||
) : product.creator ? (
|
||||
product.creator.company ?
|
||||
`${product.creator.company} (${product.creator.firstName} ${product.creator.lastName})` :
|
||||
`${product.creator.firstName} ${product.creator.lastName}`
|
||||
) : (
|
||||
'Unknown Vendor'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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') ? (
|
||||
<button
|
||||
disabled
|
||||
className="p-2 text-gray-400 dark:text-gray-500 cursor-not-allowed opacity-50"
|
||||
title="Admin-created products cannot be deleted"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleDeleteProduct(product.id)}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full bg-white dark:bg-gray-800 rounded-lg overflow-hidden shadow-sm">
|
||||
<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">
|
||||
Product
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Vendor
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Category
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Price
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Commission
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Status
|
||||
</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">
|
||||
{products.map((product) => (
|
||||
<tr key={product.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="flex-shrink-0 h-10 w-10">
|
||||
<div className="h-10 w-10 rounded-lg bg-blue-100 dark:bg-blue-900 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{product.name}</div>
|
||||
{(product.isAdminCreated || product.source === 'admin') && (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-400">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">{product.sku || 'N/A'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<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 ${
|
||||
product.isAdminCreated || product.source === 'admin'
|
||||
? 'bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200'
|
||||
: 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'
|
||||
}`}>
|
||||
{product.isAdminCreated || product.source === 'admin' ? (
|
||||
'From Cloudtopiaa'
|
||||
) : product.creator ? (
|
||||
product.creator.company ?
|
||||
`${product.creator.company} (${product.creator.firstName} ${product.creator.lastName})` :
|
||||
`${product.creator.firstName} ${product.creator.lastName}`
|
||||
) : (
|
||||
'Unknown Vendor'
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<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 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200">
|
||||
{product.category.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||
${product.price}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||
{product.commissionRate}%
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(product.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<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') ? (
|
||||
<button
|
||||
disabled
|
||||
className="p-2 text-gray-400 dark:text-gray-500 cursor-not-allowed opacity-50"
|
||||
title="Admin-created products cannot be deleted"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleDeleteProduct(product.id)}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalItems > itemsPerPage && (
|
||||
<div className="flex justify-center mt-8">
|
||||
<nav className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
{Array.from({ length: Math.ceil(totalItems / itemsPerPage) }, (_, i) => i + 1).map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className={`px-3 py-2 text-sm font-medium rounded-lg ${
|
||||
currentPage === page
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-500 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.min(prev + 1, Math.ceil(totalItems / itemsPerPage)))}
|
||||
disabled={currentPage === Math.ceil(totalItems / itemsPerPage)}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</nav>
|
||||
</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 */}
|
||||
{isDeleteModalOpen && (
|
||||
<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">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Confirm Delete</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
Are you sure you want to delete this product? This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setDeletingProductId(null);
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmDelete}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
Delete Product
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Products;
|
||||
568
src/pages/admin/RegisteredVendors.tsx
Normal file
568
src/pages/admin/RegisteredVendors.tsx
Normal file
@ -0,0 +1,568 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Building,
|
||||
Users,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
Search,
|
||||
Filter,
|
||||
MapPin,
|
||||
Mail,
|
||||
Phone,
|
||||
Globe,
|
||||
TrendingUp,
|
||||
Calendar
|
||||
} from 'lucide-react';
|
||||
|
||||
interface RegisteredVendor {
|
||||
id: string;
|
||||
companyName: string;
|
||||
companyType: string;
|
||||
contactEmail: string;
|
||||
contactPhone: string;
|
||||
website: string;
|
||||
tier: string;
|
||||
status: string;
|
||||
commissionRate: number;
|
||||
territory: string;
|
||||
specializations: string[];
|
||||
createdAt: string;
|
||||
approvedAt?: string;
|
||||
}
|
||||
|
||||
const RegisteredVendors: React.FC = () => {
|
||||
const [vendors, setVendors] = useState<RegisteredVendor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'pending' | 'suspended'>('all');
|
||||
const [selectedVendor, setSelectedVendor] = useState<RegisteredVendor | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingVendor, setEditingVendor] = useState<RegisteredVendor | null>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [deletingVendorId, setDeletingVendorId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRegisteredVendors();
|
||||
}, []);
|
||||
|
||||
const fetchRegisteredVendors = async () => {
|
||||
try {
|
||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/registered-vendors`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setVendors(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching registered vendors:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (vendorId: string) => {
|
||||
setDeletingVendorId(vendorId);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingVendorId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/registered-vendors/${deletingVendorId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchRegisteredVendors();
|
||||
setIsDeleteModalOpen(false);
|
||||
setDeletingVendorId(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting registered vendor:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (vendorId: string, updateData: Partial<RegisteredVendor>) => {
|
||||
try {
|
||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/registered-vendors/${vendorId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`
|
||||
},
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchRegisteredVendors();
|
||||
setEditingVendor(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating registered vendor:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status?: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'text-green-600 bg-green-100 dark:bg-green-900';
|
||||
case 'pending':
|
||||
return 'text-yellow-600 bg-yellow-100 dark:bg-yellow-900';
|
||||
case 'suspended':
|
||||
return 'text-red-600 bg-red-100 dark:bg-red-900';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-100 dark:bg-gray-900';
|
||||
}
|
||||
};
|
||||
|
||||
const getTierColor = (tier?: string) => {
|
||||
switch (tier) {
|
||||
case 'diamond':
|
||||
return 'text-purple-600 bg-purple-100 dark:bg-purple-900';
|
||||
case 'platinum':
|
||||
return 'text-blue-600 bg-blue-100 dark:bg-blue-900';
|
||||
case 'gold':
|
||||
return 'text-yellow-600 bg-yellow-100 dark:bg-yellow-900';
|
||||
case 'silver':
|
||||
return 'text-gray-600 bg-gray-100 dark:bg-gray-900';
|
||||
case 'bronze':
|
||||
return 'text-orange-600 bg-orange-100 dark:bg-orange-900';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-100 dark:bg-gray-900';
|
||||
}
|
||||
};
|
||||
|
||||
const filteredVendors = vendors.filter(vendor => {
|
||||
const matchesSearch = vendor.companyName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
vendor.contactEmail.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = statusFilter === 'all' || vendor.status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-full">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Registered Vendors
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Manage vendors who have partnered with us
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow 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 text-gray-400 w-4 h-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search vendors..."
|
||||
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-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as any)}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Vendors Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow 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-400 uppercase tracking-wider">
|
||||
Vendor
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Contact
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Tier
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Commission
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Joined
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filteredVendors.map((vendor) => (
|
||||
<tr key={vendor.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-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<Building className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{vendor.companyName}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{vendor.companyType}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900 dark:text-white">
|
||||
<div className="flex items-center">
|
||||
<Mail className="w-4 h-4 mr-2 text-gray-400" />
|
||||
{vendor.contactEmail}
|
||||
</div>
|
||||
<div className="flex items-center mt-1">
|
||||
<Phone className="w-4 h-4 mr-2 text-gray-400" />
|
||||
{vendor.contactPhone}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getTierColor(vendor.tier)}`}>
|
||||
{vendor.tier?.charAt(0).toUpperCase() + vendor.tier?.slice(1) || 'Unknown'}
|
||||
</span>
|
||||
</td>
|
||||
<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)}`}>
|
||||
{vendor.status?.charAt(0).toUpperCase() + vendor.status?.slice(1) || 'Unknown'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||
{vendor.commissionRate}%
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{new Date(vendor.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedVendor(vendor);
|
||||
setShowModal(true);
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-900 dark:hover:text-blue-400"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingVendor(vendor)}
|
||||
className="text-green-600 hover:text-green-900 dark:hover:text-green-400"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(vendor.id)}
|
||||
className="text-red-600 hover:text-red-900 dark:hover:text-red-400"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vendor Details Modal */}
|
||||
{showModal && selectedVendor && (
|
||||
<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-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Vendor Details
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Company Name
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white">{selectedVendor.companyName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Company Type
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white">{selectedVendor.companyType}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Contact Email
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white">{selectedVendor.contactEmail}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Contact Phone
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white">{selectedVendor.contactPhone}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Website
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white">{selectedVendor.website || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Tier
|
||||
</label>
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getTierColor(selectedVendor.tier)}`}>
|
||||
{selectedVendor.tier?.charAt(0).toUpperCase() + selectedVendor.tier?.slice(1) || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(selectedVendor.status)}`}>
|
||||
{selectedVendor.status?.charAt(0).toUpperCase() + selectedVendor.status?.slice(1) || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Commission Rate
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white">{selectedVendor.commissionRate}%</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Created At
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white">{new Date(selectedVendor.createdAt).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end p-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Vendor Modal */}
|
||||
{editingVendor && (
|
||||
<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-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Edit Vendor
|
||||
</h3>
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const updateData = {
|
||||
companyName: formData.get('companyName') as string,
|
||||
contactEmail: formData.get('contactEmail') as string,
|
||||
contactPhone: formData.get('contactPhone') as string,
|
||||
website: (formData.get('website') as string) || undefined,
|
||||
tier: formData.get('tier') as string,
|
||||
status: formData.get('status') as string,
|
||||
commissionRate: parseFloat(formData.get('commissionRate') as string)
|
||||
};
|
||||
handleUpdate(editingVendor.id, updateData);
|
||||
}}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Company Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="companyName"
|
||||
defaultValue={editingVendor.companyName}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Contact Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="contactEmail"
|
||||
defaultValue={editingVendor.contactEmail}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Contact Phone
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
name="contactPhone"
|
||||
defaultValue={editingVendor.contactPhone}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Website
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
name="website"
|
||||
defaultValue={editingVendor.website}
|
||||
placeholder="https://example.com (optional)"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Tier
|
||||
</label>
|
||||
<select
|
||||
name="tier"
|
||||
defaultValue={editingVendor.tier}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
required
|
||||
>
|
||||
<option value="bronze">Bronze</option>
|
||||
<option value="silver">Silver</option>
|
||||
<option value="gold">Gold</option>
|
||||
<option value="platinum">Platinum</option>
|
||||
<option value="diamond">Diamond</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={editingVendor.status}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
required
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Commission Rate (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="commissionRate"
|
||||
defaultValue={editingVendor.commissionRate}
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-3 mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingVendor(null)}
|
||||
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Update Vendor
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{isDeleteModalOpen && (
|
||||
<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">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Confirm Delete</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
Are you sure you want to delete this registered vendor? This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setDeletingVendorId(null);
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmDelete}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
Delete Vendor
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisteredVendors;
|
||||
379
src/pages/admin/Reports.tsx
Normal file
379
src/pages/admin/Reports.tsx
Normal file
@ -0,0 +1,379 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
FileText,
|
||||
Download,
|
||||
Calendar,
|
||||
Filter,
|
||||
Users,
|
||||
Building,
|
||||
Package,
|
||||
DollarSign,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
BarChart3,
|
||||
PieChart,
|
||||
Activity
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ReportData {
|
||||
userReports: {
|
||||
totalUsers: number;
|
||||
newUsers: number;
|
||||
activeUsers: number;
|
||||
userGrowth: number;
|
||||
};
|
||||
vendorReports: {
|
||||
totalVendors: number;
|
||||
pendingVendors: number;
|
||||
approvedVendors: number;
|
||||
rejectedVendors: number;
|
||||
};
|
||||
revenueReports: {
|
||||
totalRevenue: number;
|
||||
monthlyRevenue: number;
|
||||
revenueGrowth: number;
|
||||
topProducts: Array<{
|
||||
name: string;
|
||||
revenue: number;
|
||||
sales: number;
|
||||
}>;
|
||||
};
|
||||
systemReports: {
|
||||
uptime: number;
|
||||
responseTime: number;
|
||||
errorRate: number;
|
||||
activeSessions: number;
|
||||
};
|
||||
}
|
||||
|
||||
const AdminReports: React.FC = () => {
|
||||
const [reportData, setReportData] = useState<ReportData>({
|
||||
userReports: { totalUsers: 0, newUsers: 0, activeUsers: 0, userGrowth: 0 },
|
||||
vendorReports: { totalVendors: 0, pendingVendors: 0, approvedVendors: 0, rejectedVendors: 0 },
|
||||
revenueReports: { totalRevenue: 0, monthlyRevenue: 0, revenueGrowth: 0, topProducts: [] },
|
||||
systemReports: { uptime: 0, responseTime: 0, errorRate: 0, activeSessions: 0 }
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [reportType, setReportType] = useState('overview');
|
||||
const [dateRange, setDateRange] = useState('30d');
|
||||
|
||||
useEffect(() => {
|
||||
fetchReportData();
|
||||
}, [reportType, dateRange]);
|
||||
|
||||
const fetchReportData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Mock data for now - replace with actual API call
|
||||
const mockData: ReportData = {
|
||||
userReports: {
|
||||
totalUsers: 1247,
|
||||
newUsers: 89,
|
||||
activeUsers: 892,
|
||||
userGrowth: 12.5
|
||||
},
|
||||
vendorReports: {
|
||||
totalVendors: 156,
|
||||
pendingVendors: 23,
|
||||
approvedVendors: 128,
|
||||
rejectedVendors: 5
|
||||
},
|
||||
revenueReports: {
|
||||
totalRevenue: 284750,
|
||||
monthlyRevenue: 45600,
|
||||
revenueGrowth: 8.3,
|
||||
topProducts: [
|
||||
{ name: 'Cloud Storage Pro', revenue: 45000, sales: 156 },
|
||||
{ name: 'Database Hosting', revenue: 38000, sales: 89 },
|
||||
{ name: 'Load Balancer', revenue: 32000, sales: 67 },
|
||||
{ name: 'CDN Service', revenue: 28000, sales: 45 },
|
||||
{ name: 'Backup Solution', revenue: 25000, sales: 34 }
|
||||
]
|
||||
},
|
||||
systemReports: {
|
||||
uptime: 99.9,
|
||||
responseTime: 245,
|
||||
errorRate: 0.1,
|
||||
activeSessions: 456
|
||||
}
|
||||
};
|
||||
|
||||
setReportData(mockData);
|
||||
} catch (error) {
|
||||
console.error('Error fetching report data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const getGrowthIcon = (growth: number) => {
|
||||
return growth >= 0 ? (
|
||||
<TrendingUp className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4 text-red-600" />
|
||||
);
|
||||
};
|
||||
|
||||
const handleExportReport = (type: string) => {
|
||||
// Mock export functionality
|
||||
console.log(`Exporting ${type} report for ${dateRange}`);
|
||||
// In real implementation, this would trigger a download
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-full">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center space-y-4 lg:space-y-0">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Reports & Analytics</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">Comprehensive system reports and insights</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center space-y-2 sm:space-y-0 sm:space-x-2">
|
||||
<select
|
||||
value={reportType}
|
||||
onChange={(e) => setReportType(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md bg-white dark:bg-gray-800 dark:border-gray-600"
|
||||
>
|
||||
<option value="overview">Overview</option>
|
||||
<option value="users">User Reports</option>
|
||||
<option value="vendors">Vendor Reports</option>
|
||||
<option value="revenue">Revenue Reports</option>
|
||||
<option value="system">System Reports</option>
|
||||
</select>
|
||||
<select
|
||||
value={dateRange}
|
||||
onChange={(e) => setDateRange(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md bg-white dark:bg-gray-800 dark:border-gray-600"
|
||||
>
|
||||
<option value="7d">Last 7 days</option>
|
||||
<option value="30d">Last 30 days</option>
|
||||
<option value="90d">Last 90 days</option>
|
||||
<option value="1y">Last year</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleExportReport(reportType)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 flex items-center space-x-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
<span>Export</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview Report */}
|
||||
{reportType === 'overview' && (
|
||||
<div className="space-y-6">
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 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="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Total Users</p>
|
||||
<p className="text-2xl font-bold">{(reportData.userReports.totalUsers || 0).toLocaleString()}</p>
|
||||
<div className="flex items-center text-xs text-gray-500">
|
||||
{getGrowthIcon(reportData.userReports.userGrowth)}
|
||||
<span className="ml-1">{reportData.userReports.userGrowth}% growth</span>
|
||||
</div>
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-blue-600" />
|
||||
</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 items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Total Revenue</p>
|
||||
<p className="text-2xl font-bold">{formatCurrency(reportData.revenueReports.totalRevenue)}</p>
|
||||
<div className="flex items-center text-xs text-gray-500">
|
||||
{getGrowthIcon(reportData.revenueReports.revenueGrowth)}
|
||||
<span className="ml-1">{reportData.revenueReports.revenueGrowth}% growth</span>
|
||||
</div>
|
||||
</div>
|
||||
<DollarSign className="h-8 w-8 text-green-600" />
|
||||
</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 items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Active Vendors</p>
|
||||
<p className="text-2xl font-bold">{reportData.vendorReports.approvedVendors}</p>
|
||||
<p className="text-xs text-gray-500">{reportData.vendorReports.pendingVendors} pending</p>
|
||||
</div>
|
||||
<Building className="h-8 w-8 text-purple-600" />
|
||||
</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 items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">System Uptime</p>
|
||||
<p className="text-2xl font-bold">{reportData.systemReports.uptime}%</p>
|
||||
<p className="text-xs text-gray-500">{reportData.systemReports.activeSessions} active sessions</p>
|
||||
</div>
|
||||
<Activity className="h-8 w-8 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Products */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold flex items-center">
|
||||
<BarChart3 className="h-5 w-5 mr-2" />
|
||||
Top Performing Products
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{reportData.revenueReports.topProducts.map((product, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<span className="text-sm font-semibold text-blue-600 dark:text-blue-400">{index + 1}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{product.name}</p>
|
||||
<p className="text-sm text-gray-500">{product.sales} sales</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold">{formatCurrency(product.revenue)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Reports */}
|
||||
{reportType === 'users' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center">
|
||||
<Users className="h-5 w-5 mr-2" />
|
||||
User Statistics
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 lg:gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-blue-600">{reportData.userReports.totalUsers}</div>
|
||||
<div className="text-sm text-gray-500">Total Users</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-green-600">{reportData.userReports.newUsers}</div>
|
||||
<div className="text-sm text-gray-500">New This Month</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-purple-600">{reportData.userReports.activeUsers}</div>
|
||||
<div className="text-sm text-gray-500">Active Users</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vendor Reports */}
|
||||
{reportType === 'vendors' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center">
|
||||
<Building className="h-5 w-5 mr-2" />
|
||||
Vendor Statistics
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-blue-600">{reportData.vendorReports.totalVendors}</div>
|
||||
<div className="text-sm text-gray-500">Total Vendors</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-yellow-600">{reportData.vendorReports.pendingVendors}</div>
|
||||
<div className="text-sm text-gray-500">Pending</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-green-600">{reportData.vendorReports.approvedVendors}</div>
|
||||
<div className="text-sm text-gray-500">Approved</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-red-600">{reportData.vendorReports.rejectedVendors}</div>
|
||||
<div className="text-sm text-gray-500">Rejected</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Revenue Reports */}
|
||||
{reportType === 'revenue' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center">
|
||||
<DollarSign className="h-5 w-5 mr-2" />
|
||||
Revenue Statistics
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 lg:gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-green-600">{formatCurrency(reportData.revenueReports.totalRevenue)}</div>
|
||||
<div className="text-sm text-gray-500">Total Revenue</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-blue-600">{formatCurrency(reportData.revenueReports.monthlyRevenue)}</div>
|
||||
<div className="text-sm text-gray-500">This Month</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-purple-600">{reportData.revenueReports.revenueGrowth}%</div>
|
||||
<div className="text-sm text-gray-500">Growth Rate</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* System Reports */}
|
||||
{reportType === 'system' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center">
|
||||
<Activity className="h-5 w-5 mr-2" />
|
||||
System Health
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-green-600">{reportData.systemReports.uptime}%</div>
|
||||
<div className="text-sm text-gray-500">Uptime</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-blue-600">{reportData.systemReports.responseTime}ms</div>
|
||||
<div className="text-sm text-gray-500">Response Time</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-orange-600">{reportData.systemReports.errorRate}%</div>
|
||||
<div className="text-sm text-gray-500">Error Rate</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-purple-600">{reportData.systemReports.activeSessions}</div>
|
||||
<div className="text-sm text-gray-500">Active Sessions</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminReports;
|
||||
520
src/pages/admin/Resellers.tsx
Normal file
520
src/pages/admin/Resellers.tsx
Normal file
@ -0,0 +1,520 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Users,
|
||||
Building,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
Search,
|
||||
Filter,
|
||||
MapPin,
|
||||
Mail,
|
||||
Phone,
|
||||
Globe,
|
||||
TrendingUp,
|
||||
Calendar,
|
||||
Link
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Reseller {
|
||||
id: string;
|
||||
companyName: string;
|
||||
contactEmail: string;
|
||||
contactPhone: string;
|
||||
website: string;
|
||||
tier: string;
|
||||
status: string;
|
||||
commissionRate: number;
|
||||
channelPartnerId: string;
|
||||
channelPartner?: {
|
||||
id: string;
|
||||
companyName: string;
|
||||
status: string;
|
||||
};
|
||||
users?: Array<{
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
role: string;
|
||||
status: string;
|
||||
}>;
|
||||
createdAt: string;
|
||||
approvedAt?: string;
|
||||
}
|
||||
|
||||
const Resellers: React.FC = () => {
|
||||
const [resellers, setResellers] = useState<Reseller[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'pending' | 'suspended'>('all');
|
||||
const [vendorFilter, setVendorFilter] = useState<string>('all');
|
||||
const [vendors, setVendors] = useState<Array<{id: string, company: string}>>([]);
|
||||
const [selectedReseller, setSelectedReseller] = useState<Reseller | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingReseller, setEditingReseller] = useState<Reseller | null>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [deletingResellerId, setDeletingResellerId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchResellers();
|
||||
fetchVendors();
|
||||
}, []);
|
||||
|
||||
const fetchVendors = async () => {
|
||||
try {
|
||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/active-vendors`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setVendors(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching vendors:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchResellers = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (vendorFilter !== 'all') {
|
||||
params.append('vendor', vendorFilter);
|
||||
}
|
||||
|
||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/resellers?${params}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setResellers(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching resellers:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Refetch resellers when vendor filter changes
|
||||
useEffect(() => {
|
||||
if (vendors.length > 0) {
|
||||
fetchResellers();
|
||||
}
|
||||
}, [vendorFilter]);
|
||||
|
||||
const handleDelete = async (resellerId: string) => {
|
||||
setDeletingResellerId(resellerId);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingResellerId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/resellers/${deletingResellerId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchResellers();
|
||||
setIsDeleteModalOpen(false);
|
||||
setDeletingResellerId(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting reseller:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (resellerId: string, updateData: Partial<Reseller>) => {
|
||||
try {
|
||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/resellers/${resellerId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`
|
||||
},
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchResellers();
|
||||
setEditingReseller(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating reseller:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'text-green-600 bg-green-100 dark:bg-green-900';
|
||||
case 'pending':
|
||||
return 'text-yellow-600 bg-yellow-100 dark:bg-yellow-900';
|
||||
case 'suspended':
|
||||
return 'text-red-600 bg-red-100 dark:bg-red-900';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-100 dark:bg-gray-900';
|
||||
}
|
||||
};
|
||||
|
||||
const getTierColor = (tier: string) => {
|
||||
switch (tier) {
|
||||
case 'diamond':
|
||||
return 'text-purple-600 bg-purple-100 dark:bg-purple-900';
|
||||
case 'platinum':
|
||||
return 'text-blue-600 bg-blue-100 dark:bg-blue-900';
|
||||
case 'gold':
|
||||
return 'text-yellow-600 bg-yellow-100 dark:bg-yellow-900';
|
||||
case 'silver':
|
||||
return 'text-gray-600 bg-gray-100 dark:bg-gray-900';
|
||||
case 'bronze':
|
||||
return 'text-orange-600 bg-orange-100 dark:bg-orange-900';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-100 dark:bg-gray-900';
|
||||
}
|
||||
};
|
||||
|
||||
const filteredResellers = resellers.filter(reseller => {
|
||||
const matchesSearch = reseller.companyName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
reseller.contactEmail.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = statusFilter === 'all' || reseller.status === statusFilter;
|
||||
const matchesVendor = vendorFilter === 'all' || reseller.channelPartnerId === vendorFilter;
|
||||
return matchesSearch && matchesStatus && matchesVendor;
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-full">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Resellers
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Manage resellers who register with our vendors
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow 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 text-gray-400 w-4 h-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search resellers..."
|
||||
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-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as any)}
|
||||
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"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Vendor Filter */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Building className="w-4 h-4 text-gray-400" />
|
||||
<select
|
||||
value={vendorFilter}
|
||||
onChange={(e) => setVendorFilter(e.target.value)}
|
||||
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"
|
||||
>
|
||||
<option value="all">All Vendors</option>
|
||||
{vendors.map((vendor) => (
|
||||
<option key={vendor.id} value={vendor.id}>
|
||||
{vendor.company}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resellers Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow 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-400 uppercase tracking-wider">
|
||||
Reseller
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Contact
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Vendor
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Tier
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Commission
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Joined
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filteredResellers.map((reseller) => (
|
||||
<tr key={reseller.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-green-100 dark:bg-green-900 rounded-full flex items-center justify-center">
|
||||
<Users className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{reseller.companyName}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Reseller
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900 dark:text-white">
|
||||
<div className="flex items-center">
|
||||
<Mail className="w-4 h-4 mr-2 text-gray-400" />
|
||||
{reseller.contactEmail}
|
||||
</div>
|
||||
<div className="flex items-center mt-1">
|
||||
<Phone className="w-4 h-4 mr-2 text-gray-400" />
|
||||
{reseller.contactPhone}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<Link className="w-4 h-4 mr-2 text-gray-400" />
|
||||
<span className="text-sm text-gray-900 dark:text-white">
|
||||
{reseller.channelPartner?.companyName || 'Unknown Vendor'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<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)}`}>
|
||||
{reseller.tier?.charAt(0).toUpperCase() + reseller.tier?.slice(1) || 'Unknown'}
|
||||
</span>
|
||||
</td>
|
||||
<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)}`}>
|
||||
{reseller.status?.charAt(0).toUpperCase() + reseller.status?.slice(1) || 'Unknown'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||
{reseller.commissionRate}%
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{new Date(reseller.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedReseller(reseller);
|
||||
setShowModal(true);
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-900 dark:hover:text-blue-400"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingReseller(reseller)}
|
||||
className="text-green-600 hover:text-green-900 dark:hover:text-green-400"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(reseller.id)}
|
||||
className="text-red-600 hover:text-red-900 dark:hover:text-red-400"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reseller Details Modal */}
|
||||
{showModal && selectedReseller && (
|
||||
<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-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Reseller Details
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Company Name
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white">{selectedReseller.companyName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Contact Email
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white">{selectedReseller.contactEmail}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Contact Phone
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white">{selectedReseller.contactPhone}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Website
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white">{selectedReseller.website || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Associated Vendor
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white">{selectedReseller.channelPartner?.companyName || 'Unknown Vendor'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Tier
|
||||
</label>
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getTierColor(selectedReseller.tier)}`}>
|
||||
{selectedReseller.tier?.charAt(0).toUpperCase() + selectedReseller.tier?.slice(1) || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(selectedReseller.status)}`}>
|
||||
{selectedReseller.status?.charAt(0).toUpperCase() + selectedReseller.status?.slice(1) || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Commission Rate
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white">{selectedReseller.commissionRate}%</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Created At
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white">{new Date(selectedReseller.createdAt).toLocaleString()}</p>
|
||||
</div>
|
||||
{selectedReseller.users && selectedReseller.users.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Associated Users
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{selectedReseller.users.map((user) => (
|
||||
<div key={user.id} className="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-700 rounded">
|
||||
<span className="text-sm text-gray-900 dark:text-white">
|
||||
{user.firstName} {user.lastName} ({user.email})
|
||||
</span>
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(user.status)}`}>
|
||||
{user.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end p-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{isDeleteModalOpen && (
|
||||
<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">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Confirm Delete</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
Are you sure you want to delete this reseller? This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setDeletingResellerId(null);
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmDelete}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
Delete Reseller
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Resellers;
|
||||
617
src/pages/admin/Settings.tsx
Normal file
617
src/pages/admin/Settings.tsx
Normal file
@ -0,0 +1,617 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Settings,
|
||||
Save,
|
||||
Shield,
|
||||
Bell,
|
||||
Database,
|
||||
Globe,
|
||||
Users,
|
||||
Key,
|
||||
Eye,
|
||||
EyeOff,
|
||||
CheckCircle,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SystemSettings {
|
||||
general: {
|
||||
siteName: string;
|
||||
siteDescription: string;
|
||||
maintenanceMode: boolean;
|
||||
defaultLanguage: string;
|
||||
timezone: string;
|
||||
};
|
||||
security: {
|
||||
sessionTimeout: number;
|
||||
maxLoginAttempts: number;
|
||||
requireTwoFactor: boolean;
|
||||
passwordMinLength: number;
|
||||
enableAuditLog: boolean;
|
||||
};
|
||||
email: {
|
||||
smtpHost: string;
|
||||
smtpPort: number;
|
||||
smtpUser: string;
|
||||
smtpPassword: string;
|
||||
fromEmail: string;
|
||||
fromName: string;
|
||||
};
|
||||
notifications: {
|
||||
emailNotifications: boolean;
|
||||
pushNotifications: boolean;
|
||||
vendorApprovalAlerts: boolean;
|
||||
systemAlerts: boolean;
|
||||
reportAlerts: boolean;
|
||||
};
|
||||
integrations: {
|
||||
enableAnalytics: boolean;
|
||||
enableLogging: boolean;
|
||||
enableBackup: boolean;
|
||||
backupFrequency: string;
|
||||
};
|
||||
}
|
||||
|
||||
const AdminSettings: React.FC = () => {
|
||||
const [settings, setSettings] = useState<SystemSettings>({
|
||||
general: {
|
||||
siteName: 'Cloutopiaa Reseller Portal',
|
||||
siteDescription: 'Cloud services reseller management platform',
|
||||
maintenanceMode: false,
|
||||
defaultLanguage: 'en',
|
||||
timezone: 'UTC'
|
||||
},
|
||||
security: {
|
||||
sessionTimeout: 30,
|
||||
maxLoginAttempts: 5,
|
||||
requireTwoFactor: false,
|
||||
passwordMinLength: 8,
|
||||
enableAuditLog: true
|
||||
},
|
||||
email: {
|
||||
smtpHost: 'smtp.gmail.com',
|
||||
smtpPort: 587,
|
||||
smtpUser: '',
|
||||
smtpPassword: '',
|
||||
fromEmail: 'noreply@cloutopiaa.com',
|
||||
fromName: 'Cloutopiaa Admin'
|
||||
},
|
||||
notifications: {
|
||||
emailNotifications: true,
|
||||
pushNotifications: true,
|
||||
vendorApprovalAlerts: true,
|
||||
systemAlerts: true,
|
||||
reportAlerts: false
|
||||
},
|
||||
integrations: {
|
||||
enableAnalytics: true,
|
||||
enableLogging: true,
|
||||
enableBackup: true,
|
||||
backupFrequency: 'daily'
|
||||
}
|
||||
});
|
||||
|
||||
const [activeTab, setActiveTab] = useState('general');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle');
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Mock API call - replace with actual implementation
|
||||
// const response = await fetch('/api/admin/settings');
|
||||
// const data = await response.json();
|
||||
// setSettings(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaveStatus('saving');
|
||||
// Mock API call - replace with actual implementation
|
||||
// const response = await fetch('/api/admin/settings', {
|
||||
// method: 'PUT',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify(settings)
|
||||
// });
|
||||
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
setSaveStatus('success');
|
||||
setTimeout(() => setSaveStatus('idle'), 3000);
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
setSaveStatus('error');
|
||||
setTimeout(() => setSaveStatus('idle'), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSetting = (section: keyof SystemSettings, key: string, value: any) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
[section]: {
|
||||
...prev[section],
|
||||
[key]: value
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'general', name: 'General', icon: Settings },
|
||||
{ id: 'security', name: 'Security', icon: Shield },
|
||||
{ id: 'email', name: 'Email', icon: Bell },
|
||||
{ id: 'notifications', name: 'Notifications', icon: Bell },
|
||||
{ id: 'integrations', name: 'Integrations', icon: Database }
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-full">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center space-y-4 sm:space-y-0">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">System Settings</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">Configure system-wide settings and preferences</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saveStatus === 'saving'}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 flex items-center space-x-2"
|
||||
>
|
||||
{saveStatus === 'saving' ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
) : saveStatus === 'success' ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : saveStatus === 'error' ? (
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
<span>
|
||||
{saveStatus === 'saving' ? 'Saving...' :
|
||||
saveStatus === 'success' ? 'Saved!' :
|
||||
saveStatus === 'error' ? 'Error' : 'Save Changes'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="-mb-px flex flex-wrap space-x-4 lg:space-x-8">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center space-x-2 whitespace-nowrap ${
|
||||
activeTab === tab.id
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{tab.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
{/* General Settings */}
|
||||
{activeTab === 'general' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2" />
|
||||
General Settings
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Site Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.general.siteName}
|
||||
onChange={(e) => updateSetting('general', 'siteName', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Site Description
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.general.siteDescription}
|
||||
onChange={(e) => updateSetting('general', 'siteDescription', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Default Language
|
||||
</label>
|
||||
<select
|
||||
value={settings.general.defaultLanguage}
|
||||
onChange={(e) => updateSetting('general', 'defaultLanguage', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Spanish</option>
|
||||
<option value="fr">French</option>
|
||||
<option value="de">German</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Timezone
|
||||
</label>
|
||||
<select
|
||||
value={settings.general.timezone}
|
||||
onChange={(e) => updateSetting('general', 'timezone', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="UTC">UTC</option>
|
||||
<option value="EST">Eastern Time</option>
|
||||
<option value="PST">Pacific Time</option>
|
||||
<option value="GMT">GMT</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="maintenanceMode"
|
||||
checked={settings.general.maintenanceMode}
|
||||
onChange={(e) => updateSetting('general', 'maintenanceMode', e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="maintenanceMode" className="ml-2 block text-sm text-gray-900 dark:text-gray-300">
|
||||
Enable Maintenance Mode
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Security Settings */}
|
||||
{activeTab === 'security' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold flex items-center">
|
||||
<Shield className="h-5 w-5 mr-2" />
|
||||
Security Settings
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Session Timeout (minutes)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.security.sessionTimeout}
|
||||
onChange={(e) => updateSetting('security', 'sessionTimeout', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Max Login Attempts
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.security.maxLoginAttempts}
|
||||
onChange={(e) => updateSetting('security', 'maxLoginAttempts', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Minimum Password Length
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.security.passwordMinLength}
|
||||
onChange={(e) => updateSetting('security', 'passwordMinLength', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="requireTwoFactor"
|
||||
checked={settings.security.requireTwoFactor}
|
||||
onChange={(e) => updateSetting('security', 'requireTwoFactor', e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="requireTwoFactor" className="ml-2 block text-sm text-gray-900 dark:text-gray-300">
|
||||
Require Two-Factor Authentication
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enableAuditLog"
|
||||
checked={settings.security.enableAuditLog}
|
||||
onChange={(e) => updateSetting('security', 'enableAuditLog', e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="enableAuditLog" className="ml-2 block text-sm text-gray-900 dark:text-gray-300">
|
||||
Enable Audit Logging
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Settings */}
|
||||
{activeTab === 'email' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold flex items-center">
|
||||
<Bell className="h-5 w-5 mr-2" />
|
||||
Email Configuration
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
SMTP Host
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.email.smtpHost}
|
||||
onChange={(e) => updateSetting('email', 'smtpHost', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
SMTP Port
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.email.smtpPort}
|
||||
onChange={(e) => updateSetting('email', 'smtpPort', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
SMTP Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.email.smtpUser}
|
||||
onChange={(e) => updateSetting('email', 'smtpUser', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
SMTP Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={settings.email.smtpPassword}
|
||||
onChange={(e) => updateSetting('email', 'smtpPassword', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
From Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={settings.email.fromEmail}
|
||||
onChange={(e) => updateSetting('email', 'fromEmail', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
From Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.email.fromName}
|
||||
onChange={(e) => updateSetting('email', 'fromName', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notification Settings */}
|
||||
{activeTab === 'notifications' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold flex items-center">
|
||||
<Bell className="h-5 w-5 mr-2" />
|
||||
Notification Preferences
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="emailNotifications"
|
||||
checked={settings.notifications.emailNotifications}
|
||||
onChange={(e) => updateSetting('notifications', 'emailNotifications', e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="emailNotifications" className="ml-2 block text-sm text-gray-900 dark:text-gray-300">
|
||||
Enable Email Notifications
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="pushNotifications"
|
||||
checked={settings.notifications.pushNotifications}
|
||||
onChange={(e) => updateSetting('notifications', 'pushNotifications', e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="pushNotifications" className="ml-2 block text-sm text-gray-900 dark:text-gray-300">
|
||||
Enable Push Notifications
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="vendorApprovalAlerts"
|
||||
checked={settings.notifications.vendorApprovalAlerts}
|
||||
onChange={(e) => updateSetting('notifications', 'vendorApprovalAlerts', e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="vendorApprovalAlerts" className="ml-2 block text-sm text-gray-900 dark:text-gray-300">
|
||||
Vendor Approval Alerts
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="systemAlerts"
|
||||
checked={settings.notifications.systemAlerts}
|
||||
onChange={(e) => updateSetting('notifications', 'systemAlerts', e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="systemAlerts" className="ml-2 block text-sm text-gray-900 dark:text-gray-300">
|
||||
System Alerts
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="reportAlerts"
|
||||
checked={settings.notifications.reportAlerts}
|
||||
onChange={(e) => updateSetting('notifications', 'reportAlerts', e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="reportAlerts" className="ml-2 block text-sm text-gray-900 dark:text-gray-300">
|
||||
Report Generation Alerts
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Integration Settings */}
|
||||
{activeTab === 'integrations' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold flex items-center">
|
||||
<Database className="h-5 w-5 mr-2" />
|
||||
System Integrations
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enableAnalytics"
|
||||
checked={settings.integrations.enableAnalytics}
|
||||
onChange={(e) => updateSetting('integrations', 'enableAnalytics', e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="enableAnalytics" className="ml-2 block text-sm text-gray-900 dark:text-gray-300">
|
||||
Enable Analytics Tracking
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enableLogging"
|
||||
checked={settings.integrations.enableLogging}
|
||||
onChange={(e) => updateSetting('integrations', 'enableLogging', e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="enableLogging" className="ml-2 block text-sm text-gray-900 dark:text-gray-300">
|
||||
Enable System Logging
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enableBackup"
|
||||
checked={settings.integrations.enableBackup}
|
||||
onChange={(e) => updateSetting('integrations', 'enableBackup', e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="enableBackup" className="ml-2 block text-sm text-gray-900 dark:text-gray-300">
|
||||
Enable Automated Backups
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Backup Frequency
|
||||
</label>
|
||||
<select
|
||||
value={settings.integrations.backupFrequency}
|
||||
onChange={(e) => updateSetting('integrations', 'backupFrequency', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminSettings;
|
||||
@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAppSelector } from '../../store/hooks';
|
||||
import {
|
||||
Users,
|
||||
Clock,
|
||||
@ -11,94 +12,171 @@ import {
|
||||
Building,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin
|
||||
MapPin,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
|
||||
interface VendorRequest {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
company: string;
|
||||
role: string;
|
||||
userType: string;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
createdAt: string;
|
||||
rejectionReason?: string;
|
||||
}
|
||||
import { VendorRequest } from '../../types/vendor';
|
||||
import VendorDetailsModal from '../../components/VendorDetailsModal';
|
||||
import VendorRejectionModal from '../../components/VendorRejectionModal';
|
||||
|
||||
const VendorRequests: React.FC = () => {
|
||||
const [vendors, setVendors] = useState<VendorRequest[]>([]);
|
||||
const [vendorRequests, setVendorRequests] = useState<VendorRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'approved' | 'rejected'>('all');
|
||||
const [selectedVendor, setSelectedVendor] = useState<VendorRequest | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [rejectionReason, setRejectionReason] = useState('');
|
||||
const [showRejectionModal, setShowRejectionModal] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Get auth state from Redux
|
||||
const { isAuthenticated, token, user } = useAppSelector((state) => state.auth);
|
||||
|
||||
// Debug: Log auth state
|
||||
console.log('Auth state:', { isAuthenticated, hasToken: !!token, user });
|
||||
console.log('Modal states:', { showModal, showRejectionModal, selectedVendor: selectedVendor?.id });
|
||||
|
||||
useEffect(() => {
|
||||
fetchVendorRequests();
|
||||
}, []);
|
||||
|
||||
// Check authentication on component mount
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !token) {
|
||||
console.error('User not authenticated');
|
||||
setError('Please log in to access this page');
|
||||
}
|
||||
}, [isAuthenticated, token]);
|
||||
|
||||
const fetchVendorRequests = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/pending-vendors', {
|
||||
setLoading(true);
|
||||
|
||||
// Debug: Check if token exists
|
||||
const token = localStorage.getItem('accessToken');
|
||||
console.log('Token exists:', !!token);
|
||||
console.log('Token length:', token ? token.length : 0);
|
||||
console.log('Redux token exists:', !!token);
|
||||
|
||||
if (!token) {
|
||||
console.error('No token found in localStorage');
|
||||
setError('Authentication required. Please log in again.');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/pending-vendors`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// Debug: Log response details
|
||||
console.log('Response status:', response.status);
|
||||
console.log('Response headers:', Object.fromEntries(response.headers.entries()));
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Response data:', data);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Failed to fetch vendor requests');
|
||||
}
|
||||
|
||||
if (data.success) {
|
||||
setVendors(data.data);
|
||||
setVendorRequests(data.data.pendingRequests || []);
|
||||
} else {
|
||||
setError(data.message || 'Failed to fetch vendor requests');
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching vendor requests:', error);
|
||||
setError(error.message || 'Failed to fetch vendor requests');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = async (vendorId: string) => {
|
||||
const approveVendorRequest = async (userId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/vendors/${vendorId}/approve`, {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (!token) {
|
||||
console.error('No token found');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/vendors/${userId}/approve`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({ reason: 'Approved by admin' })
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchVendorRequests();
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
console.error('Failed to approve vendor request:', errorData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error approving vendor:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (vendorId: string, reason: string) => {
|
||||
const rejectVendorRequest = async (userId: string, reason: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/vendors/${vendorId}/reject`, {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (!token) {
|
||||
console.error('No token found');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/admin/vendors/${userId}/reject`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ reason })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchVendorRequests();
|
||||
setShowModal(false);
|
||||
setShowRejectionModal(false);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
console.error('Failed to reject vendor request:', errorData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error rejecting vendor:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredVendors = vendors.filter(vendor => {
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'text-yellow-600 bg-yellow-100 dark:bg-yellow-900';
|
||||
case 'approved':
|
||||
return 'text-green-600 bg-green-100 dark:bg-green-900';
|
||||
case 'rejected':
|
||||
return 'text-red-600 bg-red-100 dark:bg-red-900';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-100 dark:bg-gray-900';
|
||||
}
|
||||
};
|
||||
|
||||
const formatRoleName = (role: string) => {
|
||||
if (role.startsWith('channel_partner_')) {
|
||||
return 'Vendor';
|
||||
} else if (role.startsWith('reseller_')) {
|
||||
return 'Reseller';
|
||||
} else if (role.startsWith('system_')) {
|
||||
return 'System Admin';
|
||||
}
|
||||
return role.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
};
|
||||
|
||||
const filteredVendors = vendorRequests.filter(vendor => {
|
||||
const matchesSearch = vendor.firstName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
vendor.lastName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
vendor.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
@ -109,171 +187,165 @@ const VendorRequests: React.FC = () => {
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending': return 'text-amber-600 bg-amber-100 dark:text-amber-400 dark:bg-amber-900';
|
||||
case 'approved': return 'text-green-600 bg-green-100 dark:text-green-400 dark:bg-green-900';
|
||||
case 'rejected': return 'text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-900';
|
||||
default: return 'text-slate-600 bg-slate-100 dark:text-slate-400 dark:bg-slate-700';
|
||||
}
|
||||
};
|
||||
|
||||
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-blue-600"></div>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></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">
|
||||
<div className="p-6 space-y-6 max-w-full">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Vendor Requests
|
||||
</h1>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Review and manage vendor registration requests
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{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="flex items-center">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 mr-2" />
|
||||
<p className="text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<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 md:flex-row gap-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow 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-5 h-5 text-slate-400" />
|
||||
<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 vendors..."
|
||||
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-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-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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filter className="w-5 h-5 text-slate-400" />
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as any)}
|
||||
className="px-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="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"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
<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>
|
||||
|
||||
{/* Vendor List */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-lg border border-slate-200 dark:border-slate-700">
|
||||
<div className="p-6 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<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">
|
||||
Vendor Requests ({filteredVendors.length})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-50 dark:bg-slate-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<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-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Vendor
|
||||
</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-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Company
|
||||
</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-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Role
|
||||
</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-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</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-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Date
|
||||
</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-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-slate-800 divide-y divide-slate-200 dark:divide-slate-700">
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filteredVendors.map((vendor) => (
|
||||
<tr key={vendor.id} className="hover:bg-slate-50 dark:hover:bg-slate-700">
|
||||
<tr key={vendor.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-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<Users className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-white">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{vendor.firstName} {vendor.lastName}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{vendor.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<Building className="w-4 h-4 text-slate-400 mr-2" />
|
||||
<span className="text-sm text-slate-900 dark:text-white">
|
||||
{vendor.company}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-900 dark:text-white">{vendor.company}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm text-slate-900 dark:text-white">
|
||||
{vendor.role.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</span>
|
||||
<div className="text-sm text-gray-900 dark:text-white">
|
||||
{formatRoleName(vendor.role)}
|
||||
</div>
|
||||
</td>
|
||||
<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)}`}>
|
||||
{vendor.status.charAt(0).toUpperCase() + vendor.status.slice(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-500 dark:text-slate-400">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-2" />
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{new Date(vendor.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setSelectedVendor(vendor)}
|
||||
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"
|
||||
onClick={() => {
|
||||
console.log('View button clicked for vendor:', vendor);
|
||||
setSelectedVendor(vendor);
|
||||
setShowModal(true);
|
||||
console.log('Modal state set to true');
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-900 dark:hover:text-blue-400"
|
||||
>
|
||||
<Eye className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
{vendor.status === 'pending' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleApprove(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"
|
||||
title="Approve"
|
||||
onClick={() => approveVendorRequest(vendor.id)}
|
||||
className="text-green-600 hover:text-green-900 dark:hover:text-green-400"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedVendor(vendor);
|
||||
setShowModal(true);
|
||||
setShowRejectionModal(true);
|
||||
}}
|
||||
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"
|
||||
className="text-red-600 hover:text-red-900 dark:hover:text-red-400"
|
||||
>
|
||||
<XCircle className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||
<XCircle className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@ -286,148 +358,21 @@ const VendorRequests: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vendor Details Modal */}
|
||||
{selectedVendor && (
|
||||
<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-xl p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-xl font-semibold text-slate-900 dark:text-white">
|
||||
Vendor Details
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setSelectedVendor(null)}
|
||||
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div 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-slate-700 dark:text-slate-300 mb-1">
|
||||
Full Name
|
||||
</label>
|
||||
<p className="text-slate-900 dark:text-white">
|
||||
{selectedVendor.firstName} {selectedVendor.lastName}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<Mail className="w-4 h-4 text-slate-400 mr-2" />
|
||||
<p className="text-slate-900 dark:text-white">{selectedVendor.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
Phone
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<Phone className="w-4 h-4 text-slate-400 mr-2" />
|
||||
<p className="text-slate-900 dark:text-white">{selectedVendor.phone}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
Company
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<Building className="w-4 h-4 text-slate-400 mr-2" />
|
||||
<p className="text-slate-900 dark:text-white">{selectedVendor.company}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
Role
|
||||
</label>
|
||||
<p className="text-slate-900 dark:text-white">
|
||||
{selectedVendor.role.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
User Type
|
||||
</label>
|
||||
<p className="text-slate-900 dark:text-white">
|
||||
{selectedVendor.userType.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedVendor.rejectionReason && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
Rejection Reason
|
||||
</label>
|
||||
<p className="text-red-600 dark:text-red-400">{selectedVendor.rejectionReason}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t border-slate-200 dark:border-slate-700">
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Registered on {new Date(selectedVendor.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
{selectedVendor.status === 'pending' && (
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleApprove(selectedVendor.id)}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rejection Modal */}
|
||||
{showModal && selectedVendor && (
|
||||
<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-xl p-6 max-w-md w-full mx-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
|
||||
Reject Vendor Request
|
||||
</h3>
|
||||
<textarea
|
||||
placeholder="Enter rejection reason..."
|
||||
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 focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
rows={4}
|
||||
id="rejectionReason"
|
||||
{/* Shared Vendor Modals */}
|
||||
<VendorDetailsModal
|
||||
vendor={selectedVendor}
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
onApprove={approveVendorRequest}
|
||||
onReject={rejectVendorRequest}
|
||||
/>
|
||||
|
||||
<VendorRejectionModal
|
||||
vendor={selectedVendor}
|
||||
isOpen={showRejectionModal}
|
||||
onClose={() => setShowRejectionModal(false)}
|
||||
onReject={rejectVendorRequest}
|
||||
/>
|
||||
<div className="flex space-x-2 mt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
const reason = (document.getElementById('rejectionReason') as HTMLTextAreaElement).value;
|
||||
if (reason.trim()) {
|
||||
handleReject(selectedVendor.id, reason);
|
||||
}
|
||||
}}
|
||||
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="flex-1 px-4 py-2 bg-slate-300 dark:bg-slate-600 text-slate-700 dark:text-slate-300 rounded-lg hover:bg-slate-400 dark:hover:bg-slate-500 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Cloud,
|
||||
Package,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
@ -13,160 +13,248 @@ import {
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle
|
||||
AlertTriangle,
|
||||
Eye,
|
||||
ShoppingCart,
|
||||
Star,
|
||||
Tag
|
||||
} from 'lucide-react';
|
||||
import { apiService, Product } from '../../services/api';
|
||||
|
||||
interface Instance {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
status: 'running' | 'stopped' | 'starting' | 'stopping' | 'error';
|
||||
region: string;
|
||||
cpu: string;
|
||||
memory: string;
|
||||
storage: string;
|
||||
ipAddress: string;
|
||||
customer: string;
|
||||
monthlyCost: number;
|
||||
createdAt: string;
|
||||
lastStarted: string;
|
||||
interface AvailableProduct extends Product {
|
||||
vendorName?: string;
|
||||
vendorCompany?: string;
|
||||
stockAvailable?: number;
|
||||
commissionEarned?: number;
|
||||
}
|
||||
|
||||
const mockInstances: Instance[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'web-server-01',
|
||||
type: 't3.medium',
|
||||
status: 'running',
|
||||
region: 'us-east-1',
|
||||
cpu: '2 vCPU',
|
||||
memory: '4 GB',
|
||||
storage: '20 GB SSD',
|
||||
ipAddress: '192.168.1.100',
|
||||
customer: 'TechCorp Solutions',
|
||||
monthlyCost: 35.50,
|
||||
createdAt: '2024-12-01T00:00:00Z',
|
||||
lastStarted: '2025-01-15T08:00:00Z'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'db-server-01',
|
||||
type: 't3.large',
|
||||
status: 'running',
|
||||
region: 'us-east-1',
|
||||
cpu: '2 vCPU',
|
||||
memory: '8 GB',
|
||||
storage: '100 GB SSD',
|
||||
ipAddress: '192.168.1.101',
|
||||
customer: 'DataFlow Inc',
|
||||
monthlyCost: 70.25,
|
||||
createdAt: '2024-11-15T00:00:00Z',
|
||||
lastStarted: '2025-01-14T06:30:00Z'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'app-server-01',
|
||||
type: 't3.small',
|
||||
status: 'stopped',
|
||||
region: 'us-west-2',
|
||||
cpu: '2 vCPU',
|
||||
memory: '2 GB',
|
||||
storage: '20 GB SSD',
|
||||
ipAddress: '192.168.1.102',
|
||||
customer: 'CloudTech Ltd',
|
||||
monthlyCost: 17.75,
|
||||
createdAt: '2024-10-20T00:00:00Z',
|
||||
lastStarted: '2025-01-10T14:20:00Z'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'cache-server-01',
|
||||
type: 't3.micro',
|
||||
status: 'running',
|
||||
region: 'us-east-1',
|
||||
cpu: '2 vCPU',
|
||||
memory: '1 GB',
|
||||
storage: '8 GB SSD',
|
||||
ipAddress: '192.168.1.103',
|
||||
customer: 'InnovateSoft',
|
||||
monthlyCost: 8.90,
|
||||
createdAt: '2024-12-10T00:00:00Z',
|
||||
lastStarted: '2025-01-15T09:15:00Z'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'backup-server-01',
|
||||
type: 't3.medium',
|
||||
status: 'error',
|
||||
region: 'us-west-2',
|
||||
cpu: '2 vCPU',
|
||||
memory: '4 GB',
|
||||
storage: '500 GB SSD',
|
||||
ipAddress: '192.168.1.104',
|
||||
customer: 'NetSolutions',
|
||||
monthlyCost: 45.00,
|
||||
createdAt: '2024-09-05T00:00:00Z',
|
||||
lastStarted: '2025-01-12T22:45:00Z'
|
||||
}
|
||||
];
|
||||
|
||||
const Instances: React.FC = () => {
|
||||
const [instances, setInstances] = useState<Instance[]>(mockInstances);
|
||||
const AvailableProducts: React.FC = () => {
|
||||
const [products, setProducts] = useState<AvailableProduct[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [regionFilter, setRegionFilter] = useState<string>('all');
|
||||
const [priceFilter, setPriceFilter] = useState<string>('all');
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(12);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
|
||||
const filteredInstances = instances.filter(instance => {
|
||||
const matchesSearch = instance.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
instance.customer.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
instance.ipAddress.includes(searchTerm);
|
||||
const matchesStatus = statusFilter === 'all' || instance.status === statusFilter;
|
||||
const matchesRegion = regionFilter === 'all' || instance.region === regionFilter;
|
||||
return matchesSearch && matchesStatus && matchesRegion;
|
||||
// Mock data for demonstration - in real app this would come from API
|
||||
const mockProducts: AvailableProduct[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'AWS EC2 t3.medium Instance',
|
||||
description: 'High-performance cloud computing instance with 2 vCPUs and 4GB RAM',
|
||||
category: 'cloud_computing',
|
||||
price: 35.50,
|
||||
currency: 'USD',
|
||||
commissionRate: 15,
|
||||
status: 'active',
|
||||
availability: 'available',
|
||||
stockQuantity: 100,
|
||||
sku: 'AWS-EC2-T3M',
|
||||
vendorName: 'John Smith',
|
||||
vendorCompany: 'CloudTech Solutions',
|
||||
stockAvailable: 100,
|
||||
commissionEarned: 5.33,
|
||||
createdBy: 1,
|
||||
createdAt: '2024-12-01T00:00:00Z',
|
||||
updatedAt: '2024-12-01T00:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Google Cloud Storage 100GB',
|
||||
description: 'Scalable cloud storage solution with high availability and redundancy',
|
||||
category: 'cloud_storage',
|
||||
price: 20.00,
|
||||
currency: 'USD',
|
||||
commissionRate: 12,
|
||||
status: 'active',
|
||||
availability: 'available',
|
||||
stockQuantity: 500,
|
||||
sku: 'GCS-100GB',
|
||||
vendorName: 'Sarah Johnson',
|
||||
vendorCompany: 'DataFlow Inc',
|
||||
stockAvailable: 500,
|
||||
commissionEarned: 2.40,
|
||||
createdBy: 1,
|
||||
createdAt: '2024-11-15T00:00:00Z',
|
||||
updatedAt: '2024-11-15T00:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Microsoft Azure Security Center',
|
||||
description: 'Advanced threat protection and security management for cloud workloads',
|
||||
category: 'cybersecurity',
|
||||
price: 150.00,
|
||||
currency: 'USD',
|
||||
commissionRate: 18,
|
||||
status: 'active',
|
||||
availability: 'available',
|
||||
stockQuantity: 50,
|
||||
sku: 'AZURE-SEC',
|
||||
vendorName: 'Mike Chen',
|
||||
vendorCompany: 'SecureNet Pro',
|
||||
stockAvailable: 50,
|
||||
commissionEarned: 27.00,
|
||||
createdBy: 1,
|
||||
createdAt: '2024-10-20T00:00:00Z',
|
||||
updatedAt: '2024-10-20T00:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'IBM Watson AI Platform',
|
||||
description: 'Enterprise AI and machine learning platform with cognitive services',
|
||||
category: 'ai_ml',
|
||||
price: 500.00,
|
||||
currency: 'USD',
|
||||
commissionRate: 20,
|
||||
status: 'active',
|
||||
availability: 'available',
|
||||
stockQuantity: 25,
|
||||
sku: 'IBM-WATSON',
|
||||
vendorName: 'Lisa Wang',
|
||||
vendorCompany: 'AI Innovations',
|
||||
stockAvailable: 25,
|
||||
commissionEarned: 100.00,
|
||||
createdBy: 1,
|
||||
createdAt: '2024-12-10T00:00:00Z',
|
||||
updatedAt: '2024-12-10T00:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Oracle Database Cloud Service',
|
||||
description: 'Fully managed Oracle database service with automated backups',
|
||||
category: 'data_analytics',
|
||||
price: 300.00,
|
||||
currency: 'USD',
|
||||
commissionRate: 16,
|
||||
status: 'active',
|
||||
availability: 'available',
|
||||
stockQuantity: 30,
|
||||
sku: 'ORACLE-DB',
|
||||
vendorName: 'David Brown',
|
||||
vendorCompany: 'Database Experts',
|
||||
stockAvailable: 30,
|
||||
commissionEarned: 48.00,
|
||||
createdBy: 1,
|
||||
createdAt: '2024-09-05T00:00:00Z',
|
||||
updatedAt: '2024-09-05T00:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Cisco IoT Gateway',
|
||||
description: 'Industrial IoT gateway for edge computing and device management',
|
||||
category: 'iot',
|
||||
price: 250.00,
|
||||
currency: 'USD',
|
||||
commissionRate: 14,
|
||||
status: 'active',
|
||||
availability: 'available',
|
||||
stockQuantity: 40,
|
||||
sku: 'CISCO-IOT',
|
||||
vendorName: 'Alex Rodriguez',
|
||||
vendorCompany: 'IoT Solutions',
|
||||
stockAvailable: 40,
|
||||
commissionEarned: 35.00,
|
||||
createdBy: 1,
|
||||
createdAt: '2024-11-20T00:00:00Z',
|
||||
updatedAt: '2024-11-20T00:00:00Z'
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
// In real app, fetch products from API
|
||||
// fetchProducts();
|
||||
setProducts(mockProducts);
|
||||
setTotalItems(mockProducts.length);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const categories = [
|
||||
{ value: 'cloud_storage', label: 'Cloud Storage' },
|
||||
{ value: 'cloud_computing', label: 'Cloud Computing' },
|
||||
{ value: 'cybersecurity', label: 'Cybersecurity' },
|
||||
{ value: 'data_analytics', label: 'Data Analytics' },
|
||||
{ value: 'ai_ml', label: 'AI & Machine Learning' },
|
||||
{ value: 'iot', label: 'Internet of Things' },
|
||||
{ value: 'blockchain', label: 'Blockchain' },
|
||||
{ value: 'other', label: 'Other' }
|
||||
];
|
||||
|
||||
const priceRanges = [
|
||||
{ value: 'all', label: 'All Prices' },
|
||||
{ value: '0-50', label: '$0 - $50' },
|
||||
{ value: '51-100', label: '$51 - $100' },
|
||||
{ value: '101-200', label: '$101 - $200' },
|
||||
{ value: '201-500', label: '$201 - $500' },
|
||||
{ value: '500+', label: '$500+' }
|
||||
];
|
||||
|
||||
const filteredProducts = products.filter(product => {
|
||||
const matchesSearch = product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
product.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
product.sku.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesCategory = categoryFilter === 'all' || product.category === categoryFilter;
|
||||
const matchesStatus = statusFilter === 'all' || product.status === statusFilter;
|
||||
|
||||
let matchesPrice = true;
|
||||
if (priceFilter !== 'all') {
|
||||
const [min, max] = priceFilter.split('-').map(Number);
|
||||
if (max) {
|
||||
matchesPrice = product.price >= min && product.price <= max;
|
||||
} else {
|
||||
matchesPrice = product.price >= min;
|
||||
}
|
||||
}
|
||||
|
||||
return matchesSearch && matchesCategory && matchesStatus && matchesPrice;
|
||||
});
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'bg-success-100 text-success-800 dark:bg-success-900 dark:text-success-300';
|
||||
case 'stopped':
|
||||
return 'bg-secondary-100 text-secondary-800 dark:bg-secondary-900 dark:text-secondary-300';
|
||||
case 'starting':
|
||||
case 'stopping':
|
||||
return 'bg-warning-100 text-warning-800 dark:bg-warning-900 dark:text-warning-300';
|
||||
case 'error':
|
||||
return 'bg-danger-100 text-danger-800 dark:bg-danger-900 dark:text-danger-300';
|
||||
const getCategoryColor = (category: string) => {
|
||||
switch (category) {
|
||||
case 'cloud_storage':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300';
|
||||
case 'cloud_computing':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
|
||||
case 'cybersecurity':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300';
|
||||
case 'data_analytics':
|
||||
return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300';
|
||||
case 'ai_ml':
|
||||
return 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300';
|
||||
case 'iot':
|
||||
return 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300';
|
||||
case 'blockchain':
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300';
|
||||
default:
|
||||
return 'bg-secondary-100 text-secondary-800 dark:bg-secondary-900 dark:text-secondary-300';
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return <CheckCircle className="w-4 h-4" />;
|
||||
case 'stopped':
|
||||
return <XCircle className="w-4 h-4" />;
|
||||
case 'starting':
|
||||
case 'stopping':
|
||||
return <Clock className="w-4 h-4" />;
|
||||
case 'error':
|
||||
return <AlertTriangle className="w-4 h-4" />;
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch (category) {
|
||||
case 'cloud_storage':
|
||||
return <Database className="w-4 h-4" />;
|
||||
case 'cloud_computing':
|
||||
return <Server className="w-4 h-4" />;
|
||||
case 'cybersecurity':
|
||||
return <Shield className="w-4 h-4" />;
|
||||
case 'data_analytics':
|
||||
return <Globe className="w-4 h-4" />;
|
||||
case 'ai_ml':
|
||||
return <Zap className="w-4 h-4" />;
|
||||
case 'iot':
|
||||
return <Package className="w-4 h-4" />;
|
||||
case 'blockchain':
|
||||
return <Tag className="w-4 h-4" />;
|
||||
default:
|
||||
return <Clock className="w-4 h-4" />;
|
||||
return <Package className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
@ -176,7 +264,51 @@ const Instances: React.FC = () => {
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const totalMonthlyCost = instances.reduce((sum, instance) => sum + instance.monthlyCost, 0);
|
||||
const totalProducts = products.length;
|
||||
const totalValue = products.reduce((sum, product) => sum + product.price, 0);
|
||||
const totalCommission = products.reduce((sum, product) => sum + (product.price * product.commissionRate / 100), 0);
|
||||
const availableCategories = new Set(products.map(p => p.category)).size;
|
||||
|
||||
const handleViewProduct = (product: AvailableProduct) => {
|
||||
// Handle viewing product details
|
||||
console.log('Viewing product:', product);
|
||||
};
|
||||
|
||||
const handleAddToCart = (product: AvailableProduct) => {
|
||||
// Handle adding product to cart
|
||||
console.log('Adding to cart:', product);
|
||||
};
|
||||
|
||||
const handleContactVendor = (product: AvailableProduct) => {
|
||||
// Handle contacting vendor
|
||||
console.log('Contacting vendor for:', product);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<AlertTriangle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Error loading products
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@ -184,16 +316,34 @@ const Instances: React.FC = () => {
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between space-y-4 sm:space-y-0">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-secondary-900 dark:text-white">
|
||||
Cloud Instances
|
||||
Available Products
|
||||
</h1>
|
||||
<p className="text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
Manage your cloud infrastructure and instances
|
||||
Browse and manage products from your assigned vendor
|
||||
</p>
|
||||
</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">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Instance
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
|
||||
className="btn btn-outline btn-sm"
|
||||
>
|
||||
{viewMode === 'grid' ? (
|
||||
<>
|
||||
<Package className="w-4 h-4 mr-2" />
|
||||
Grid View
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Server className="w-4 h-4 mr-2" />
|
||||
List View
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button className="btn btn-primary btn-sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Request Product
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
@ -202,14 +352,14 @@ const Instances: React.FC = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-secondary-600 dark:text-secondary-400">
|
||||
Total Instances
|
||||
Total Products
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
{instances.length}
|
||||
{totalProducts}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center">
|
||||
<Server className="w-6 h-6 text-primary-600 dark:text-primary-400" />
|
||||
<Package className="w-6 h-6 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -218,14 +368,14 @@ const Instances: React.FC = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-secondary-600 dark:text-secondary-400">
|
||||
Running Instances
|
||||
Total Value
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
{instances.filter(i => i.status === 'running').length}
|
||||
{formatCurrency(totalValue)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-success-100 dark:bg-success-900 rounded-full flex items-center justify-center">
|
||||
<CheckCircle className="w-6 h-6 text-success-600 dark:text-success-400" />
|
||||
<Zap className="w-6 h-6 text-success-600 dark:text-success-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -234,14 +384,14 @@ const Instances: React.FC = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-secondary-600 dark:text-secondary-400">
|
||||
Monthly Cost
|
||||
Commission Potential
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
{formatCurrency(totalMonthlyCost)}
|
||||
{formatCurrency(totalCommission)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-warning-100 dark:bg-warning-900 rounded-full flex items-center justify-center">
|
||||
<Zap className="w-6 h-6 text-warning-600 dark:text-warning-400" />
|
||||
<Star className="w-6 h-6 text-warning-600 dark:text-warning-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -250,14 +400,14 @@ const Instances: React.FC = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-secondary-600 dark:text-secondary-400">
|
||||
Active Customers
|
||||
Categories
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
{new Set(instances.map(i => i.customer)).size}
|
||||
{availableCategories}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-secondary-100 dark:bg-secondary-900 rounded-full flex items-center justify-center">
|
||||
<Globe className="w-6 h-6 text-secondary-600 dark:text-secondary-400" />
|
||||
<Tag className="w-6 h-6 text-secondary-600 dark:text-secondary-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -271,7 +421,7 @@ const Instances: React.FC = () => {
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-secondary-400 w-4 h-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search instances..."
|
||||
placeholder="Search products by name, description, or SKU..."
|
||||
value={searchTerm}
|
||||
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"
|
||||
@ -280,132 +430,246 @@ const Instances: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(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"
|
||||
>
|
||||
<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="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"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="stopped">Stopped</option>
|
||||
<option value="starting">Starting</option>
|
||||
<option value="stopping">Stopping</option>
|
||||
<option value="error">Error</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="draft">Draft</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={regionFilter}
|
||||
onChange={(e) => setRegionFilter(e.target.value)}
|
||||
value={priceFilter}
|
||||
onChange={(e) => setPriceFilter(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"
|
||||
>
|
||||
<option value="all">All Regions</option>
|
||||
<option value="us-east-1">US East (N. Virginia)</option>
|
||||
<option value="us-west-2">US West (Oregon)</option>
|
||||
<option value="eu-west-1">Europe (Ireland)</option>
|
||||
<option value="ap-southeast-1">Asia Pacific (Singapore)</option>
|
||||
{priceRanges.map(range => (
|
||||
<option key={range.value} value={range.value}>
|
||||
{range.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instances Grid */}
|
||||
{/* Products Display */}
|
||||
{viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-6">
|
||||
{filteredInstances.map((instance) => (
|
||||
<div key={instance.id} className="card p-4 sm:p-6 hover:shadow-lg transition-shadow duration-200">
|
||||
{filteredProducts.map((product) => (
|
||||
<div key={product.id} className="card p-4 sm:p-6 hover:shadow-lg transition-shadow duration-200">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getCategoryColor(product.category)}`}>
|
||||
{getCategoryIcon(product.category)}
|
||||
<span className="ml-1 capitalize">
|
||||
{categories.find(c => c.value === product.category)?.label}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-1">
|
||||
{instance.name}
|
||||
{product.name}
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
{instance.customer}
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-2">
|
||||
{product.description}
|
||||
</p>
|
||||
<div className="flex items-center space-x-4 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
<span>SKU: {product.sku}</span>
|
||||
<span>Stock: {product.stockAvailable}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className="text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300">
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-secondary-600 dark:text-secondary-400">Status</span>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(instance.status)}`}>
|
||||
{getStatusIcon(instance.status)}
|
||||
<span className="ml-1 capitalize">{instance.status}</span>
|
||||
<span className="text-sm text-secondary-600 dark:text-secondary-400">Vendor</span>
|
||||
<span className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{product.vendorCompany}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-secondary-600 dark:text-secondary-400">Type</span>
|
||||
<span className="text-sm font-medium text-secondary-900 dark:text-white">{instance.type}</span>
|
||||
<span className="text-sm text-secondary-600 dark:text-secondary-400">Commission Rate</span>
|
||||
<span className="text-sm font-medium text-success-600 dark:text-success-400">
|
||||
{product.commissionRate}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-secondary-600 dark:text-secondary-400">Region</span>
|
||||
<span className="text-sm font-medium text-secondary-900 dark:text-white">{instance.region}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-secondary-600 dark:text-secondary-400">IP Address</span>
|
||||
<span className="text-sm font-mono text-secondary-900 dark:text-white">{instance.ipAddress}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div className="text-center p-2 bg-secondary-50 dark:bg-secondary-800 rounded">
|
||||
<div className="font-medium text-secondary-900 dark:text-white">{instance.cpu}</div>
|
||||
<div className="text-secondary-500 dark:text-secondary-400">CPU</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-secondary-50 dark:bg-secondary-800 rounded">
|
||||
<div className="font-medium text-secondary-900 dark:text-white">{instance.memory}</div>
|
||||
<div className="text-secondary-500 dark:text-secondary-400">Memory</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-secondary-50 dark:bg-secondary-800 rounded">
|
||||
<div className="font-medium text-secondary-900 dark:text-white">{instance.storage}</div>
|
||||
<div className="text-secondary-500 dark:text-secondary-400">Storage</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<span className="text-sm text-secondary-600 dark:text-secondary-400">Monthly Cost</span>
|
||||
<span className="text-lg font-bold text-secondary-900 dark:text-white">
|
||||
{formatCurrency(instance.monthlyCost)}
|
||||
{formatCurrency(product.price)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-secondary-500 dark:text-secondary-400">
|
||||
<span>Created: {formatDate(instance.createdAt)}</span>
|
||||
<span>Last: {formatDate(instance.lastStarted)}</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-secondary-600 dark:text-secondary-400">Commission Earned</span>
|
||||
<span className="text-sm font-bold text-success-600 dark:text-success-400">
|
||||
{formatCurrency(product.commissionEarned || 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button className="flex-1 px-3 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-lg transition-colors duration-200">
|
||||
Manage
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleViewProduct(product)}
|
||||
className="flex-1 px-3 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-lg transition-colors duration-200"
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
View Details
|
||||
</button>
|
||||
<button className="px-3 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-300 border border-secondary-300 dark:border-secondary-600 hover:bg-secondary-50 dark:hover:bg-secondary-800 rounded-lg transition-colors duration-200">
|
||||
<Shield className="w-4 h-4" />
|
||||
<button
|
||||
onClick={() => handleAddToCart(product)}
|
||||
className="px-3 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-300 border border-secondary-300 dark:border-secondary-600 hover:bg-secondary-50 dark:hover:bg-secondary-800 rounded-lg transition-colors duration-200"
|
||||
>
|
||||
<ShoppingCart className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-800">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
||||
Product
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
||||
Category
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
||||
Vendor
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
||||
Price
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
||||
Commission
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
||||
Stock
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-900 divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
{filteredProducts.map((product) => (
|
||||
<tr key={product.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-800">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{product.name}
|
||||
</div>
|
||||
<div className="text-sm text-secondary-500 dark:text-secondary-400">
|
||||
{product.sku}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<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 ${getCategoryColor(product.category)}`}>
|
||||
{getCategoryIcon(product.category)}
|
||||
<span className="ml-1 capitalize">
|
||||
{categories.find(c => c.value === product.category)?.label}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-secondary-900 dark:text-white">
|
||||
{product.vendorCompany}
|
||||
</div>
|
||||
<div className="text-sm text-secondary-500 dark:text-secondary-400">
|
||||
{product.vendorName}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{formatCurrency(product.price)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-success-600 dark:text-success-400">
|
||||
{product.commissionRate}%
|
||||
</div>
|
||||
<div className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
{formatCurrency(product.commissionEarned || 0)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-secondary-900 dark:text-white">
|
||||
{product.stockAvailable}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleViewProduct(product)}
|
||||
className="text-primary-600 hover:text-primary-900 dark:hover:text-primary-400"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAddToCart(product)}
|
||||
className="text-secondary-600 hover:text-secondary-900 dark:hover:text-secondary-400"
|
||||
>
|
||||
<ShoppingCart className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleContactVendor(product)}
|
||||
className="text-warning-600 hover:text-warning-900 dark:hover:text-warning-400"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{filteredInstances.length === 0 && (
|
||||
{filteredProducts.length === 0 && (
|
||||
<div className="card p-12 text-center">
|
||||
<Cloud className="w-16 h-16 text-secondary-400 mx-auto mb-4" />
|
||||
<Package className="w-16 h-16 text-secondary-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-2">
|
||||
No instances found
|
||||
No products found
|
||||
</h3>
|
||||
<p className="text-secondary-600 dark:text-secondary-400 mb-4">
|
||||
{searchTerm || statusFilter !== 'all' || regionFilter !== 'all'
|
||||
{searchTerm || categoryFilter !== 'all' || statusFilter !== 'all' || priceFilter !== 'all'
|
||||
? 'Try adjusting your filters or search terms.'
|
||||
: 'Get started by creating your first cloud instance.'
|
||||
: 'No products are currently available from your assigned vendor.'
|
||||
}
|
||||
</p>
|
||||
<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">
|
||||
<button className="btn btn-primary">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Instance
|
||||
Request Product
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@ -413,4 +677,4 @@ const Instances: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Instances;
|
||||
export default AvailableProducts;
|
||||
@ -18,6 +18,7 @@ import { useAppSelector } from '../../store/hooks';
|
||||
import { RootState } from '../../store';
|
||||
import { toggleTheme } from '../../store/slices/themeSlice';
|
||||
import { cn } from '../../utils/cn';
|
||||
import BlockedAccountModal from '../../components/BlockedAccountModal';
|
||||
|
||||
const ResellerLogin: React.FC = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
@ -26,6 +27,9 @@ const ResellerLogin: React.FC = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [showInactiveModal, setShowInactiveModal] = useState(false);
|
||||
const [inactiveUserEmail, setInactiveUserEmail] = useState('');
|
||||
const [inactiveUserStatus, setInactiveUserStatus] = useState('');
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@ -75,8 +79,25 @@ const ResellerLogin: React.FC = () => {
|
||||
navigate(redirectPath, { replace: true });
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.message || 'Invalid email or password. Please try again.';
|
||||
|
||||
// Check if the error is related to account status issues
|
||||
if (errorMessage.includes('Account access blocked') || errorMessage.includes('Status: inactive') || errorMessage.includes('Status: suspended') || errorMessage.includes('Status: pending')) {
|
||||
// Extract status from error message
|
||||
let status = 'inactive';
|
||||
if (errorMessage.includes('Status: suspended')) {
|
||||
status = 'suspended';
|
||||
} else if (errorMessage.includes('Status: pending')) {
|
||||
status = 'pending';
|
||||
}
|
||||
|
||||
setInactiveUserEmail(email);
|
||||
setInactiveUserStatus(status);
|
||||
setShowInactiveModal(true);
|
||||
setError('');
|
||||
} else {
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@ -223,30 +244,7 @@ const ResellerLogin: React.FC = () => {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-slate-300 dark:border-slate-600"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social Login Buttons */}
|
||||
<div className="space-y-3">
|
||||
<button className="w-full flex items-center justify-center px-4 py-3 border border-slate-300 dark:border-slate-600 rounded-xl font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-700 hover:bg-slate-50 dark:hover:bg-slate-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 transition-all duration-200">
|
||||
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
Continue with Google
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sign Up Link */}
|
||||
<div className="mt-6 text-center">
|
||||
@ -282,6 +280,14 @@ const ResellerLogin: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Blocked Account Modal */}
|
||||
<BlockedAccountModal
|
||||
isOpen={showInactiveModal}
|
||||
onClose={() => setShowInactiveModal(false)}
|
||||
userEmail={inactiveUserEmail}
|
||||
userStatus={inactiveUserStatus}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
827
src/pages/reseller/Receipts.tsx
Normal file
827
src/pages/reseller/Receipts.tsx
Normal file
@ -0,0 +1,827 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '../../store/hooks';
|
||||
import {
|
||||
FileText,
|
||||
Upload,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Eye,
|
||||
Download,
|
||||
Trash2,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
User,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
MoreVertical,
|
||||
RefreshCw
|
||||
} from 'lucide-react';
|
||||
import { Receipt, ReceiptUploadData, ReceiptFilters } from '../../types/receipt';
|
||||
import {
|
||||
fetchReceipts,
|
||||
uploadReceipt,
|
||||
deleteReceipt,
|
||||
downloadReceipt,
|
||||
setFilters,
|
||||
clearFilters,
|
||||
setSelectedReceipt,
|
||||
clearError,
|
||||
selectReceipts,
|
||||
selectReceiptPagination,
|
||||
selectReceiptFilters,
|
||||
selectReceiptsLoading,
|
||||
selectReceiptsUploading,
|
||||
selectReceiptsError,
|
||||
selectReceiptStats
|
||||
} from '../../store/slices/receiptSlice';
|
||||
import toast from 'react-hot-toast';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
const Receipts: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { user } = useAppSelector((state) => state.auth);
|
||||
|
||||
// Redux selectors
|
||||
const receipts = useAppSelector(selectReceipts);
|
||||
const pagination = useAppSelector(selectReceiptPagination);
|
||||
const filters = useAppSelector(selectReceiptFilters);
|
||||
const isLoading = useAppSelector(selectReceiptsLoading);
|
||||
const isUploading = useAppSelector(selectReceiptsUploading);
|
||||
const error = useAppSelector(selectReceiptsError);
|
||||
const stats = useAppSelector(selectReceiptStats);
|
||||
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
const [selectedReceipt, setSelectedReceipt] = useState<Receipt | null>(null);
|
||||
const [uploadForm, setUploadForm] = useState<ReceiptUploadData>({
|
||||
orderId: 0,
|
||||
clientName: '',
|
||||
clientEmail: '',
|
||||
clientPhone: '',
|
||||
clientAddress: '',
|
||||
saleAmount: 0,
|
||||
currency: 'USD',
|
||||
saleDate: new Date().toISOString().split('T')[0],
|
||||
paymentMethod: '',
|
||||
description: '',
|
||||
receiptFile: null
|
||||
});
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [deletingReceiptId, setDeletingReceiptId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchReceipts({}));
|
||||
}, [dispatch]);
|
||||
|
||||
// Clear error when component unmounts or error changes
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast.error(error);
|
||||
dispatch(clearError());
|
||||
}
|
||||
}, [error, dispatch]);
|
||||
|
||||
const handleUpload = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!uploadForm.receiptFile) {
|
||||
toast.error('Please select a receipt file');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
|
||||
// Append all form fields
|
||||
Object.entries(uploadForm).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
const result = await dispatch(uploadReceipt(formData)).unwrap();
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Receipt uploaded successfully');
|
||||
setShowUploadModal(false);
|
||||
resetUploadForm();
|
||||
dispatch(fetchReceipts({}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading receipt:', error);
|
||||
toast.error('Failed to upload receipt');
|
||||
}
|
||||
};
|
||||
|
||||
const resetUploadForm = () => {
|
||||
setUploadForm({
|
||||
orderId: 0,
|
||||
clientName: '',
|
||||
clientEmail: '',
|
||||
clientPhone: '',
|
||||
clientAddress: '',
|
||||
saleAmount: 0,
|
||||
currency: 'USD',
|
||||
saleDate: new Date().toISOString().split('T')[0],
|
||||
paymentMethod: '',
|
||||
description: '',
|
||||
receiptFile: null
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setUploadForm(prev => ({ ...prev, receiptFile: file }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const searchTerm = e.target.value;
|
||||
dispatch(setFilters({ search: searchTerm, page: 1 }));
|
||||
};
|
||||
|
||||
const handleDeleteReceipt = async (receiptId: number) => {
|
||||
setDeletingReceiptId(receiptId);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingReceiptId) return;
|
||||
|
||||
try {
|
||||
const result = await dispatch(deleteReceipt(deletingReceiptId)).unwrap();
|
||||
if (result.success) {
|
||||
toast.success('Receipt deleted successfully');
|
||||
dispatch(fetchReceipts({}));
|
||||
setIsDeleteModalOpen(false);
|
||||
setDeletingReceiptId(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting receipt:', error);
|
||||
toast.error('Failed to delete receipt');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadReceipt = async (receiptId: number) => {
|
||||
try {
|
||||
const result = await dispatch(downloadReceipt(receiptId)).unwrap();
|
||||
const url = window.URL.createObjectURL(result.blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `receipt-${receiptId}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error('Error downloading receipt:', error);
|
||||
toast.error('Failed to download receipt');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||
case 'rejected':
|
||||
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';
|
||||
case 'disputed':
|
||||
return 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return <CheckCircle className="w-4 h-4" />;
|
||||
case 'rejected':
|
||||
return <XCircle className="w-4 h-4" />;
|
||||
case 'pending':
|
||||
return <Clock className="w-4 h-4" />;
|
||||
case 'disputed':
|
||||
return <AlertCircle className="w-4 h-4" />;
|
||||
default:
|
||||
return <Clock className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
Receipt Management
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Upload and manage your sales receipts for commission tracking
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<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-sm 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">Total Receipts</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stats.totalReceipts}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-100 dark:bg-blue-900 rounded-lg">
|
||||
<FileText 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-sm 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">Pending Approval</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{stats.pendingApproval}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
|
||||
<Clock 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-sm 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">Approved</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{stats.approved}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-green-100 dark:bg-green-900 rounded-lg">
|
||||
<CheckCircle 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-sm 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">Total Sales</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
${stats.totalSales.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-emerald-100 dark:bg-emerald-900 rounded-lg">
|
||||
<DollarSign className="w-6 h-6 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions Bar */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-sm border border-gray-200 dark:border-gray-700 mb-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row gap-4 flex-1">
|
||||
<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 receipts..."
|
||||
value={filters.search || ''}
|
||||
onChange={handleSearch}
|
||||
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>
|
||||
|
||||
<select
|
||||
value={filters.status || ''}
|
||||
onChange={(e) => dispatch(setFilters({ status: e.target.value, page: 1 }))}
|
||||
className="px-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-emerald-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
<option value="disputed">Disputed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
disabled={isUploading}
|
||||
className="inline-flex items-center gap-2 px-6 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4" />
|
||||
Upload Receipt
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Receipts Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center">
|
||||
<RefreshCw className="w-8 h-8 text-gray-400 animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400">Loading receipts...</p>
|
||||
</div>
|
||||
) : receipts.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<FileText 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 receipts found</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
Get started by uploading your first receipt
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Upload Receipt
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<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-400 uppercase tracking-wider">
|
||||
Receipt
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Client
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Amount
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{receipts.map((receipt) => (
|
||||
<tr key={receipt.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="flex-shrink-0 h-10 w-10">
|
||||
<div className="h-10 w-10 rounded-lg bg-emerald-100 dark:bg-emerald-900 flex items-center justify-center">
|
||||
<FileText className="w-5 h-5 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{receipt.receiptNumber}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Order #{receipt.orderId}
|
||||
</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">
|
||||
{receipt.clientName}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{receipt.clientEmail}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
${receipt.saleAmount.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{receipt.currency}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900 dark:text-white">
|
||||
{new Date(receipt.saleDate).toLocaleDateString()}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full",
|
||||
getStatusColor(receipt.status)
|
||||
)}>
|
||||
{getStatusIcon(receipt.status)}
|
||||
{receipt.status.charAt(0).toUpperCase() + receipt.status.slice(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedReceipt(receipt)}
|
||||
className="text-emerald-600 hover:text-emerald-900 dark:text-emerald-400 dark:hover:text-emerald-300"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDownloadReceipt(receipt.id)}
|
||||
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteReceipt(receipt.id)}
|
||||
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-700 dark:text-gray-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 gap-2">
|
||||
<button
|
||||
onClick={() => dispatch(setFilters({ page: pagination.currentPage - 1 }))}
|
||||
disabled={pagination.currentPage === 1}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => dispatch(setFilters({ page: pagination.currentPage + 1 }))}
|
||||
disabled={pagination.currentPage === pagination.totalPages}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload Modal */}
|
||||
{showUploadModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Upload New Receipt
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowUploadModal(false)}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<XCircle className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleUpload} 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-gray-700 dark:text-gray-300 mb-2">
|
||||
Order ID
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={uploadForm.orderId || ''}
|
||||
onChange={(e) => setUploadForm(prev => ({ ...prev, orderId: parseInt(e.target.value) || 0 }))}
|
||||
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-emerald-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Client Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={uploadForm.clientName}
|
||||
onChange={(e) => setUploadForm(prev => ({ ...prev, clientName: 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-emerald-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Client Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={uploadForm.clientEmail}
|
||||
onChange={(e) => setUploadForm(prev => ({ ...prev, clientEmail: 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-emerald-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Client Phone
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={uploadForm.clientPhone}
|
||||
onChange={(e) => setUploadForm(prev => ({ ...prev, clientPhone: 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-emerald-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Sale Amount
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={uploadForm.saleAmount || ''}
|
||||
onChange={(e) => setUploadForm(prev => ({ ...prev, saleAmount: parseFloat(e.target.value) || 0 }))}
|
||||
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-emerald-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Currency
|
||||
</label>
|
||||
<select
|
||||
value={uploadForm.currency}
|
||||
onChange={(e) => setUploadForm(prev => ({ ...prev, currency: 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-emerald-500 focus:border-transparent"
|
||||
>
|
||||
<option value="USD">USD</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="GBP">GBP</option>
|
||||
<option value="INR">INR</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Sale Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={uploadForm.saleDate}
|
||||
onChange={(e) => setUploadForm(prev => ({ ...prev, saleDate: 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-emerald-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Payment Method
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={uploadForm.paymentMethod}
|
||||
onChange={(e) => setUploadForm(prev => ({ ...prev, paymentMethod: 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-emerald-500 focus:border-transparent"
|
||||
placeholder="e.g., Credit Card, Bank Transfer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Client Address
|
||||
</label>
|
||||
<textarea
|
||||
value={uploadForm.clientAddress}
|
||||
onChange={(e) => setUploadForm(prev => ({ ...prev, clientAddress: 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-emerald-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={uploadForm.description}
|
||||
onChange={(e) => setUploadForm(prev => ({ ...prev, 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-emerald-500 focus:border-transparent"
|
||||
placeholder="Additional details about the sale..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Receipt File
|
||||
</label>
|
||||
<div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-6 text-center">
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleFileChange}
|
||||
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx"
|
||||
className="hidden"
|
||||
id="receiptFile"
|
||||
required
|
||||
/>
|
||||
<label htmlFor="receiptFile" className="cursor-pointer">
|
||||
<Upload className="w-8 h-8 text-gray-400 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{uploadForm.receiptFile ? uploadForm.receiptFile.name : 'Click to upload or drag and drop'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
PDF, JPG, PNG, DOC, DOCX (Max 10MB)
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUploadModal(false)}
|
||||
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isUploading}
|
||||
className="px-6 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isUploading ? 'Uploading...' : 'Upload Receipt'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Receipt Detail Modal */}
|
||||
{selectedReceipt && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Receipt Details
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setSelectedReceipt(null)}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<XCircle className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400">Receipt Number</label>
|
||||
<p className="text-sm text-gray-900 dark:text-white">{selectedReceipt.receiptNumber}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400">Order ID</label>
|
||||
<p className="text-sm text-gray-900 dark:text-white">{selectedReceipt.orderId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400">Client Name</label>
|
||||
<p className="text-sm text-gray-900 dark:text-white">{selectedReceipt.clientName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400">Sale Amount</label>
|
||||
<p className="text-sm text-gray-900 dark:text-white">
|
||||
${selectedReceipt.saleAmount.toLocaleString()} {selectedReceipt.currency}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400">Sale Date</label>
|
||||
<p className="text-sm text-gray-900 dark:text-white">
|
||||
{new Date(selectedReceipt.saleDate).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400">Status</label>
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full",
|
||||
getStatusColor(selectedReceipt.status)
|
||||
)}>
|
||||
{getStatusIcon(selectedReceipt.status)}
|
||||
{selectedReceipt.status.charAt(0).toUpperCase() + selectedReceipt.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedReceipt.clientEmail && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400">Client Email</label>
|
||||
<p className="text-sm text-gray-900 dark:text-white">{selectedReceipt.clientEmail}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedReceipt.clientPhone && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400">Client Phone</label>
|
||||
<p className="text-sm text-gray-900 dark:text-white">{selectedReceipt.clientPhone}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedReceipt.clientAddress && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400">Client Address</label>
|
||||
<p className="text-sm text-gray-900 dark:text-white">{selectedReceipt.clientAddress}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedReceipt.description && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400">Description</label>
|
||||
<p className="text-sm text-gray-900 dark:text-white">{selectedReceipt.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedReceipt.rejectionReason && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400">Rejection Reason</label>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{selectedReceipt.rejectionReason}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-4 flex gap-3">
|
||||
<button
|
||||
onClick={() => handleDownloadReceipt(selectedReceipt.id)}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4 inline mr-2" />
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedReceipt(null)}
|
||||
className="flex-1 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{isDeleteModalOpen && (
|
||||
<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">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Confirm Delete</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
Are you sure you want to delete this receipt? This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setDeletingReceiptId(null);
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmDelete}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
Delete Receipt
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Receipts;
|
||||
File diff suppressed because it is too large
Load Diff
@ -13,6 +13,19 @@ export interface RegisterRequest {
|
||||
lastName: string;
|
||||
phone?: string;
|
||||
company?: string;
|
||||
// Vendor-specific fields
|
||||
companyType?: 'corporation' | 'llc' | 'partnership' | 'sole_proprietorship' | 'other';
|
||||
registrationNumber?: string;
|
||||
gstNumber?: string;
|
||||
panNumber?: string;
|
||||
address?: string;
|
||||
website?: string;
|
||||
businessLicense?: string;
|
||||
taxId?: string;
|
||||
industry?: string;
|
||||
yearsInBusiness?: string | number;
|
||||
annualRevenue?: string | number;
|
||||
employeeCount?: string | number;
|
||||
role?: 'channel_partner_admin' | 'channel_partner_manager' | 'channel_partner_sales' | 'channel_partner_support' | 'channel_partner_finance' | 'channel_partner_analyst' | 'reseller_admin' | 'reseller_manager' | 'reseller_sales' | 'reseller_support' | 'reseller_finance' | 'reseller_analyst' | 'system_admin' | 'system_support' | 'system_analyst' | 'read_only';
|
||||
userType?: 'channel_partner' | 'reseller' | 'system';
|
||||
}
|
||||
@ -66,6 +79,127 @@ export interface User {
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
category: 'cloud_storage' | 'cloud_computing' | 'cybersecurity' | 'data_analytics' | 'ai_ml' | 'iot' | 'blockchain' | 'other';
|
||||
subcategory?: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
commissionRate: number;
|
||||
features?: string[];
|
||||
specifications?: Record<string, any>;
|
||||
images?: string[];
|
||||
documents?: string[];
|
||||
status: 'draft' | 'active' | 'inactive' | 'discontinued';
|
||||
availability: 'available' | 'out_of_stock' | 'coming_soon' | 'discontinued';
|
||||
stockQuantity: number;
|
||||
sku: string;
|
||||
tags?: string[];
|
||||
metadata?: Record<string, any>;
|
||||
createdBy: number;
|
||||
updatedBy?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
creator?: {
|
||||
id: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
company?: string;
|
||||
};
|
||||
updater?: {
|
||||
id: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
};
|
||||
vendor?: {
|
||||
id: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
company?: string;
|
||||
};
|
||||
isAdminCreated?: boolean;
|
||||
source?: 'admin' | 'vendor';
|
||||
purchaseUrl?: string;
|
||||
}
|
||||
|
||||
export interface TrainingCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TrainingVideo {
|
||||
id: number;
|
||||
moduleId: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
youtubeUrl?: string;
|
||||
duration?: string;
|
||||
thumbnail?: string;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TrainingMaterial {
|
||||
id: number;
|
||||
moduleId: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
type: 'PDF' | 'PPT' | 'DOC' | 'VIDEO';
|
||||
downloadUrl?: string;
|
||||
fileSize?: string;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TrainingModule {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
duration?: string;
|
||||
level: 'Beginner' | 'Intermediate' | 'Advanced';
|
||||
categoryId?: number;
|
||||
thumbnailUrl?: string;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
createdBy?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
category?: TrainingCategory;
|
||||
videos?: TrainingVideo[];
|
||||
materials?: TrainingMaterial[];
|
||||
userProgress?: {
|
||||
status: 'not_started' | 'in_progress' | 'completed';
|
||||
progressPercentage: number;
|
||||
timeSpent: number;
|
||||
completedAt?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TrainingProgress {
|
||||
moduleId: number;
|
||||
videoId?: number;
|
||||
materialId?: number;
|
||||
status: 'not_started' | 'in_progress' | 'completed';
|
||||
progressPercentage: number;
|
||||
timeSpent: number;
|
||||
}
|
||||
|
||||
class ApiService {
|
||||
private baseURL: string;
|
||||
|
||||
@ -101,6 +235,14 @@ class ApiService {
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
// Handle account status errors
|
||||
if (response.status === 403 && data.errorCode) {
|
||||
const error = new Error(data.message || 'Account is not active');
|
||||
(error as any).errorCode = data.errorCode;
|
||||
(error as any).status = data.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error(data.message || 'API request failed');
|
||||
}
|
||||
|
||||
@ -207,6 +349,271 @@ class ApiService {
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
});
|
||||
}
|
||||
|
||||
// Vendor operations
|
||||
async getAvailableVendorCompanies(): Promise<{ success: boolean; data: Array<{ id: number; company: string; firstName: string; lastName: string; email: string }> }> {
|
||||
return this.request<{ success: boolean; data: Array<{ id: number; company: string; firstName: string; lastName: string; email: string }> }>('/public/vendors/available-companies');
|
||||
}
|
||||
|
||||
// Reseller operations
|
||||
async getResellerUserTypes(): Promise<{ success: boolean; data: Array<{ value: string; label: string; description: string; permissions: string[] }> }> {
|
||||
return this.request<{ success: boolean; data: Array<{ value: string; label: string; description: string; permissions: string[] }> }>('/public/reseller/user-types');
|
||||
}
|
||||
|
||||
async getPendingResellerRequests(): Promise<{ success: boolean; data: User[] }> {
|
||||
return this.request<{ success: boolean; data: User[] }>('/vendors/pending-resellers');
|
||||
}
|
||||
|
||||
async getVendorResellers(): Promise<{ success: boolean; data: User[] }> {
|
||||
return this.request<{ success: boolean; data: User[] }>('/vendors/resellers');
|
||||
}
|
||||
|
||||
async createReseller(resellerData: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
company: string;
|
||||
userType: 'reseller_admin' | 'reseller_sales' | 'reseller_support' | 'read_only';
|
||||
region: string;
|
||||
businessType: string;
|
||||
address?: string;
|
||||
}): Promise<{ success: boolean; message: string; data: any }> {
|
||||
return this.request<{ success: boolean; message: string; data: any }>('/vendors/resellers', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(resellerData),
|
||||
});
|
||||
}
|
||||
|
||||
async getVendorDashboardStats(): Promise<{ success: boolean; data: any }> {
|
||||
return this.request<{ success: boolean; data: any }>('/vendors/dashboard/stats');
|
||||
}
|
||||
|
||||
async getVendorProducts(params?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
category?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
}): Promise<{ success: boolean; data: { products: Product[]; pagination: any } }> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) queryParams.append(key, value.toString());
|
||||
});
|
||||
}
|
||||
return this.request<{ success: boolean; data: { products: Product[]; pagination: any } }>(`/vendors/products?${queryParams}`);
|
||||
}
|
||||
|
||||
async getVendorCommissions(params?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
status?: string;
|
||||
dateRange?: string;
|
||||
}): Promise<{ success: boolean; data: { commissions: any[]; pagination: any } }> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) queryParams.append(key, value.toString());
|
||||
});
|
||||
}
|
||||
return this.request<{ success: boolean; data: { commissions: any[]; pagination: any } }>(`/vendors/commissions?${queryParams}`);
|
||||
}
|
||||
|
||||
async approveResellerRequest(userId: number): Promise<{ success: boolean; message: string }> {
|
||||
return this.request<{ success: boolean; message: string }>(`/vendors/resellers/${userId}/approve`, {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
async rejectResellerRequest(userId: number, reason: string): Promise<{ success: boolean; message: string }> {
|
||||
return this.request<{ success: boolean; message: string }>(`/vendors/resellers/${userId}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason })
|
||||
});
|
||||
}
|
||||
|
||||
// Product management
|
||||
async getAllProducts(params?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
category?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
vendor?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
}): Promise<{ success: boolean; data: { products: Product[]; pagination: any } }> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) queryParams.append(key, value.toString());
|
||||
});
|
||||
}
|
||||
return this.request<{ success: boolean; data: { products: Product[]; pagination: any } }>(`/products?${queryParams}`);
|
||||
}
|
||||
|
||||
async getProductById(id: number): Promise<{ success: boolean; data: Product }> {
|
||||
return this.request<{ success: boolean; data: Product }>(`/products/${id}`);
|
||||
}
|
||||
|
||||
async createProduct(productData: Partial<Product>): Promise<{ success: boolean; data: Product }> {
|
||||
return this.request<{ success: boolean; data: Product }>('/products', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(productData),
|
||||
});
|
||||
}
|
||||
|
||||
async updateProduct(id: number, productData: Partial<Product>): Promise<{ success: boolean; data: Product }> {
|
||||
return this.request<{ success: boolean; data: Product }>(`/products/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(productData),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteProduct(id: number): Promise<{ success: boolean; message: string }> {
|
||||
return this.request<{ success: boolean; message: string }>(`/products/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async getProductCategories(): Promise<{ success: boolean; data: string[] }> {
|
||||
return this.request<{ success: boolean; data: string[] }>('/products/categories');
|
||||
}
|
||||
|
||||
async getProductStats(): Promise<{ success: boolean; data: any }> {
|
||||
return this.request<{ success: boolean; data: any }>('/products/stats');
|
||||
}
|
||||
|
||||
async getActiveVendors(): Promise<{ success: boolean; data: Array<{ id: number; firstName: string; lastName: string; company?: string }> }> {
|
||||
return this.request<{ success: boolean; data: Array<{ id: number; firstName: string; lastName: string; company?: string }> }>('/products/vendors');
|
||||
}
|
||||
|
||||
async getVendorById(id: number): Promise<{ success: boolean; data: { id: number; firstName: string; lastName: string; email: string; company?: string } }> {
|
||||
return this.request<{ success: boolean; data: { id: number; firstName: string; lastName: string; email: string; company?: string } }>(`/vendors/${id}`);
|
||||
}
|
||||
|
||||
// Receipt management
|
||||
async uploadReceipt(data: FormData): Promise<{ success: boolean; data: any; message: string }> {
|
||||
return this.request<{ success: boolean; data: any; message: string }>('/receipts/upload', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
async getResellerReceipts(params?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
status?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<{ success: boolean; data: any[]; pagination?: any }> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) queryParams.append(key, value.toString());
|
||||
});
|
||||
}
|
||||
return this.request<{ success: boolean; data: any[]; pagination?: any }>(`/receipts/reseller?${queryParams}`);
|
||||
}
|
||||
|
||||
async getVendorReceipts(params?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
status?: string;
|
||||
resellerId?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<{ success: boolean; data: any[]; pagination?: any }> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) queryParams.append(key, value.toString());
|
||||
});
|
||||
}
|
||||
return this.request<{ success: boolean; data: any[]; pagination?: any }>(`/receipts/vendor?${queryParams}`);
|
||||
}
|
||||
|
||||
async getReceiptById(id: number): Promise<{ success: boolean; data: any }> {
|
||||
return this.request<{ success: boolean; data: any }>(`/receipts/reseller/${id}`);
|
||||
}
|
||||
|
||||
async getVendorReceiptById(id: number): Promise<{ success: boolean; data: any }> {
|
||||
return this.request<{ success: boolean; data: any }>(`/receipts/vendor/${id}`);
|
||||
}
|
||||
|
||||
async updateReceiptStatus(id: number, statusUpdate: { status: string; rejectionReason?: string }): Promise<{ success: boolean; data: any; message: string }> {
|
||||
return this.request<{ success: boolean; data: any; message: string }>(`/receipts/vendor/${id}/status`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(statusUpdate),
|
||||
});
|
||||
}
|
||||
|
||||
async downloadReceipt(id: number): Promise<Blob> {
|
||||
const response = await fetch(`${this.baseURL}/receipts/${id}/download`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
async deleteReceipt(id: number): Promise<{ success: boolean; message: string }> {
|
||||
return this.request<{ success: boolean; message: string }>(`/receipts/reseller/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// Get vendor products with current stock quantities
|
||||
async getVendorProductsWithStock(params?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
category?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
}): Promise<{ success: boolean; data: { products: any[]; pagination: any } }> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) queryParams.append(key, value.toString());
|
||||
});
|
||||
}
|
||||
return this.request<{ success: boolean; data: { products: any[]; pagination: any } }>(`/receipts/vendor/products/stock?${queryParams}`);
|
||||
}
|
||||
|
||||
// Get products shared by vendor for reseller with current stock
|
||||
async getResellerVendorProducts(params: {
|
||||
vendorId: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
category?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
}): Promise<{ success: boolean; data: { products: any[]; pagination: any } }> {
|
||||
const queryParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) queryParams.append(key, value.toString());
|
||||
});
|
||||
return this.request<{ success: boolean; data: { products: any[]; pagination: any } }>(`/receipts/reseller/vendor/products?${queryParams}`);
|
||||
}
|
||||
|
||||
// Manually update product stock quantity
|
||||
async updateProductStock(productId: number, stockQuantity: number): Promise<{ success: boolean; data: any; message: string }> {
|
||||
return this.request<{ success: boolean; data: any; message: string }>(`/receipts/vendor/products/${productId}/stock`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ stockQuantity }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const apiService = new ApiService();
|
||||
|
||||
187
src/services/socketService.ts
Normal file
187
src/services/socketService.ts
Normal file
@ -0,0 +1,187 @@
|
||||
import io from 'socket.io-client';
|
||||
|
||||
class SocketService {
|
||||
private socket: any = null;
|
||||
private isConnected = false;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 5;
|
||||
private reconnectDelay = 1000;
|
||||
|
||||
// Event listeners
|
||||
private listeners: Map<string, Function[]> = new Map();
|
||||
|
||||
connect(token: string) {
|
||||
if (this.socket && this.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.socket = io(process.env.REACT_APP_SOCKET_URL || 'http://localhost:5000', {
|
||||
auth: {
|
||||
token
|
||||
},
|
||||
transports: ['websocket', 'polling'],
|
||||
timeout: 20000,
|
||||
reconnection: true,
|
||||
reconnectionAttempts: this.maxReconnectAttempts,
|
||||
reconnectionDelay: this.reconnectDelay
|
||||
});
|
||||
|
||||
this.setupEventListeners();
|
||||
} catch (error) {
|
||||
console.error('Socket connection error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private setupEventListeners() {
|
||||
if (!this.socket) return;
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('Socket connected');
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.emit('socket_connected', { timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', (reason: string) => {
|
||||
console.log('Socket disconnected:', reason);
|
||||
this.isConnected = false;
|
||||
|
||||
if (reason === 'io server disconnect') {
|
||||
// Server disconnected us, try to reconnect
|
||||
this.socket?.connect();
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on('connect_error', (error: Error) => {
|
||||
console.error('Socket connection error:', error);
|
||||
this.reconnectAttempts++;
|
||||
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('Max reconnection attempts reached');
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on('reconnect', (attemptNumber: number) => {
|
||||
console.log('Socket reconnected after', attemptNumber, 'attempts');
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
});
|
||||
|
||||
this.socket.on('reconnect_error', (error: Error) => {
|
||||
console.error('Socket reconnection error:', error);
|
||||
});
|
||||
|
||||
// Handle notification events
|
||||
this.socket.on('NEW_VENDOR_REQUEST', (data: any) => {
|
||||
this.triggerListeners('NEW_VENDOR_REQUEST', data);
|
||||
});
|
||||
|
||||
this.socket.on('NEW_RESELLER_REQUEST', (data: any) => {
|
||||
this.triggerListeners('NEW_RESELLER_REQUEST', data);
|
||||
});
|
||||
|
||||
this.socket.on('VENDOR_APPROVED', (data: any) => {
|
||||
this.triggerListeners('VENDOR_APPROVED', data);
|
||||
});
|
||||
|
||||
this.socket.on('VENDOR_REJECTED', (data: any) => {
|
||||
this.triggerListeners('VENDOR_REJECTED', data);
|
||||
});
|
||||
|
||||
this.socket.on('RESELLER_CREATED', (data: any) => {
|
||||
this.triggerListeners('RESELLER_CREATED', data);
|
||||
});
|
||||
|
||||
this.socket.on('RESELLER_ACCOUNT_CREATED', (data: any) => {
|
||||
this.triggerListeners('RESELLER_ACCOUNT_CREATED', data);
|
||||
});
|
||||
|
||||
this.socket.on('SYSTEM_ALERT', (data: any) => {
|
||||
this.triggerListeners('SYSTEM_ALERT', data);
|
||||
});
|
||||
|
||||
// Handle general notifications
|
||||
this.socket.on('notification', (data: any) => {
|
||||
this.triggerListeners('notification', data);
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.socket) {
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
this.isConnected = false;
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
emit(event: string, data: any) {
|
||||
if (this.socket && this.isConnected) {
|
||||
this.socket.emit(event, data);
|
||||
} else {
|
||||
console.warn('Socket not connected, cannot emit event:', event);
|
||||
}
|
||||
}
|
||||
|
||||
on(event: string, callback: Function) {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, []);
|
||||
}
|
||||
this.listeners.get(event)?.push(callback);
|
||||
}
|
||||
|
||||
off(event: string, callback?: Function) {
|
||||
if (!callback) {
|
||||
this.listeners.delete(event);
|
||||
} else {
|
||||
const callbacks = this.listeners.get(event);
|
||||
if (callbacks) {
|
||||
const index = callbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
callbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private triggerListeners(event: string, data: any) {
|
||||
const callbacks = this.listeners.get(event);
|
||||
if (callbacks) {
|
||||
callbacks.forEach(callback => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error('Error in socket event listener:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
isSocketConnected(): boolean {
|
||||
return this.isConnected;
|
||||
}
|
||||
|
||||
// Join specific rooms
|
||||
joinRoom(room: string) {
|
||||
this.emit('join-room', room);
|
||||
}
|
||||
|
||||
leaveRoom(room: string) {
|
||||
this.emit('leave-room', room);
|
||||
}
|
||||
|
||||
// Get connection status
|
||||
getConnectionStatus() {
|
||||
return {
|
||||
isConnected: this.isConnected,
|
||||
reconnectAttempts: this.reconnectAttempts,
|
||||
maxReconnectAttempts: this.maxReconnectAttempts
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const socketService = new SocketService();
|
||||
|
||||
export default socketService;
|
||||
@ -3,6 +3,8 @@ import themeReducer from './slices/themeSlice';
|
||||
import authReducer from './slices/authSlice';
|
||||
import dashboardReducer from './slices/dashboardSlice';
|
||||
import resellerDashboardReducer from './reseller/dashboardSlice';
|
||||
import productReducer from './slices/productSlice';
|
||||
import receiptReducer from './slices/receiptSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
@ -10,6 +12,8 @@ export const store = configureStore({
|
||||
auth: authReducer,
|
||||
dashboard: dashboardReducer,
|
||||
resellerDashboard: resellerDashboardReducer,
|
||||
product: productReducer,
|
||||
receipts: receiptReducer,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -13,12 +13,12 @@ interface AuthState {
|
||||
|
||||
const initialState: AuthState = {
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isAuthenticated: !!localStorage.getItem('accessToken'),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
sessionId: null,
|
||||
token: localStorage.getItem('accessToken'),
|
||||
refreshToken: localStorage.getItem('refreshToken'),
|
||||
sessionId: localStorage.getItem('sessionId'),
|
||||
};
|
||||
|
||||
const authSlice = createSlice({
|
||||
@ -38,6 +38,11 @@ const authSlice = createSlice({
|
||||
state.sessionId = action.payload.sessionId;
|
||||
state.isAuthenticated = true;
|
||||
state.error = null;
|
||||
state.isLoading = false;
|
||||
// Store tokens in localStorage
|
||||
localStorage.setItem('accessToken', action.payload.token);
|
||||
localStorage.setItem('refreshToken', action.payload.refreshToken);
|
||||
localStorage.setItem('sessionId', action.payload.sessionId);
|
||||
},
|
||||
logout: (state) => {
|
||||
state.user = null;
|
||||
@ -46,6 +51,11 @@ const authSlice = createSlice({
|
||||
state.sessionId = null;
|
||||
state.isAuthenticated = false;
|
||||
state.error = null;
|
||||
state.isLoading = false;
|
||||
// Clear localStorage
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
localStorage.removeItem('sessionId');
|
||||
},
|
||||
updateUser: (state, action: PayloadAction<Partial<User>>) => {
|
||||
if (state.user) {
|
||||
@ -56,10 +66,21 @@ const authSlice = createSlice({
|
||||
state.token = action.payload.token;
|
||||
state.refreshToken = action.payload.refreshToken;
|
||||
state.sessionId = action.payload.sessionId;
|
||||
// Set authenticated to true when tokens are set
|
||||
state.isAuthenticated = true;
|
||||
// Store tokens in localStorage
|
||||
localStorage.setItem('accessToken', action.payload.token);
|
||||
localStorage.setItem('refreshToken', action.payload.refreshToken);
|
||||
localStorage.setItem('sessionId', action.payload.sessionId);
|
||||
},
|
||||
setUser: (state, action: PayloadAction<User>) => {
|
||||
state.user = action.payload;
|
||||
state.isAuthenticated = true;
|
||||
state.error = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setLoading, setError, loginSuccess, logout, updateUser, setTokens } = authSlice.actions;
|
||||
export const { setLoading, setError, loginSuccess, logout, updateUser, setTokens, setUser } = authSlice.actions;
|
||||
export type { User };
|
||||
export default authSlice.reducer;
|
||||
@ -1,6 +1,6 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { apiService, LoginRequest, RegisterRequest } from '../../services/api';
|
||||
import { setLoading, setError, loginSuccess, logout, setTokens } from './authSlice';
|
||||
import { setLoading, setError, loginSuccess, logout, setTokens, setUser } from './authSlice';
|
||||
|
||||
export const loginUser = createAsyncThunk(
|
||||
'auth/login',
|
||||
@ -12,12 +12,13 @@ export const loginUser = createAsyncThunk(
|
||||
const response = await apiService.login(credentials);
|
||||
|
||||
if (response.success && response.data) {
|
||||
// Store tokens in localStorage
|
||||
localStorage.setItem('accessToken', response.data.accessToken);
|
||||
localStorage.setItem('refreshToken', response.data.refreshToken);
|
||||
localStorage.setItem('sessionId', response.data.sessionId);
|
||||
// Check if user account is blocked
|
||||
const userStatus = response.data.user.status;
|
||||
if (['inactive', 'pending', 'suspended'].includes(userStatus)) {
|
||||
throw new Error(`Account access blocked. Status: ${userStatus}. Please contact your vendor administrator.`);
|
||||
}
|
||||
|
||||
// Dispatch login success
|
||||
// Dispatch login success (tokens will be stored in the slice)
|
||||
dispatch(loginSuccess({
|
||||
user: response.data.user,
|
||||
token: response.data.accessToken,
|
||||
@ -122,19 +123,11 @@ export const logoutUser = createAsyncThunk(
|
||||
await apiService.logout(sessionId);
|
||||
}
|
||||
|
||||
// Clear localStorage
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
localStorage.removeItem('sessionId');
|
||||
|
||||
// Dispatch logout
|
||||
// Dispatch logout (localStorage will be cleared in the slice)
|
||||
dispatch(logout());
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
// Still logout locally even if API call fails
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
localStorage.removeItem('sessionId');
|
||||
dispatch(logout());
|
||||
}
|
||||
}
|
||||
@ -150,6 +143,15 @@ export const getCurrentUser = createAsyncThunk(
|
||||
const response = await apiService.getCurrentUser();
|
||||
|
||||
if (response.success) {
|
||||
// Check if user account is blocked
|
||||
const userStatus = response.data.status;
|
||||
if (['inactive', 'pending', 'suspended'].includes(userStatus)) {
|
||||
// Force logout if account is blocked
|
||||
dispatch(logout());
|
||||
throw new Error(`Account access blocked. Status: ${userStatus}. Please contact your vendor administrator.`);
|
||||
}
|
||||
|
||||
dispatch(setUser(response.data));
|
||||
return response.data;
|
||||
} else {
|
||||
throw new Error('Failed to get current user');
|
||||
@ -178,12 +180,17 @@ export const refreshUserToken = createAsyncThunk(
|
||||
const response = await apiService.refreshToken(refreshToken);
|
||||
|
||||
if (response.success && response.data) {
|
||||
// Update tokens in localStorage
|
||||
localStorage.setItem('accessToken', response.data.accessToken);
|
||||
localStorage.setItem('refreshToken', response.data.refreshToken);
|
||||
localStorage.setItem('sessionId', response.data.sessionId);
|
||||
// Check if user account is blocked (if user data is included in refresh response)
|
||||
if (response.data.user) {
|
||||
const userStatus = response.data.user.status;
|
||||
if (['inactive', 'pending', 'suspended'].includes(userStatus)) {
|
||||
// Force logout if account is blocked
|
||||
dispatch(logout());
|
||||
throw new Error(`Account access blocked. Status: ${userStatus}. Please contact your vendor administrator.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update tokens in store
|
||||
// Update tokens in store (localStorage will be updated in the slice)
|
||||
dispatch(setTokens({
|
||||
token: response.data.accessToken,
|
||||
refreshToken: response.data.refreshToken,
|
||||
|
||||
117
src/store/slices/productSlice.ts
Normal file
117
src/store/slices/productSlice.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { Product } from '../../services/api';
|
||||
|
||||
interface ProductState {
|
||||
products: Product[];
|
||||
currentProduct: Product | null;
|
||||
categories: string[];
|
||||
stats: any;
|
||||
pagination: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
itemsPerPage: number;
|
||||
};
|
||||
filters: {
|
||||
category: string;
|
||||
status: string;
|
||||
search: string;
|
||||
sortBy: string;
|
||||
sortOrder: string;
|
||||
};
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: ProductState = {
|
||||
products: [],
|
||||
currentProduct: null,
|
||||
categories: [],
|
||||
stats: null,
|
||||
pagination: {
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
totalItems: 0,
|
||||
itemsPerPage: 10,
|
||||
},
|
||||
filters: {
|
||||
category: '',
|
||||
status: '',
|
||||
search: '',
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'DESC',
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const productSlice = createSlice({
|
||||
name: 'product',
|
||||
initialState,
|
||||
reducers: {
|
||||
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||
state.isLoading = action.payload;
|
||||
},
|
||||
setError: (state, action: PayloadAction<string | null>) => {
|
||||
state.error = action.payload;
|
||||
},
|
||||
setProducts: (state, action: PayloadAction<Product[]>) => {
|
||||
state.products = action.payload;
|
||||
},
|
||||
setCurrentProduct: (state, action: PayloadAction<Product | null>) => {
|
||||
state.currentProduct = action.payload;
|
||||
},
|
||||
setCategories: (state, action: PayloadAction<string[]>) => {
|
||||
state.categories = action.payload;
|
||||
},
|
||||
setStats: (state, action: PayloadAction<any>) => {
|
||||
state.stats = action.payload;
|
||||
},
|
||||
setPagination: (state, action: PayloadAction<any>) => {
|
||||
state.pagination = action.payload;
|
||||
},
|
||||
setFilters: (state, action: PayloadAction<Partial<ProductState['filters']>>) => {
|
||||
state.filters = { ...state.filters, ...action.payload };
|
||||
},
|
||||
addProduct: (state, action: PayloadAction<Product>) => {
|
||||
state.products.unshift(action.payload);
|
||||
},
|
||||
updateProduct: (state, action: PayloadAction<Product>) => {
|
||||
const index = state.products.findIndex(p => p.id === action.payload.id);
|
||||
if (index !== -1) {
|
||||
state.products[index] = action.payload;
|
||||
}
|
||||
if (state.currentProduct?.id === action.payload.id) {
|
||||
state.currentProduct = action.payload;
|
||||
}
|
||||
},
|
||||
removeProduct: (state, action: PayloadAction<number>) => {
|
||||
state.products = state.products.filter(p => p.id !== action.payload);
|
||||
if (state.currentProduct?.id === action.payload) {
|
||||
state.currentProduct = null;
|
||||
}
|
||||
},
|
||||
clearProducts: (state) => {
|
||||
state.products = [];
|
||||
state.currentProduct = null;
|
||||
state.pagination = initialState.pagination;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setLoading,
|
||||
setError,
|
||||
setProducts,
|
||||
setCurrentProduct,
|
||||
setCategories,
|
||||
setStats,
|
||||
setPagination,
|
||||
setFilters,
|
||||
addProduct,
|
||||
updateProduct,
|
||||
removeProduct,
|
||||
clearProducts,
|
||||
} = productSlice.actions;
|
||||
|
||||
export default productSlice.reducer;
|
||||
192
src/store/slices/productThunks.ts
Normal file
192
src/store/slices/productThunks.ts
Normal file
@ -0,0 +1,192 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { apiService, Product } from '../../services/api';
|
||||
import {
|
||||
setLoading,
|
||||
setError,
|
||||
setProducts,
|
||||
setCurrentProduct,
|
||||
setCategories,
|
||||
setStats,
|
||||
setPagination,
|
||||
addProduct,
|
||||
updateProduct,
|
||||
removeProduct,
|
||||
} from './productSlice';
|
||||
|
||||
export const fetchProducts = createAsyncThunk(
|
||||
'product/fetchProducts',
|
||||
async (params: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
category?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
} = {}, { dispatch }) => {
|
||||
try {
|
||||
dispatch(setLoading(true));
|
||||
dispatch(setError(null));
|
||||
|
||||
const response = await apiService.getAllProducts(params);
|
||||
|
||||
if (response.success) {
|
||||
dispatch(setProducts(response.data.products));
|
||||
dispatch(setPagination(response.data.pagination));
|
||||
return response.data;
|
||||
} else {
|
||||
throw new Error('Failed to fetch products');
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.message || 'Failed to fetch products';
|
||||
dispatch(setError(errorMessage));
|
||||
throw error;
|
||||
} finally {
|
||||
dispatch(setLoading(false));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchProductById = createAsyncThunk(
|
||||
'product/fetchProductById',
|
||||
async (id: number, { dispatch }) => {
|
||||
try {
|
||||
dispatch(setLoading(true));
|
||||
dispatch(setError(null));
|
||||
|
||||
const response = await apiService.getProductById(id);
|
||||
|
||||
if (response.success) {
|
||||
dispatch(setCurrentProduct(response.data));
|
||||
return response.data;
|
||||
} else {
|
||||
throw new Error('Failed to fetch product');
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.message || 'Failed to fetch product';
|
||||
dispatch(setError(errorMessage));
|
||||
throw error;
|
||||
} finally {
|
||||
dispatch(setLoading(false));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const createProduct = createAsyncThunk(
|
||||
'product/createProduct',
|
||||
async (productData: Partial<Product>, { dispatch }) => {
|
||||
try {
|
||||
dispatch(setLoading(true));
|
||||
dispatch(setError(null));
|
||||
|
||||
const response = await apiService.createProduct(productData);
|
||||
|
||||
if (response.success) {
|
||||
dispatch(addProduct(response.data));
|
||||
return response.data;
|
||||
} else {
|
||||
throw new Error('Failed to create product');
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.message || 'Failed to create product';
|
||||
dispatch(setError(errorMessage));
|
||||
throw error;
|
||||
} finally {
|
||||
dispatch(setLoading(false));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const updateProductById = createAsyncThunk(
|
||||
'product/updateProductById',
|
||||
async ({ id, productData }: { id: number; productData: Partial<Product> }, { dispatch }) => {
|
||||
try {
|
||||
dispatch(setLoading(true));
|
||||
dispatch(setError(null));
|
||||
|
||||
const response = await apiService.updateProduct(id, productData);
|
||||
|
||||
if (response.success) {
|
||||
dispatch(updateProduct(response.data));
|
||||
return response.data;
|
||||
} else {
|
||||
throw new Error('Failed to update product');
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.message || 'Failed to update product';
|
||||
dispatch(setError(errorMessage));
|
||||
throw error;
|
||||
} finally {
|
||||
dispatch(setLoading(false));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteProductById = createAsyncThunk(
|
||||
'product/deleteProductById',
|
||||
async (id: number, { dispatch }) => {
|
||||
try {
|
||||
dispatch(setLoading(true));
|
||||
dispatch(setError(null));
|
||||
|
||||
const response = await apiService.deleteProduct(id);
|
||||
|
||||
if (response.success) {
|
||||
dispatch(removeProduct(id));
|
||||
return response;
|
||||
} else {
|
||||
throw new Error('Failed to delete product');
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.message || 'Failed to delete product';
|
||||
dispatch(setError(errorMessage));
|
||||
throw error;
|
||||
} finally {
|
||||
dispatch(setLoading(false));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchProductCategories = createAsyncThunk(
|
||||
'product/fetchProductCategories',
|
||||
async (_, { dispatch }) => {
|
||||
try {
|
||||
dispatch(setError(null));
|
||||
|
||||
const response = await apiService.getProductCategories();
|
||||
|
||||
if (response.success) {
|
||||
dispatch(setCategories(response.data));
|
||||
return response.data;
|
||||
} else {
|
||||
throw new Error('Failed to fetch product categories');
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.message || 'Failed to fetch product categories';
|
||||
dispatch(setError(errorMessage));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchProductStats = createAsyncThunk(
|
||||
'product/fetchProductStats',
|
||||
async (_, { dispatch }) => {
|
||||
try {
|
||||
dispatch(setError(null));
|
||||
|
||||
const response = await apiService.getProductStats();
|
||||
|
||||
if (response.success) {
|
||||
dispatch(setStats(response.data));
|
||||
return response.data;
|
||||
} else {
|
||||
throw new Error('Failed to fetch product stats');
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.message || 'Failed to fetch product stats';
|
||||
dispatch(setError(errorMessage));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
232
src/store/slices/receiptSlice.ts
Normal file
232
src/store/slices/receiptSlice.ts
Normal file
@ -0,0 +1,232 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { Receipt, ReceiptUploadData, ReceiptFilters, ReceiptResponse } from '../../types/receipt';
|
||||
import apiService from '../../services/api';
|
||||
|
||||
// Async thunks
|
||||
export const fetchReceipts = createAsyncThunk(
|
||||
'receipts/fetchReceipts',
|
||||
async (filters: ReceiptFilters = {}) => {
|
||||
const response = await apiService.getResellerReceipts(filters);
|
||||
if (!response.success) {
|
||||
throw new Error('Failed to fetch receipts');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const uploadReceipt = createAsyncThunk(
|
||||
'receipts/uploadReceipt',
|
||||
async (data: FormData) => {
|
||||
const response = await apiService.uploadReceipt(data);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to upload receipt');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteReceipt = createAsyncThunk(
|
||||
'receipts/deleteReceipt',
|
||||
async (receiptId: number) => {
|
||||
const response = await apiService.deleteReceipt(receiptId);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to delete receipt');
|
||||
}
|
||||
return { receiptId, ...response };
|
||||
}
|
||||
);
|
||||
|
||||
export const downloadReceipt = createAsyncThunk(
|
||||
'receipts/downloadReceipt',
|
||||
async (receiptId: number) => {
|
||||
const blob = await apiService.downloadReceipt(receiptId);
|
||||
return { receiptId, blob };
|
||||
}
|
||||
);
|
||||
|
||||
// State interface
|
||||
interface ReceiptState {
|
||||
receipts: Receipt[];
|
||||
pagination: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
itemsPerPage: number;
|
||||
} | null;
|
||||
filters: ReceiptFilters;
|
||||
isLoading: boolean;
|
||||
isUploading: boolean;
|
||||
error: string | null;
|
||||
selectedReceipt: Receipt | null;
|
||||
stats: {
|
||||
totalReceipts: number;
|
||||
pendingApproval: number;
|
||||
approved: number;
|
||||
totalSales: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Initial state
|
||||
const initialState: ReceiptState = {
|
||||
receipts: [],
|
||||
pagination: null,
|
||||
filters: {
|
||||
status: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
page: 1,
|
||||
limit: 10
|
||||
},
|
||||
isLoading: false,
|
||||
isUploading: false,
|
||||
error: null,
|
||||
selectedReceipt: null,
|
||||
stats: {
|
||||
totalReceipts: 0,
|
||||
pendingApproval: 0,
|
||||
approved: 0,
|
||||
totalSales: 0
|
||||
}
|
||||
};
|
||||
|
||||
// Slice
|
||||
const receiptSlice = createSlice({
|
||||
name: 'receipts',
|
||||
initialState,
|
||||
reducers: {
|
||||
setFilters: (state, action: PayloadAction<Partial<ReceiptFilters>>) => {
|
||||
state.filters = { ...state.filters, ...action.payload };
|
||||
// Reset to first page when filters change
|
||||
if (action.payload.page === undefined) {
|
||||
state.filters.page = 1;
|
||||
}
|
||||
},
|
||||
clearFilters: (state) => {
|
||||
state.filters = {
|
||||
status: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
page: 1,
|
||||
limit: 10
|
||||
};
|
||||
},
|
||||
setSelectedReceipt: (state, action: PayloadAction<Receipt | null>) => {
|
||||
state.selectedReceipt = action.payload;
|
||||
},
|
||||
clearError: (state) => {
|
||||
state.error = null;
|
||||
},
|
||||
updateReceiptStatus: (state, action: PayloadAction<{ receiptId: number; status: string; rejectionReason?: string }>) => {
|
||||
const receipt = state.receipts.find(r => r.id === action.payload.receiptId);
|
||||
if (receipt) {
|
||||
receipt.status = action.payload.status as any;
|
||||
if (action.payload.rejectionReason) {
|
||||
receipt.rejectionReason = action.payload.rejectionReason;
|
||||
}
|
||||
}
|
||||
},
|
||||
calculateStats: (state) => {
|
||||
const receipts = state.receipts;
|
||||
state.stats = {
|
||||
totalReceipts: receipts.length,
|
||||
pendingApproval: receipts.filter(r => r.status === 'pending').length,
|
||||
approved: receipts.filter(r => r.status === 'approved').length,
|
||||
totalSales: receipts.reduce((sum, r) => sum + r.saleAmount, 0)
|
||||
};
|
||||
}
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
// Fetch receipts
|
||||
builder
|
||||
.addCase(fetchReceipts.pending, (state) => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchReceipts.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.receipts = action.payload.data || [];
|
||||
state.pagination = action.payload.pagination || null;
|
||||
// Calculate stats
|
||||
const receipts = action.payload.data || [];
|
||||
state.stats = {
|
||||
totalReceipts: receipts.length,
|
||||
pendingApproval: receipts.filter(r => r.status === 'pending').length,
|
||||
approved: receipts.filter(r => r.status === 'approved').length,
|
||||
totalSales: receipts.reduce((sum, r) => sum + r.saleAmount, 0)
|
||||
};
|
||||
})
|
||||
.addCase(fetchReceipts.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.error.message || 'Failed to fetch receipts';
|
||||
});
|
||||
|
||||
// Upload receipt
|
||||
builder
|
||||
.addCase(uploadReceipt.pending, (state) => {
|
||||
state.isUploading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(uploadReceipt.fulfilled, (state, action) => {
|
||||
state.isUploading = false;
|
||||
// Add the new receipt to the list
|
||||
if (action.payload.data) {
|
||||
state.receipts.unshift(action.payload.data);
|
||||
// Recalculate stats
|
||||
const receipts = state.receipts;
|
||||
state.stats = {
|
||||
totalReceipts: receipts.length,
|
||||
pendingApproval: receipts.filter(r => r.status === 'pending').length,
|
||||
approved: receipts.filter(r => r.status === 'approved').length,
|
||||
totalSales: receipts.reduce((sum, r) => sum + r.saleAmount, 0)
|
||||
};
|
||||
}
|
||||
})
|
||||
.addCase(uploadReceipt.rejected, (state, action) => {
|
||||
state.isUploading = false;
|
||||
state.error = action.error.message || 'Failed to upload receipt';
|
||||
});
|
||||
|
||||
// Delete receipt
|
||||
builder
|
||||
.addCase(deleteReceipt.fulfilled, (state, action) => {
|
||||
state.receipts = state.receipts.filter(r => r.id !== action.payload.receiptId);
|
||||
// Recalculate stats
|
||||
const receipts = state.receipts;
|
||||
state.stats = {
|
||||
totalReceipts: receipts.length,
|
||||
pendingApproval: receipts.filter(r => r.status === 'pending').length,
|
||||
approved: receipts.filter(r => r.status === 'approved').length,
|
||||
totalSales: receipts.reduce((sum, r) => sum + r.saleAmount, 0)
|
||||
};
|
||||
});
|
||||
|
||||
// Download receipt
|
||||
builder
|
||||
.addCase(downloadReceipt.fulfilled, (state, action) => {
|
||||
// Handle download success (usually no state changes needed)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Export actions
|
||||
export const {
|
||||
setFilters,
|
||||
clearFilters,
|
||||
setSelectedReceipt,
|
||||
clearError,
|
||||
updateReceiptStatus,
|
||||
calculateStats
|
||||
} = receiptSlice.actions;
|
||||
|
||||
// Export selectors
|
||||
export const selectReceipts = (state: { receipts: ReceiptState }) => state.receipts.receipts;
|
||||
export const selectReceiptPagination = (state: { receipts: ReceiptState }) => state.receipts.pagination;
|
||||
export const selectReceiptFilters = (state: { receipts: ReceiptState }) => state.receipts.filters;
|
||||
export const selectReceiptsLoading = (state: { receipts: ReceiptState }) => state.receipts.isLoading;
|
||||
export const selectReceiptsUploading = (state: { receipts: ReceiptState }) => state.receipts.isUploading;
|
||||
export const selectReceiptsError = (state: { receipts: ReceiptState }) => state.receipts.error;
|
||||
export const selectSelectedReceipt = (state: { receipts: ReceiptState }) => state.receipts.selectedReceipt;
|
||||
export const selectReceiptStats = (state: { receipts: ReceiptState }) => state.receipts.stats;
|
||||
|
||||
// Export reducer
|
||||
export default receiptSlice.reducer;
|
||||
@ -12,10 +12,10 @@ const getInitialTheme = (): Theme => {
|
||||
const savedTheme = localStorage.getItem('theme') as Theme;
|
||||
if (savedTheme) return savedTheme;
|
||||
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
return systemTheme;
|
||||
// Default to dark theme instead of system preference
|
||||
return 'dark';
|
||||
}
|
||||
return 'light';
|
||||
return 'dark';
|
||||
};
|
||||
|
||||
const initialState: ThemeState = {
|
||||
|
||||
104
src/types/receipt.ts
Normal file
104
src/types/receipt.ts
Normal file
@ -0,0 +1,104 @@
|
||||
export interface Receipt {
|
||||
id: number;
|
||||
receiptNumber: string;
|
||||
orderId: number;
|
||||
resellerId: number;
|
||||
vendorId: number;
|
||||
channelPartnerId: number;
|
||||
clientName: string;
|
||||
clientEmail?: string;
|
||||
clientPhone?: string;
|
||||
clientAddress?: string;
|
||||
saleAmount: number;
|
||||
currency: string;
|
||||
saleDate: string;
|
||||
paymentMethod?: string;
|
||||
paymentStatus: 'pending' | 'paid' | 'partial' | 'failed';
|
||||
receiptFile: string;
|
||||
receiptFileType?: string;
|
||||
receiptFileSize?: number;
|
||||
description?: string;
|
||||
status: 'pending' | 'approved' | 'rejected' | 'disputed';
|
||||
approvedBy?: number;
|
||||
approvedAt?: string;
|
||||
rejectionReason?: string;
|
||||
metadata?: Record<string, any>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
// Related data
|
||||
order?: {
|
||||
id: number;
|
||||
orderNumber: string;
|
||||
items?: Array<{
|
||||
id: number;
|
||||
product: {
|
||||
id: number;
|
||||
name: string;
|
||||
sku: string;
|
||||
price: number;
|
||||
};
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
}>;
|
||||
};
|
||||
vendor?: {
|
||||
id: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
company?: string;
|
||||
};
|
||||
reseller?: {
|
||||
id: number;
|
||||
companyName: string;
|
||||
contactEmail: string;
|
||||
contactPhone: string;
|
||||
};
|
||||
approver?: {
|
||||
id: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ReceiptUploadData {
|
||||
orderId: number;
|
||||
clientName: string;
|
||||
clientEmail?: string;
|
||||
clientPhone?: string;
|
||||
clientAddress?: string;
|
||||
saleAmount: number;
|
||||
currency?: string;
|
||||
saleDate?: string;
|
||||
paymentMethod?: string;
|
||||
description?: string;
|
||||
receiptFile?: File | null;
|
||||
}
|
||||
|
||||
export interface ReceiptFilters {
|
||||
status?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
resellerId?: number;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ReceiptResponse {
|
||||
success: boolean;
|
||||
data: Receipt[];
|
||||
pagination?: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
itemsPerPage: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ReceiptStatusUpdate {
|
||||
status: 'approved' | 'rejected';
|
||||
rejectionReason?: string;
|
||||
}
|
||||
41
src/types/vendor.ts
Normal file
41
src/types/vendor.ts
Normal file
@ -0,0 +1,41 @@
|
||||
export interface VendorRequest {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
company: string;
|
||||
role: string;
|
||||
userType: string;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
createdAt: string;
|
||||
rejectionReason?: string;
|
||||
// Vendor-specific fields from User model
|
||||
companyType?: string;
|
||||
registrationNumber?: string;
|
||||
gstNumber?: string;
|
||||
panNumber?: string;
|
||||
address?: string;
|
||||
website?: string;
|
||||
businessLicense?: string;
|
||||
taxId?: string;
|
||||
industry?: string;
|
||||
yearsInBusiness?: number;
|
||||
annualRevenue?: number;
|
||||
employeeCount?: number;
|
||||
}
|
||||
|
||||
export interface VendorModalProps {
|
||||
vendor: VendorRequest | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onApprove: (vendorId: string) => void;
|
||||
onReject: (vendorId: string, reason: string) => void;
|
||||
}
|
||||
|
||||
export interface RejectionModalProps {
|
||||
vendor: VendorRequest | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onReject: (vendorId: string, reason: string) => void;
|
||||
}
|
||||
38
src/utils/authTest.ts
Normal file
38
src/utils/authTest.ts
Normal file
@ -0,0 +1,38 @@
|
||||
// Utility to test authentication persistence
|
||||
export const testAuthPersistence = () => {
|
||||
console.log('=== Testing Authentication Persistence ===');
|
||||
|
||||
// Check localStorage
|
||||
const accessToken = localStorage.getItem('accessToken');
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
const sessionId = localStorage.getItem('sessionId');
|
||||
|
||||
console.log('localStorage tokens:');
|
||||
console.log('- accessToken:', accessToken ? 'Present' : 'Missing');
|
||||
console.log('- refreshToken:', refreshToken ? 'Present' : 'Missing');
|
||||
console.log('- sessionId:', sessionId ? 'Present' : 'Missing');
|
||||
|
||||
// Check if tokens are valid (not expired)
|
||||
if (accessToken) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(accessToken.split('.')[1]));
|
||||
const expiry = new Date(payload.exp * 1000);
|
||||
const now = new Date();
|
||||
|
||||
console.log('Token expiry:', expiry.toISOString());
|
||||
console.log('Current time:', now.toISOString());
|
||||
console.log('Token expired:', expiry < now);
|
||||
} catch (error) {
|
||||
console.log('Error parsing token:', error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('==========================================');
|
||||
};
|
||||
|
||||
// Function to simulate page reload
|
||||
export const simulatePageReload = () => {
|
||||
console.log('Simulating page reload...');
|
||||
// Clear any in-memory state but keep localStorage
|
||||
window.location.reload();
|
||||
};
|
||||
@ -1,7 +1,7 @@
|
||||
// Phone number validation
|
||||
export const validatePhoneNumber = (phone: string): boolean => {
|
||||
// Allow international format with optional +, digits only, 7-15 digits total
|
||||
const phoneRegex = /^[\+]?[1-9][\d]{6,14}$/;
|
||||
const phoneRegex = /^[+]?[1-9][\d]{6,14}$/;
|
||||
return phoneRegex.test(phone);
|
||||
};
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user