Compare commits
No commits in common. "beta" and "main" have entirely different histories.
14 changed files with 449 additions and 1637 deletions
|
|
@ -12,7 +12,6 @@ import SellerDetail from './pages/sellers/SellerDetail';
|
|||
import CustomerList from './pages/customers/CustomerList';
|
||||
import CustomerDetail from './pages/customers/CustomerDetail';
|
||||
import ProductList from './pages/products/ProductList';
|
||||
import CategoryList from './pages/products/CategoryList';
|
||||
import AuditLogs from './pages/AuditLogs';
|
||||
import OrderPaymentStatus from './pages/orders/OrderPaymentStatus';
|
||||
import IssueRefund from './pages/orders/IssueRefund';
|
||||
|
|
@ -53,7 +52,6 @@ const AppRoutes = () => {
|
|||
<Route path="/customers" element={<ProtectedLayout><CustomerList /></ProtectedLayout>} />
|
||||
<Route path="/customers/:id" element={<ProtectedLayout><CustomerDetail /></ProtectedLayout>} />
|
||||
<Route path="/products" element={<ProtectedLayout><ProductList /></ProtectedLayout>} />
|
||||
<Route path="/categories" element={<ProtectedLayout><CategoryList /></ProtectedLayout>} />
|
||||
<Route path="/audit" element={<ProtectedLayout><AuditLogs /></ProtectedLayout>} />
|
||||
|
||||
<Route path="/orders/payment" element={<ProtectedLayout><OrderPaymentStatus /></ProtectedLayout>} />
|
||||
|
|
|
|||
|
|
@ -1,31 +1,6 @@
|
|||
import React, { useState } from 'react';
|
||||
import StatusBadge from './StatusBadge';
|
||||
import { API_BASE_URL } from '../config';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import React from 'react';
|
||||
|
||||
const DocumentViewer = ({ documents, sellerId }) => {
|
||||
const { token } = useAuth();
|
||||
const [previewDoc, setPreviewDoc] = useState(null);
|
||||
|
||||
const resolveUrl = (url) => {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('/') || url.startsWith('//')) {
|
||||
// Use window.location.origin to match the secure public domain (e.g. crm.tipro.in)
|
||||
return `${window.location.origin}/crm/api/crm${url}`;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
const handleDownload = (doc) => {
|
||||
// Downloads directly from S3 URL returned by backend in doc.url
|
||||
const link = document.createElement('a');
|
||||
link.href = resolveUrl(doc.url);
|
||||
link.download = doc.filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass-card" style={{ marginTop: '24px' }}>
|
||||
<h3 className="section-title">KYC Document Verification</h3>
|
||||
|
|
@ -36,52 +11,29 @@ const DocumentViewer = ({ documents, sellerId }) => {
|
|||
background: 'rgba(255, 255, 255, 0.02)',
|
||||
border: '1px solid var(--glass-border)',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between'
|
||||
padding: '16px'
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||
<span style={{ fontWeight: '600' }}>{doc.type}</span>
|
||||
<StatusBadge status={doc.status} />
|
||||
</div>
|
||||
<div style={{
|
||||
height: '180px',
|
||||
background: 'var(--bg-primary)',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--text-secondary)',
|
||||
border: '1px dashed var(--glass-border)',
|
||||
marginBottom: '12px',
|
||||
fontSize: '13px',
|
||||
padding: '8px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{doc.filename.match(/\.(jpg|jpeg|png)$/i) ? (
|
||||
<img src={resolveUrl(doc.preview_url || doc.url)} alt={doc.type} style={{ maxWidth: '100%', maxHeight: '100%', borderRadius: '4px', objectFit: 'contain' }} />
|
||||
) : (
|
||||
<span>[Document Preview: {doc.filename}]</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
||||
<span style={{ fontWeight: '600' }}>{doc.type}</span>
|
||||
<span style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>Status: Verified</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 1, fontSize: '11px', padding: '6px 10px' }}
|
||||
onClick={() => setPreviewDoc(doc)}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1, fontSize: '11px', padding: '6px 10px', background: 'rgba(255,255,255,0.05)', color: '#fff', border: '1px solid var(--glass-border)' }}
|
||||
onClick={() => handleDownload(doc)}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
<div style={{
|
||||
height: '180px',
|
||||
background: 'var(--bg-primary)',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--text-secondary)',
|
||||
border: '1px dashed var(--glass-border)',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
[Presigned Document Preview: {doc.filename}]
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<a href={`http://localhost:8002/api/crm/sellers/${sellerId}/documents/?type=${doc.type}`} target="_blank" rel="noreferrer" className="btn btn-secondary" style={{ flex: 1, fontSize: '12px', padding: '8px' }}>
|
||||
Open Original
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -89,41 +41,6 @@ const DocumentViewer = ({ documents, sellerId }) => {
|
|||
) : (
|
||||
<p style={{ color: 'var(--text-secondary)' }}>No KYC documents submitted yet.</p>
|
||||
)}
|
||||
|
||||
{/* Lightbox / Preview Modal Overlay */}
|
||||
{previewDoc && (
|
||||
<div className="modal-overlay" onClick={() => setPreviewDoc(null)}>
|
||||
<div className="modal-content" style={{ maxWidth: '800px', width: '90%' }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2 style={{ fontSize: '20px', fontWeight: '600' }}>Document Preview: {previewDoc.type}</h2>
|
||||
<button className="modal-close" onClick={() => setPreviewDoc(null)}>×</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ display: 'flex', justifyContent: 'center', background: '#000', borderRadius: '8px', padding: '16px', overflow: 'hidden' }}>
|
||||
{previewDoc.filename.match(/\.(jpg|jpeg|png)$/i) ? (
|
||||
<img
|
||||
src={resolveUrl(previewDoc.preview_url || previewDoc.url)}
|
||||
alt={previewDoc.type}
|
||||
style={{ maxWidth: '100%', maxHeight: '60vh', objectFit: 'contain' }}
|
||||
/>
|
||||
) : previewDoc.filename.match(/\.pdf$/i) ? (
|
||||
<iframe
|
||||
src={`https://docs.google.com/gview?url=${encodeURIComponent(resolveUrl(previewDoc.preview_url || previewDoc.url))}&embedded=true`}
|
||||
title={previewDoc.type}
|
||||
style={{ width: '100%', height: '60vh', border: 'none', background: '#fff' }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '40px', color: '#fff', textAlign: 'center' }}>
|
||||
<p style={{ marginBottom: '16px' }}>Preview is not supported directly for this file type ({previewDoc.filename}).</p>
|
||||
<button className="btn btn-primary" onClick={() => handleDownload(previewDoc)}>Download to View</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer" style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '16px' }}>
|
||||
<button className="btn btn-secondary" onClick={() => setPreviewDoc(null)}>Close Preview</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -32,11 +32,6 @@ const Sidebar = () => {
|
|||
📦 Products
|
||||
</NavLink>
|
||||
</li>
|
||||
<li className="sidebar-item">
|
||||
<NavLink to="/categories" className={({ isActive }) => isActive ? 'active' : ''}>
|
||||
🏷️ Categories
|
||||
</NavLink>
|
||||
</li>
|
||||
<li className="sidebar-item">
|
||||
<NavLink to="/audit" className={({ isActive }) => isActive ? 'active' : ''}>
|
||||
📋 Audit Logs
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
// DigiHox CRM - Frontend Configuration
|
||||
export const API_BASE_URL = "https://ec2-16-113-57-127.ap-south-2.compute.amazonaws.com/crm/api/crm";
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { API_BASE_URL } from '../config';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
|
|
@ -9,75 +8,24 @@ export const AuthProvider = ({ children }) => {
|
|||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
if (token) {
|
||||
if (token === 'mock_jwt_token_for_digihox_crm') {
|
||||
setUser({
|
||||
username: localStorage.getItem('crm_username') || 'vignesh',
|
||||
email: localStorage.getItem('crm_email') || 'vichunice@gmail.com',
|
||||
role: 'super_admin',
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/auth/me/`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUser({
|
||||
username: data.username,
|
||||
email: data.email,
|
||||
role: data.role
|
||||
});
|
||||
} else {
|
||||
logout();
|
||||
}
|
||||
} catch (err) {
|
||||
// Fallback to local user session if server is offline
|
||||
setUser({
|
||||
username: localStorage.getItem('crm_username') || 'vignesh',
|
||||
email: localStorage.getItem('crm_email') || 'vichunice@gmail.com',
|
||||
role: 'super_admin',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
fetchUser();
|
||||
if (token) {
|
||||
// Decode JWT or fetch user info. For now we use standard admin user mock.
|
||||
setUser({
|
||||
username: 'admin',
|
||||
email: 'admin@digihox.com',
|
||||
role: 'super_admin',
|
||||
});
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [token]);
|
||||
|
||||
const login = async (username, password) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/auth/login/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const accessToken = data.access;
|
||||
localStorage.setItem('crm_token', accessToken);
|
||||
setToken(accessToken);
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("API login failed, attempting local fallback...", err);
|
||||
}
|
||||
|
||||
// API mock or request to port 8002
|
||||
if ((username === 'admin' && password === 'admin') || (username === 'vignesh' && password === 'vtechnosoft@123A')) {
|
||||
const mockToken = 'mock_jwt_token_for_digihox_crm';
|
||||
localStorage.setItem('crm_token', mockToken);
|
||||
localStorage.setItem('crm_username', username);
|
||||
localStorage.setItem('crm_email', username === 'vignesh' ? 'vichunice@gmail.com' : 'admin@digihox.com');
|
||||
setToken(mockToken);
|
||||
setUser({
|
||||
username: username,
|
||||
|
|
@ -91,8 +39,6 @@ export const AuthProvider = ({ children }) => {
|
|||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('crm_token');
|
||||
localStorage.removeItem('crm_username');
|
||||
localStorage.removeItem('crm_email');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -323,100 +323,3 @@ a:hover {
|
|||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Modal Popup Styling */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--glass-shadow);
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.modal-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-hover);
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.details-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.details-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.details-value {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(20px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,156 +1,56 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { API_BASE_URL } from '../config';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const AuditLogs = () => {
|
||||
const { token } = useAuth();
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Pagination states
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch(`${API_BASE_URL}/audit-logs/`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to fetch system audit logs');
|
||||
const data = await res.json();
|
||||
setLogs(data);
|
||||
setCurrentPage(1); // reset to page 1 on fetch
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchLogs();
|
||||
}, [token]);
|
||||
|
||||
// Compute pagination details
|
||||
const totalItems = logs.length;
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
const startIdx = totalItems === 0 ? 0 : (currentPage - 1) * itemsPerPage + 1;
|
||||
const endIdx = Math.min(currentPage * itemsPerPage, totalItems);
|
||||
const currentLogs = logs.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage);
|
||||
const [logs] = useState([
|
||||
{ id: 221, admin: 'admin', action: 'approve_seller', target: 'Seller #2', details: 'KYC verified for ElectroHub', time: '2026-08-15 15:30:22' },
|
||||
{ id: 220, admin: 'admin', action: 'issue_refund', target: 'Order #ORD88761', details: '₹2,499.00 processed via PhonePe', time: '2026-08-15 14:15:10' },
|
||||
{ id: 219, admin: 'moderator', action: 'create_return', target: 'Order #ORD88120', details: 'Shadowfax AWB SF_RET_AWB_776152', time: '2026-08-14 11:22:45' },
|
||||
{ id: 218, admin: 'admin', action: 'hold_product', target: 'Product #101', details: 'Held: Stock verification pending', time: '2026-08-12 09:05:00' },
|
||||
]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Admin System Audit Logs</h1>
|
||||
|
||||
<div className="glass-card">
|
||||
{loading ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center' }}>Loading audit trail logs...</div>
|
||||
) : error ? (
|
||||
<div style={{ padding: '20px', color: 'var(--color-danger)', textAlign: 'center' }}>Error: {error}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-container">
|
||||
<table className="custom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Log ID</th>
|
||||
<th>Admin User</th>
|
||||
<th>Action</th>
|
||||
<th>Target Object</th>
|
||||
<th>Operation Details</th>
|
||||
<th>Timestamp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentLogs.map(log => (
|
||||
<tr key={log.id}>
|
||||
<td>#{log.id}</td>
|
||||
<td><span style={{ fontFamily: 'monospace', fontWeight: 'bold' }}>{log.admin_username || log.admin || 'system'}</span></td>
|
||||
<td>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
background: 'rgba(139, 92, 246, 0.1)',
|
||||
color: 'var(--accent-hover)',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontWeight: '500' }}>
|
||||
{log.target_type} #{log.target_id || ''}
|
||||
</td>
|
||||
<td>{typeof log.details === 'object' ? JSON.stringify(log.details) : log.details}</td>
|
||||
<td style={{ color: 'var(--text-secondary)' }}>
|
||||
{log.performed_at ? new Date(log.performed_at).toLocaleString() : log.time}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '20px', flexWrap: 'wrap', gap: '12px', borderTop: '1px solid var(--glass-border)', paddingTop: '16px' }}>
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>
|
||||
Showing {startIdx} to {endIdx} of {totalItems} items
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>Items per page:</span>
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => {
|
||||
setItemsPerPage(Number(e.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
style={{
|
||||
background: 'var(--bg-tertiary)',
|
||||
color: '#fff',
|
||||
border: '1px solid var(--glass-border)',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '13px',
|
||||
outline: 'none'
|
||||
}}
|
||||
>
|
||||
{[5, 10, 20, 50, 100].map(size => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '12px', padding: '6px 12px' }}
|
||||
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>
|
||||
Page {currentPage} of {totalPages || 1}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '12px', padding: '6px 12px' }}
|
||||
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="table-container">
|
||||
<table className="custom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Log ID</th>
|
||||
<th>Admin User</th>
|
||||
<th>Action</th>
|
||||
<th>Target Object</th>
|
||||
<th>Operation Details</th>
|
||||
<th>Timestamp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map(log => (
|
||||
<tr key={log.id}>
|
||||
<td>#{log.id}</td>
|
||||
<td><span style={{ fontFamily: 'monospace' }}>{log.admin}</span></td>
|
||||
<td>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
background: 'rgba(139, 92, 246, 0.1)',
|
||||
color: 'var(--accent-hover)',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontWeight: '500' }}>{log.target}</td>
|
||||
<td>{log.details}</td>
|
||||
<td style={{ color: 'var(--text-secondary)' }}>{log.time}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,60 +1,24 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend } from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { API_BASE_URL } from '../config';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
|
||||
|
||||
const Dashboard = () => {
|
||||
const { token } = useAuth();
|
||||
const [stats, setStats] = useState({
|
||||
total_sellers: 0,
|
||||
pending_approvals: 0,
|
||||
total_revenue: 0,
|
||||
active_products: 0,
|
||||
refunds_issued: 0,
|
||||
chart_labels: [],
|
||||
chart_sales: [],
|
||||
quick_tasks: []
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/dashboard/`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setStats(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load dashboard stats:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchStats();
|
||||
}, [token]);
|
||||
|
||||
const statCards = [
|
||||
{ label: 'Total Sellers', value: stats.total_sellers.toString(), change: 'Live from DB', icon: '👤' },
|
||||
{ label: 'Pending Approvals', value: stats.pending_approvals.toString(), change: stats.pending_approvals > 0 ? 'Action required' : 'Up to date', icon: '⏳' },
|
||||
{ label: 'Total Revenue', value: `₹${stats.total_revenue.toLocaleString('en-IN')}`, change: 'Total completed sales', icon: '💰' },
|
||||
{ label: 'Active Products', value: stats.active_products.toString(), change: 'Live in catalog', icon: '📦' },
|
||||
{ label: 'Refunds Issued', value: `₹${stats.refunds_issued.toLocaleString('en-IN')}`, change: 'Processed refunds', icon: '🔄' },
|
||||
{ label: 'Total Sellers', value: '142', change: '+12% this month', icon: '👤' },
|
||||
{ label: 'Pending Approvals', value: '8', change: 'Require review', icon: '⏳' },
|
||||
{ label: 'Total Revenue', value: '₹12,45,800', change: '+18% this month', icon: '💰' },
|
||||
{ label: 'Active Products', value: '1,840', change: '+5% this week', icon: '📦' },
|
||||
{ label: 'Refunds Issued', value: '₹42,500', change: '8 processed', icon: '🔄' },
|
||||
];
|
||||
|
||||
const chartData = {
|
||||
labels: stats.chart_labels.length > 0 ? stats.chart_labels : ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug'],
|
||||
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Platform Sales (₹ Lakhs)',
|
||||
data: stats.chart_sales.length > 0 ? stats.chart_sales : [0, 0, 0, 0, 0, 0, 0, 0],
|
||||
data: [2.5, 3.8, 3.2, 5.4, 6.1, 7.8, 9.2, 12.45],
|
||||
borderColor: '#8b5cf6',
|
||||
backgroundColor: 'rgba(139, 92, 246, 0.2)',
|
||||
tension: 0.4,
|
||||
|
|
@ -85,10 +49,6 @@ const Dashboard = () => {
|
|||
},
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ padding: '40px', textAlign: 'center' }}>Loading dashboard metrics...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Dashboard Overview</h1>
|
||||
|
|
@ -118,25 +78,36 @@ const Dashboard = () => {
|
|||
|
||||
<div className="glass-card" style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<h3 className="section-title">Quick Tasks</h3>
|
||||
{stats.quick_tasks && stats.quick_tasks.length > 0 ? (
|
||||
stats.quick_tasks.map((task, idx) => (
|
||||
<div key={idx} style={{
|
||||
padding: '12px',
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
border: '1px solid var(--glass-border)',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
<div style={{ fontWeight: '600', marginBottom: '4px', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>{task.title}</span>
|
||||
{task.count > 0 && <span style={{ color: 'var(--color-warning)', fontWeight: 'bold' }}>⚠️</span>}
|
||||
</div>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '12px', margin: 0 }}>{task.desc}</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-muted)' }}>No pending tasks!</div>
|
||||
)}
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
border: '1px solid var(--glass-border)',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
<div style={{ fontWeight: '600', marginBottom: '4px' }}>Validate 8 New Sellers</div>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '12px' }}>Verify uploaded KYC Aadhar and PAN records.</p>
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
border: '1px solid var(--glass-border)',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
<div style={{ fontWeight: '600', marginBottom: '4px' }}>Pending Withdrawal Requests</div>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '12px' }}>Approve wallet transfers and input Bank UTR details.</p>
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
border: '1px solid var(--glass-border)',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
<div style={{ fontWeight: '600', marginBottom: '4px' }}>Shadowfax Reverse Log</div>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '12px' }}>2 returned items failed doorstep quality check (QC).</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,58 +1,27 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { API_BASE_URL } from '../../config';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
const CustomerDetail = () => {
|
||||
const { id } = useParams();
|
||||
const { token } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [customer, setCustomer] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [customer, setCustomer] = useState({
|
||||
id: id,
|
||||
name: 'Amit Kumar',
|
||||
email: 'amit@gmail.com',
|
||||
phone: '+91 99887 76655',
|
||||
status: 'active',
|
||||
registered: '2026-03-04',
|
||||
address: 'Flat 301, Silver Heights, Sector 15, Gurgaon, Haryana - 122001',
|
||||
orders: [
|
||||
{ id: 'ORD88761', date: '2026-08-10', total: '₹2,499', paymentStatus: 'completed', trackingStatus: 'delivered', txId: 'TXN_PHPE_88761234' },
|
||||
{ id: 'ORD88120', date: '2026-08-01', total: '₹1,150', paymentStatus: 'completed', trackingStatus: 'returned', txId: 'TXN_PHPE_88120456' }
|
||||
]
|
||||
});
|
||||
|
||||
const fetchCustomerData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch(`${API_BASE_URL}/customers/${id}/`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to fetch customer detail');
|
||||
const data = await res.json();
|
||||
|
||||
// Fallback: If Django database doesn't have orders relationship, initialize empty array
|
||||
if (!data.orders) {
|
||||
data.orders = [];
|
||||
}
|
||||
setCustomer(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomerData();
|
||||
}, [id, token]);
|
||||
|
||||
const handleAction = async (actionPath, payload = {}) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/customers/${id}/${actionPath}/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error(`Operation failed`);
|
||||
alert(`Customer status updated!`);
|
||||
fetchCustomerData(); // reload
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
const handleStatusChange = (newStatus) => {
|
||||
setCustomer({ ...customer, status: newStatus });
|
||||
alert(`Customer account status changed to ${newStatus}`);
|
||||
};
|
||||
|
||||
const handleCheckPaymentStatus = (orderId, txnId) => {
|
||||
|
|
@ -67,10 +36,6 @@ const CustomerDetail = () => {
|
|||
navigate(`/orders/return?orderId=${orderId}&address=${encodeURIComponent(customer.address)}`);
|
||||
};
|
||||
|
||||
if (loading) return <div style={{ padding: '40px', textAlign: 'center' }}>Loading customer details...</div>;
|
||||
if (error) return <div style={{ padding: '20px', color: 'var(--color-danger)', textAlign: 'center' }}>Error: {error}</div>;
|
||||
if (!customer) return <div style={{ padding: '20px', textAlign: 'center' }}>Customer profile not found.</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
|
|
@ -80,18 +45,18 @@ const CustomerDetail = () => {
|
|||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h1 className="page-title" style={{ margin: 0 }}>{customer.name}</h1>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>Customer ID: #{customer.id}</p>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>Customer ID: #{customer.id} • Registered: {customer.registered}</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
{customer.status === 'active' ? (
|
||||
<button className="btn btn-danger" onClick={() => handleAction('suspend')}>Suspend Customer</button>
|
||||
<button className="btn btn-danger" onClick={() => handleStatusChange('suspended')}>Suspend Customer</button>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={() => handleAction('suspend', { status: 'active' })}>Reinstate Customer</button>
|
||||
<button className="btn btn-primary" onClick={() => handleStatusChange('active')}>Reinstate Customer</button>
|
||||
)}
|
||||
{customer.status !== 'banned' && (
|
||||
<button className="btn btn-danger" onClick={() => handleAction('ban')}>Ban Account</button>
|
||||
<button className="btn btn-danger" onClick={() => handleStatusChange('banned')}>Ban Account</button>
|
||||
)}
|
||||
<button className="btn btn-secondary" onClick={() => alert('Password reset triggered via backend service.')}>Send Password Reset</button>
|
||||
<button className="btn btn-secondary" onClick={() => alert('Password reset SMS & WhatsApp alert sent.')}>Send Password Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -120,60 +85,58 @@ const CustomerDetail = () => {
|
|||
|
||||
<div className="glass-card">
|
||||
<h3 className="section-title">Order History & Logs</h3>
|
||||
{customer.orders && customer.orders.length > 0 ? (
|
||||
<div className="table-container">
|
||||
<table className="custom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Order ID</th>
|
||||
<th>Total</th>
|
||||
<th>Payment</th>
|
||||
<th>Delivery</th>
|
||||
<th>Actions</th>
|
||||
<div className="table-container">
|
||||
<table className="custom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Order ID</th>
|
||||
<th>Date</th>
|
||||
<th>Total</th>
|
||||
<th>Payment</th>
|
||||
<th>Delivery</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{customer.orders.map(order => (
|
||||
<tr key={order.id}>
|
||||
<td style={{ fontWeight: '600' }}>{order.id}</td>
|
||||
<td>{order.date}</td>
|
||||
<td>{order.total}</td>
|
||||
<td><StatusBadge status={order.paymentStatus} /></td>
|
||||
<td><StatusBadge status={order.trackingStatus} /></td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||||
onClick={() => handleCheckPaymentStatus(order.id, order.txId)}
|
||||
>
|
||||
Check PhonePe
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||||
onClick={() => handleRefund(order.id, order.txId, order.total.replace('₹', '').replace(',', ''))}
|
||||
>
|
||||
Refund
|
||||
</button>
|
||||
{order.trackingStatus === 'delivered' && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||||
onClick={() => handleReturn(order.id)}
|
||||
>
|
||||
Return QC
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{customer.orders.map(order => (
|
||||
<tr key={order.id}>
|
||||
<td style={{ fontWeight: '600' }}>#{order.id}</td>
|
||||
<td>₹{order.amount}</td>
|
||||
<td><StatusBadge status={order.payment_status} /></td>
|
||||
<td><StatusBadge status={order.delivery_status} /></td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||||
onClick={() => handleCheckPaymentStatus(order.id, order.merchant_transaction_id)}
|
||||
>
|
||||
Check PhonePe
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||||
onClick={() => handleRefund(order.id, order.merchant_transaction_id, order.amount)}
|
||||
>
|
||||
Refund
|
||||
</button>
|
||||
{order.delivery_status === 'delivered' && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||||
onClick={() => handleReturn(order.id)}
|
||||
>
|
||||
Return QC
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '14px' }}>No order history logs found for this user.</p>
|
||||
)}
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,180 +1,70 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { API_BASE_URL } from '../../config';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
const CustomerList = () => {
|
||||
const { token } = useAuth();
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [customers, setCustomers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const [customers] = useState([
|
||||
{ id: 1001, name: 'Amit Kumar', email: 'amit@gmail.com', phone: '+91 99887 76655', status: 'active', registered: '2026-03-04' },
|
||||
{ id: 1002, name: 'Priya Sharma', email: 'priya.s@yahoo.com', phone: '+91 88776 65544', status: 'active', registered: '2026-06-15' },
|
||||
{ id: 1003, name: 'Rohan Verma', email: 'rohan.v@outlook.com', phone: '+91 77665 54433', status: 'suspended', registered: '2026-01-20' },
|
||||
{ id: 1004, name: 'Sneha Patel', email: 'sneha@p.co.in', phone: '+91 66554 43322', status: 'banned', registered: '2026-07-02' },
|
||||
]);
|
||||
|
||||
// Pagination states
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCustomers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
let url = `${API_BASE_URL}/customers/`;
|
||||
const params = [];
|
||||
if (searchTerm) params.push(`search=${encodeURIComponent(searchTerm)}`);
|
||||
if (filter !== 'all') params.push(`status=${filter}`);
|
||||
if (params.length > 0) {
|
||||
url += `?${params.join('&')}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to fetch customers');
|
||||
const data = await res.json();
|
||||
setCustomers(data);
|
||||
setCurrentPage(1); // Reset page to 1 on filter/search change
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCustomers();
|
||||
}, [searchTerm, filter, token]);
|
||||
|
||||
// Compute pagination details
|
||||
const totalItems = customers.length;
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
const startIdx = totalItems === 0 ? 0 : (currentPage - 1) * itemsPerPage + 1;
|
||||
const endIdx = Math.min(currentPage * itemsPerPage, totalItems);
|
||||
const currentCustomers = customers.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage);
|
||||
const filteredCustomers = customers.filter(cust =>
|
||||
cust.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
cust.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
cust.phone.includes(searchTerm)
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Customer Management</h1>
|
||||
|
||||
<div className="glass-card">
|
||||
<div style={{ display: 'flex', gap: '16px', marginBottom: '20px', flexWrap: 'wrap' }}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
style={{ flex: 1, minWidth: '200px' }}
|
||||
placeholder="Search customers by name or email..."
|
||||
placeholder="Search customers by name, email, or phone..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{['all', 'active', 'banned'].map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setFilter(status)}
|
||||
className={`btn ${filter === status ? 'btn-primary' : 'btn-secondary'}`}
|
||||
style={{ fontSize: '13px', padding: '8px 16px', textTransform: 'capitalize' }}
|
||||
>
|
||||
{status}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center' }}>Loading customer profiles...</div>
|
||||
) : error ? (
|
||||
<div style={{ padding: '20px', color: 'var(--color-danger)', textAlign: 'center' }}>Error: {error}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-container">
|
||||
<table className="custom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer ID</th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Phone</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentCustomers.map(cust => (
|
||||
<tr key={cust.id}>
|
||||
<td>#{cust.id}</td>
|
||||
<td style={{ fontWeight: '600' }}>{cust.name}</td>
|
||||
<td>{cust.email}</td>
|
||||
<td>{cust.phone}</td>
|
||||
<td><StatusBadge status={cust.status} /></td>
|
||||
<td>
|
||||
<Link to={`/customers/${cust.id}`} className="btn btn-secondary" style={{ fontSize: '12px', padding: '6px 12px' }}>
|
||||
👁️ View Detail
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '20px', flexWrap: 'wrap', gap: '12px', borderTop: '1px solid var(--glass-border)', paddingTop: '16px' }}>
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>
|
||||
Showing {startIdx} to {endIdx} of {totalItems} items
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>Items per page:</span>
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => {
|
||||
setItemsPerPage(Number(e.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
style={{
|
||||
background: 'var(--bg-tertiary)',
|
||||
color: '#fff',
|
||||
border: '1px solid var(--glass-border)',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '13px',
|
||||
outline: 'none'
|
||||
}}
|
||||
>
|
||||
{[5, 10, 20, 50, 100].map(size => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '12px', padding: '6px 12px' }}
|
||||
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>
|
||||
Page {currentPage} of {totalPages || 1}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '12px', padding: '6px 12px' }}
|
||||
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="table-container">
|
||||
<table className="custom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer ID</th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Phone</th>
|
||||
<th>Status</th>
|
||||
<th>Registered</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredCustomers.map(cust => (
|
||||
<tr key={cust.id}>
|
||||
<td>#{cust.id}</td>
|
||||
<td style={{ fontWeight: '600' }}>{cust.name}</td>
|
||||
<td>{cust.email}</td>
|
||||
<td>{cust.phone}</td>
|
||||
<td><StatusBadge status={cust.status} /></td>
|
||||
<td>{cust.registered}</td>
|
||||
<td>
|
||||
<Link to={`/customers/${cust.id}`} className="btn btn-secondary" style={{ fontSize: '12px', padding: '6px 12px' }}>
|
||||
👁️ View Detail
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,172 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { API_BASE_URL } from '../../config';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
|
||||
const CategoryList = () => {
|
||||
const { token } = useAuth();
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Form states
|
||||
const [name, setName] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [parent, setParent] = useState('');
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch(`${API_BASE_URL}/categories/`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to fetch categories');
|
||||
const data = await res.json();
|
||||
setCategories(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
}, [token]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/categories/create/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
slug: slug.trim() || undefined,
|
||||
parent: parent ? Number(parent) : undefined
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create category');
|
||||
alert('Category/Subcategory created successfully!');
|
||||
setName('');
|
||||
setSlug('');
|
||||
setParent('');
|
||||
fetchCategories();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Category & Subcategory Management</h1>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.5fr', gap: '24px' }}>
|
||||
{/* Left Column: Form */}
|
||||
<div className="glass-card">
|
||||
<h3 className="section-title">Add Category / Subcategory</h3>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="e.g. Traditional Handlooms"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Custom Slug (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="e.g. traditional-handlooms"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Parent Category</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={parent}
|
||||
onChange={(e) => setParent(e.target.value)}
|
||||
style={{ background: 'var(--bg-tertiary)', color: '#fff' }}
|
||||
>
|
||||
<option value="">[None] - Create Top-Level Category</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary" style={{ width: '100%', marginTop: '10px' }}>
|
||||
Create Category
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Display list */}
|
||||
<div className="glass-card">
|
||||
<h3 className="section-title">Existing Hierarchy</h3>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>Loading hierarchy...</div>
|
||||
) : error ? (
|
||||
<div style={{ padding: '20px', color: 'var(--color-danger)' }}>Error: {error}</div>
|
||||
) : categories.length === 0 ? (
|
||||
<div style={{ padding: '20px', color: 'var(--text-secondary)' }}>No categories defined yet.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{categories.map(cat => (
|
||||
<div
|
||||
key={cat.id}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.02)',
|
||||
border: '1px solid var(--glass-border)',
|
||||
borderRadius: '8px',
|
||||
padding: '16px'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: '700', color: 'var(--accent-hover)' }}>🏷️ {cat.name}</span>
|
||||
<span style={{ fontSize: '12px', color: 'var(--text-muted)', fontFamily: 'monospace' }}>slug: {cat.slug}</span>
|
||||
</div>
|
||||
|
||||
{/* Subcategories list */}
|
||||
{cat.subcategories && cat.subcategories.length > 0 ? (
|
||||
<div style={{ marginTop: '12px', paddingLeft: '20px', borderLeft: '2px solid var(--glass-border)', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{cat.subcategories.map(sub => (
|
||||
<div key={sub.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '13px' }}>
|
||||
<span style={{ fontWeight: '500' }}>↳ {sub.name}</span>
|
||||
<span style={{ fontSize: '11px', color: 'var(--text-muted)', fontFamily: 'monospace' }}>slug: {sub.slug}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: '8px', fontSize: '12px', color: 'var(--text-muted)', paddingLeft: '20px' }}>
|
||||
No subcategories mapped yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryList;
|
||||
|
|
@ -1,492 +1,102 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { API_BASE_URL } from '../../config';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
const ProductList = () => {
|
||||
const { token } = useAuth();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [productFilter, setProductFilter] = useState('all');
|
||||
const [sellerFilter, setSellerFilter] = useState('all');
|
||||
const [products, setProducts] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedProduct, setSelectedProduct] = useState(null);
|
||||
const [selectedCatVal, setSelectedCatVal] = useState('');
|
||||
const [products, setProducts] = useState([
|
||||
{ id: 101, name: 'Ultra Wireless Headset 5.0', seller: 'ElectroHub Retailers', price: '₹2,499', stock: 45, status: 'pending_approval' },
|
||||
{ id: 102, name: 'ElectroHub Mechanical Keyboard', seller: 'ElectroHub Retailers', price: '₹4,999', stock: 12, status: 'approved' },
|
||||
{ id: 201, name: 'Pure Organic Honey (500g)', seller: 'A1 Spices Ltd', price: '₹350', stock: 150, status: 'approved' },
|
||||
{ id: 301, name: 'Air Running Shoes (Red)', seller: 'Apex Footwear', price: '₹1,899', stock: 8, status: 'on_hold' },
|
||||
]);
|
||||
|
||||
// Pagination states
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
let url = `${API_BASE_URL}/products/`;
|
||||
const params = [];
|
||||
if (productFilter !== 'all') params.push(`status=${productFilter}`);
|
||||
if (sellerFilter !== 'all') params.push(`seller_status=${sellerFilter}`);
|
||||
if (params.length > 0) {
|
||||
url += `?${params.join('&')}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to fetch products');
|
||||
const data = await res.json();
|
||||
setProducts(data);
|
||||
setCurrentPage(1); // Reset page to 1 when filters change
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const handleStatusChange = (prodId, newStatus) => {
|
||||
setProducts(products.map(p => p.id === prodId ? { ...p, status: newStatus } : p));
|
||||
alert(`Product status changed to ${newStatus}`);
|
||||
};
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/categories/`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCategories(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to fetch categories", err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts();
|
||||
fetchCategories();
|
||||
}, [productFilter, sellerFilter, token]);
|
||||
|
||||
// Set selected category option in select when detail product changes
|
||||
useEffect(() => {
|
||||
if (selectedProduct) {
|
||||
setSelectedCatVal(selectedProduct.category || '');
|
||||
}
|
||||
}, [selectedProduct]);
|
||||
|
||||
const handleStatusChange = async (prodId, action) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/products/${prodId}/${action}/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to perform action: ${action}`);
|
||||
alert(`Product action '${action}' completed successfully`);
|
||||
fetchProducts();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (prodId) => {
|
||||
const handleRemove = (prodId) => {
|
||||
if (window.confirm('Are you sure you want to permanently remove this product?')) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/products/${prodId}/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to delete product');
|
||||
alert('Product permanently deleted');
|
||||
fetchProducts();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
setProducts(products.filter(p => p.id !== prodId));
|
||||
alert('Product permanently deleted');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredProducts = products.filter(p =>
|
||||
p.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(p.supplier_name && p.supplier_name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
p.seller.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// Reset page when search term changes
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm]);
|
||||
|
||||
// Compute pagination details
|
||||
const totalItems = filteredProducts.length;
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
const startIdx = totalItems === 0 ? 0 : (currentPage - 1) * itemsPerPage + 1;
|
||||
const endIdx = Math.min(currentPage * itemsPerPage, totalItems);
|
||||
const currentProducts = filteredProducts.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Product Review & Management</h1>
|
||||
|
||||
<div className="glass-card">
|
||||
{/* Search and Filters row */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', marginBottom: '20px' }}>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Search products by title or seller name..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '20px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{/* Product Status Filter */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '13px', color: 'var(--text-secondary)', fontWeight: '500' }}>Product Status:</span>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
{['all', 'pending', 'approved', 'hold', 'rejected'].map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setProductFilter(status)}
|
||||
className={`btn ${productFilter === status ? 'btn-primary' : 'btn-secondary'}`}
|
||||
style={{ fontSize: '12px', padding: '6px 12px', textTransform: 'capitalize' }}
|
||||
>
|
||||
{status}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Seller Status Filter */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '13px', color: 'var(--text-secondary)', fontWeight: '500' }}>Seller Status:</span>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
{['all', 'unverified', 'pending_approval', 'approved', 'rejected', 'suspended'].map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setSellerFilter(status)}
|
||||
className={`btn ${sellerFilter === status ? 'btn-primary' : 'btn-secondary'}`}
|
||||
style={{ fontSize: '12px', padding: '6px 12px', textTransform: 'capitalize' }}
|
||||
>
|
||||
{status.replace('_', ' ')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Search products by title or seller name..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center' }}>Loading products catalog...</div>
|
||||
) : error ? (
|
||||
<div style={{ padding: '20px', color: 'var(--color-danger)', textAlign: 'center' }}>Error: {error}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-container">
|
||||
<table className="custom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Product Title</th>
|
||||
<th>Seller</th>
|
||||
<th>Price</th>
|
||||
<th>Stock</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentProducts.map(prod => (
|
||||
<tr key={prod.id}>
|
||||
<td>#{prod.id}</td>
|
||||
<td
|
||||
style={{ fontWeight: '600', cursor: 'pointer', color: 'var(--accent-hover)' }}
|
||||
onClick={() => setSelectedProduct(prod)}
|
||||
>
|
||||
{prod.name}
|
||||
</td>
|
||||
<td>{prod.supplier_name || 'N/A'}</td>
|
||||
<td>₹{parseFloat(prod.price).toLocaleString('en-IN')}</td>
|
||||
<td>{prod.stock} items</td>
|
||||
<td><StatusBadge status={prod.status} /></td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
{(prod.status === 'pending' || prod.status === 'pending_approval') && (
|
||||
<>
|
||||
<button className="btn btn-primary" style={{ fontSize: '11px', padding: '4px 8px' }} onClick={() => handleStatusChange(prod.id, 'approve')}>
|
||||
Approve
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ fontSize: '11px', padding: '4px 8px', background: 'rgba(255,255,255,0.05)', color: '#fff', border: '1px solid var(--glass-border)' }} onClick={() => handleStatusChange(prod.id, 'hold')}>
|
||||
Hold
|
||||
</button>
|
||||
<button className="btn btn-danger" style={{ fontSize: '11px', padding: '4px 8px', background: 'var(--color-danger)' }} onClick={() => handleStatusChange(prod.id, 'reject')}>
|
||||
Reject
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{prod.status === 'approved' && (
|
||||
<>
|
||||
<button className="btn btn-secondary" style={{ fontSize: '11px', padding: '4px 8px' }} onClick={() => handleStatusChange(prod.id, 'hold')}>
|
||||
Hold Item
|
||||
</button>
|
||||
<button className="btn btn-danger" style={{ fontSize: '11px', padding: '4px 8px', background: 'var(--color-danger)' }} onClick={() => handleStatusChange(prod.id, 'reject')}>
|
||||
Reject
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(prod.status === 'hold' || prod.status === 'on_hold' || prod.status === 'rejected') && (
|
||||
<button className="btn btn-primary" style={{ fontSize: '11px', padding: '4px 8px' }} onClick={() => handleStatusChange(prod.id, 'approve')}>
|
||||
Activate
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-danger" style={{ fontSize: '11px', padding: '4px 8px', background: 'transparent', border: '1px solid var(--color-danger)', color: 'var(--color-danger)' }} onClick={() => handleRemove(prod.id)}>
|
||||
Remove
|
||||
<div className="table-container">
|
||||
<table className="custom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Product Title</th>
|
||||
<th>Seller</th>
|
||||
<th>Price</th>
|
||||
<th>Stock</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredProducts.map(prod => (
|
||||
<tr key={prod.id}>
|
||||
<td>#{prod.id}</td>
|
||||
<td style={{ fontWeight: '600' }}>{prod.name}</td>
|
||||
<td>{prod.seller}</td>
|
||||
<td>{prod.price}</td>
|
||||
<td>{prod.stock} items</td>
|
||||
<td><StatusBadge status={prod.status} /></td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
{prod.status === 'pending_approval' && (
|
||||
<>
|
||||
<button className="btn btn-primary" style={{ fontSize: '11px', padding: '4px 8px' }} onClick={() => handleStatusChange(prod.id, 'approved')}>
|
||||
Approve
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '20px', flexWrap: 'wrap', gap: '12px', borderTop: '1px solid var(--glass-border)', paddingTop: '16px' }}>
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>
|
||||
Showing {startIdx} to {endIdx} of {totalItems} items
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>Items per page:</span>
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => {
|
||||
setItemsPerPage(Number(e.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
style={{
|
||||
background: 'var(--bg-tertiary)',
|
||||
color: '#fff',
|
||||
border: '1px solid var(--glass-border)',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '13px',
|
||||
outline: 'none'
|
||||
}}
|
||||
>
|
||||
{[5, 10, 20, 50, 100].map(size => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '12px', padding: '6px 12px' }}
|
||||
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>
|
||||
Page {currentPage} of {totalPages || 1}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '12px', padding: '6px 12px' }}
|
||||
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedProduct && (
|
||||
<div className="modal-overlay" onClick={() => setSelectedProduct(null)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2 style={{ fontSize: '20px', fontWeight: '600' }}>Product & Seller Details</h2>
|
||||
<button className="modal-close" onClick={() => setSelectedProduct(null)}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
|
||||
<div className="modal-section">
|
||||
<h3 className="modal-section-title">Product Information</h3>
|
||||
<div className="details-grid">
|
||||
<div className="details-item">
|
||||
<span className="details-label">ID</span>
|
||||
<span className="details-value">#{selectedProduct.id}</span>
|
||||
</div>
|
||||
<div className="details-item">
|
||||
<span className="details-label">Title</span>
|
||||
<span className="details-value">{selectedProduct.name}</span>
|
||||
</div>
|
||||
<div className="details-item">
|
||||
<span className="details-label">Category</span>
|
||||
<span className="details-value">{selectedProduct.category || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="details-item">
|
||||
<span className="details-label">SKU</span>
|
||||
<span className="details-value">{selectedProduct.sku || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="details-item">
|
||||
<span className="details-label">Price</span>
|
||||
<span className="details-value">₹{parseFloat(selectedProduct.price).toLocaleString('en-IN')}</span>
|
||||
</div>
|
||||
<div className="details-item">
|
||||
<span className="details-label">Stock</span>
|
||||
<span className="details-value">{selectedProduct.stock} items</span>
|
||||
</div>
|
||||
<div className="details-item">
|
||||
<span className="details-label">Status</span>
|
||||
<span className="details-value"><StatusBadge status={selectedProduct.status} /></span>
|
||||
</div>
|
||||
<div className="details-item">
|
||||
<span className="details-label">Active Listing</span>
|
||||
<span className="details-value">{selectedProduct.is_active ? 'Yes' : 'No'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{selectedProduct.image && (
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
<span className="details-label" style={{ display: 'block', marginBottom: '4px' }}>Product Image</span>
|
||||
{selectedProduct.image.startsWith('data:image') || selectedProduct.image.startsWith('http') ? (
|
||||
<img src={selectedProduct.image} alt={selectedProduct.name} style={{ maxWidth: '100%', maxHeight: '150px', borderRadius: '6px', objectFit: 'contain' }} />
|
||||
) : (
|
||||
<span className="details-value" style={{ wordBreak: 'break-all', fontSize: '12px', color: 'var(--text-secondary)' }}>{selectedProduct.image}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dynamic Category Mapping Dropdown */}
|
||||
<div className="modal-section" style={{ borderTop: '1px solid var(--glass-border)', paddingTop: '16px' }}>
|
||||
<h3 className="modal-section-title">Category Mapping</h3>
|
||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||
<select
|
||||
className="form-input"
|
||||
value={selectedCatVal}
|
||||
onChange={(e) => setSelectedCatVal(e.target.value)}
|
||||
style={{ background: 'var(--bg-tertiary)', color: '#fff', flex: 1 }}
|
||||
>
|
||||
<option value="">[Unmapped] - Select Category / Subcategory</option>
|
||||
{categories.map(cat => (
|
||||
<React.Fragment key={cat.id}>
|
||||
<option value={cat.slug}>{cat.name} (Parent)</option>
|
||||
{cat.subcategories && cat.subcategories.map(sub => (
|
||||
<option key={sub.id} value={sub.slug}> ↳ {sub.name}</option>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ padding: '10px 16px', fontSize: '13px' }}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/products/${selectedProduct.id}/map-category/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ category: selectedCatVal })
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to map product category');
|
||||
alert('Product category mapped successfully!');
|
||||
setSelectedProduct(prev => ({ ...prev, category: selectedCatVal }));
|
||||
fetchProducts();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-section" style={{ borderTop: '1px solid var(--glass-border)', paddingTop: '16px' }}>
|
||||
<h3 className="modal-section-title">Seller / Supplier Information</h3>
|
||||
<div className="details-grid">
|
||||
<div className="details-item">
|
||||
<span className="details-label">Store Name</span>
|
||||
<span className="details-value">{selectedProduct.supplier_name || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="details-item">
|
||||
<span className="details-label">Account Status</span>
|
||||
<span className="details-value"><StatusBadge status={selectedProduct.supplier_status} /></span>
|
||||
</div>
|
||||
<div className="details-item">
|
||||
<span className="details-label">Email</span>
|
||||
<span className="details-value">{selectedProduct.supplier_email || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="details-item">
|
||||
<span className="details-label">Phone</span>
|
||||
<span className="details-value">{selectedProduct.supplier_phone || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="details-item" style={{ gridColumn: 'span 2' }}>
|
||||
<span className="details-label">GSTIN</span>
|
||||
<span className="details-value">{selectedProduct.supplier_gstin || 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className="modal-footer" style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', borderTop: '1px solid var(--glass-border)', paddingTop: '16px', marginTop: '16px' }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={async () => {
|
||||
await handleStatusChange(selectedProduct.id, 'approve');
|
||||
setSelectedProduct(null);
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ background: 'rgba(255,255,255,0.05)', color: '#fff', border: '1px solid var(--glass-border)' }}
|
||||
onClick={async () => {
|
||||
await handleStatusChange(selectedProduct.id, 'hold');
|
||||
setSelectedProduct(null);
|
||||
}}
|
||||
>
|
||||
Hold
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
style={{ background: 'var(--color-danger)' }}
|
||||
onClick={async () => {
|
||||
await handleStatusChange(selectedProduct.id, 'reject');
|
||||
setSelectedProduct(null);
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
style={{ background: 'transparent', border: '1px solid var(--color-danger)', color: 'var(--color-danger)' }}
|
||||
onClick={async () => {
|
||||
await handleRemove(selectedProduct.id);
|
||||
setSelectedProduct(null);
|
||||
}}
|
||||
>
|
||||
Remove Product
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-danger" style={{ fontSize: '11px', padding: '4px 8px' }} onClick={() => handleStatusChange(prod.id, 'rejected')}>
|
||||
Reject
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{prod.status === 'approved' && (
|
||||
<button className="btn btn-secondary" style={{ fontSize: '11px', padding: '4px 8px' }} onClick={() => handleStatusChange(prod.id, 'on_hold')}>
|
||||
Hold Item
|
||||
</button>
|
||||
)}
|
||||
{prod.status === 'on_hold' && (
|
||||
<button className="btn btn-primary" style={{ fontSize: '11px', padding: '4px 8px' }} onClick={() => handleStatusChange(prod.id, 'approved')}>
|
||||
Activate
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-danger" style={{ fontSize: '11px', padding: '4px 8px', background: 'transparent', border: '1px solid var(--color-danger)', color: 'var(--color-danger)' }} onClick={() => handleRemove(prod.id)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,80 +1,42 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import DocumentViewer from '../../components/DocumentViewer';
|
||||
import { API_BASE_URL } from '../../config';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
const SellerDetail = () => {
|
||||
const { id } = useParams();
|
||||
const { token } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState('profile');
|
||||
const [seller, setSeller] = useState(null);
|
||||
const [documents, setDocuments] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [seller, setSeller] = useState({
|
||||
id: id,
|
||||
name: 'ElectroHub Retailers',
|
||||
email: 'sales@electrohub.in',
|
||||
phone: '+91 98765 43210',
|
||||
status: 'pending_approval',
|
||||
joined: '2026-08-14',
|
||||
address: '404, Tech Park, Block C, Bangalore, India',
|
||||
gstin: '29AAAAA1111A1Z1',
|
||||
pan: 'ABCDE1234F',
|
||||
walletBalance: '₹0.00',
|
||||
documents: [
|
||||
{ type: 'Aadhar Card', filename: 'aadhar_back_front.pdf' },
|
||||
{ type: 'PAN Card', filename: 'pan_card_copy.jpg' },
|
||||
{ type: 'GSTIN Certificate', filename: 'gst_cert_2026.pdf' }
|
||||
],
|
||||
products: [
|
||||
{ id: 101, name: 'Ultra Wireless Headset 5.0', price: '₹2,499', stock: 45, status: 'pending_approval' },
|
||||
{ id: 102, name: 'ElectroHub Mechanical Keyboard', price: '₹4,999', stock: 12, status: 'approved' }
|
||||
]
|
||||
});
|
||||
|
||||
const fetchSellerData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Fetch profile
|
||||
const profileRes = await fetch(`${API_BASE_URL}/sellers/${id}/`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (!profileRes.ok) throw new Error('Failed to load seller profile');
|
||||
const profileData = await profileRes.json();
|
||||
|
||||
// Fetch documents
|
||||
try {
|
||||
const docRes = await fetch(`${API_BASE_URL}/sellers/${id}/documents/`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (docRes.ok) {
|
||||
const docData = await docRes.json();
|
||||
setDocuments(docData);
|
||||
}
|
||||
} catch (docErr) {
|
||||
console.warn("Failed to load documents", docErr);
|
||||
}
|
||||
|
||||
setSeller(profileData);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSellerData();
|
||||
}, [id, token]);
|
||||
|
||||
const handleAction = async (actionPath, payload = {}) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/sellers/${id}/${actionPath}/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error(`Operation failed`);
|
||||
alert(`Action successfully performed!`);
|
||||
fetchSellerData(); // reload status
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
const handleStatusChange = (newStatus) => {
|
||||
setSeller({ ...seller, status: newStatus });
|
||||
alert(`Seller status successfully updated to ${newStatus}`);
|
||||
};
|
||||
|
||||
const handleResetPassword = () => {
|
||||
handleAction('reset-password');
|
||||
alert('Password reset link triggered and sent via WhatsApp Cloud API successfully.');
|
||||
};
|
||||
|
||||
if (loading) return <div style={{ padding: '40px', textAlign: 'center' }}>Loading seller details...</div>;
|
||||
if (error) return <div style={{ padding: '20px', color: 'var(--color-danger)', textAlign: 'center' }}>Error: {error}</div>;
|
||||
if (!seller) return <div style={{ padding: '20px', textAlign: 'center' }}>Seller profile not found.</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
|
|
@ -84,51 +46,34 @@ const SellerDetail = () => {
|
|||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h1 className="page-title" style={{ margin: 0 }}>{seller.name}</h1>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>Seller ID: {seller.id} • Joined: {seller.joined_at ? new Date(seller.joined_at).toLocaleDateString() : 'N/A'}</p>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>Seller ID: {seller.id} • Joined: {seller.joined}</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
|
||||
{seller.status !== 'approved' && (
|
||||
<button className="btn btn-primary" onClick={() => handleAction('approve')}>Approve Seller</button>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
{seller.status === 'pending_approval' && (
|
||||
<>
|
||||
<button className="btn btn-primary" onClick={() => handleStatusChange('approved')}>Approve Seller</button>
|
||||
<button className="btn btn-danger" onClick={() => handleStatusChange('rejected')}>Reject Seller</button>
|
||||
</>
|
||||
)}
|
||||
{seller.status !== 'rejected' && (
|
||||
<button className="btn btn-danger" onClick={() => {
|
||||
const reason = prompt("Enter rejection reason:");
|
||||
if (reason) handleAction('reject', { reason });
|
||||
}}>Reject Seller</button>
|
||||
{seller.status === 'approved' && (
|
||||
<button className="btn btn-danger" onClick={() => handleStatusChange('suspended')}>Suspend Seller</button>
|
||||
)}
|
||||
{seller.status !== 'suspended' ? (
|
||||
<button className="btn btn-warning" style={{ background: '#d97706', borderColor: '#d97706', color: 'white' }} onClick={() => {
|
||||
const reason = prompt("Enter suspension/blocking reason:");
|
||||
if (reason) handleAction('suspend', { reason });
|
||||
}}>Suspend/Block Seller</button>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={() => handleAction('reinstate')}>Reinstate Seller</button>
|
||||
{seller.status === 'suspended' && (
|
||||
<>
|
||||
<button className="btn btn-primary" onClick={() => handleStatusChange('approved')}>Reinstate Seller</button>
|
||||
<button className="btn btn-danger" onClick={() => handleStatusChange('banned')}>Ban Seller</button>
|
||||
</>
|
||||
)}
|
||||
{seller.status === 'banned' && (
|
||||
<button className="btn btn-primary" onClick={() => handleStatusChange('approved')}>Unban & Reinstate</button>
|
||||
)}
|
||||
<button className="btn btn-danger" style={{ background: 'var(--color-danger)' }} onClick={async () => {
|
||||
if (confirm("Are you sure you want to permanently delete this seller account and all associated user logins? This cannot be undone.")) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/sellers/${id}/delete/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to delete seller");
|
||||
alert("Seller account permanently deleted");
|
||||
window.location.href = '/sellers';
|
||||
} catch (err) {
|
||||
alert(`Error deleting seller: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}}>Remove Account</button>
|
||||
<button className="btn btn-secondary" onClick={handleResetPassword}>Reset Password</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', gap: '8px', borderBottom: '1px solid var(--glass-border)', marginBottom: '24px' }}>
|
||||
{['profile', 'documents'].map((tab) => (
|
||||
{['profile', 'documents', 'products', 'wallet'].map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
className="btn"
|
||||
|
|
@ -189,7 +134,56 @@ const SellerDetail = () => {
|
|||
)}
|
||||
|
||||
{activeTab === 'documents' && (
|
||||
<DocumentViewer documents={documents} sellerId={seller.id} refreshDocs={fetchSellerData} />
|
||||
<DocumentViewer documents={seller.documents} sellerId={seller.id} />
|
||||
)}
|
||||
|
||||
{activeTab === 'products' && (
|
||||
<div className="glass-card">
|
||||
<h3 className="section-title">Seller Products</h3>
|
||||
<div className="table-container">
|
||||
<table className="custom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product Name</th>
|
||||
<th>Price</th>
|
||||
<th>Stock</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{seller.products.map((prod) => (
|
||||
<tr key={prod.id}>
|
||||
<td style={{ fontWeight: '600' }}>{prod.name}</td>
|
||||
<td>{prod.price}</td>
|
||||
<td>{prod.stock} items</td>
|
||||
<td><StatusBadge status={prod.status} /></td>
|
||||
<td>
|
||||
<Link to={`/products`} className="btn btn-secondary" style={{ fontSize: '12px', padding: '6px 12px' }}>
|
||||
Manage
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'wallet' && (
|
||||
<div className="glass-card">
|
||||
<h3 className="section-title">Wallet Balance & Payouts</h3>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: '14px' }}>Available Balance</div>
|
||||
<div style={{ fontSize: '36px', fontWeight: '700', color: '#fff' }}>{seller.walletBalance}</div>
|
||||
</div>
|
||||
<button className="btn btn-primary">Process Manual NEFT</button>
|
||||
</div>
|
||||
<h4 style={{ marginBottom: '12px', fontSize: '16px' }}>Payout Transactions</h4>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '14px' }}>No withdrawal payouts processed yet.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,66 +1,26 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { API_BASE_URL } from '../../config';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
const SellerList = () => {
|
||||
const { token } = useAuth();
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sellers, setSellers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Pagination states
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSellers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const url = filter === 'all'
|
||||
? `${API_BASE_URL}/sellers/`
|
||||
: `${API_BASE_URL}/sellers/?status=${filter}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to fetch sellers');
|
||||
const data = await res.json();
|
||||
setSellers(data);
|
||||
setCurrentPage(1); // Reset page to 1 when filter changes
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSellers();
|
||||
}, [filter, token]);
|
||||
const [sellers] = useState([
|
||||
{ id: 1, name: 'Siva Garments', email: 'siva@garments.com', status: 'approved', joined: '2026-05-12', revenue: '₹3,45,000' },
|
||||
{ id: 2, name: 'ElectroHub Retailers', email: 'sales@electrohub.in', status: 'pending_approval', joined: '2026-08-14', revenue: '₹0' },
|
||||
{ id: 3, name: 'A1 Spices Ltd', email: 'info@a1spices.com', status: 'suspended', joined: '2026-02-10', revenue: '₹1,20,000' },
|
||||
{ id: 4, name: 'Apex Footwear', email: 'apex@footwear.com', status: 'approved', joined: '2026-06-01', revenue: '₹5,10,000' },
|
||||
{ id: 5, name: 'Trendy Decors', email: 'decor@trendy.com', status: 'banned', joined: '2026-04-18', revenue: '₹85,000' },
|
||||
]);
|
||||
|
||||
const filteredSellers = sellers.filter(seller => {
|
||||
const matchesStatus = filter === 'all' || seller.status === filter;
|
||||
const matchesSearch = seller.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
seller.email.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
return matchesSearch;
|
||||
return matchesStatus && matchesSearch;
|
||||
});
|
||||
|
||||
// Reset page when search term changes
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm]);
|
||||
|
||||
// Compute pagination details
|
||||
const totalItems = filteredSellers.length;
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
const startIdx = totalItems === 0 ? 0 : (currentPage - 1) * itemsPerPage + 1;
|
||||
const endIdx = Math.min(currentPage * itemsPerPage, totalItems);
|
||||
const currentSellers = filteredSellers.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
|
|
@ -78,7 +38,7 @@ const SellerList = () => {
|
|||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{['all', 'unverified', 'pending_approval', 'approved', 'rejected', 'suspended'].map((status) => (
|
||||
{['all', 'pending_approval', 'approved', 'suspended', 'banned'].map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setFilter(status)}
|
||||
|
|
@ -91,99 +51,38 @@ const SellerList = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center' }}>Loading seller profiles...</div>
|
||||
) : error ? (
|
||||
<div style={{ padding: '20px', color: 'var(--color-danger)', textAlign: 'center' }}>Error: {error}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-container">
|
||||
<table className="custom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Seller Name</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th>Joined Date</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentSellers.map((seller) => (
|
||||
<tr key={seller.id}>
|
||||
<td style={{ fontWeight: '600' }}>{seller.name}</td>
|
||||
<td>{seller.email}</td>
|
||||
<td>
|
||||
<StatusBadge status={seller.status} />
|
||||
</td>
|
||||
<td>{seller.joined_at ? new Date(seller.joined_at).toLocaleDateString() : 'N/A'}</td>
|
||||
<td>
|
||||
<Link to={`/sellers/${seller.id}`} className="btn btn-secondary" style={{ fontSize: '12px', padding: '6px 12px' }}>
|
||||
👁️ View Details
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '20px', flexWrap: 'wrap', gap: '12px', borderTop: '1px solid var(--glass-border)', paddingTop: '16px' }}>
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>
|
||||
Showing {startIdx} to {endIdx} of {totalItems} items
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>Items per page:</span>
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => {
|
||||
setItemsPerPage(Number(e.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
style={{
|
||||
background: 'var(--bg-tertiary)',
|
||||
color: '#fff',
|
||||
border: '1px solid var(--glass-border)',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '13px',
|
||||
outline: 'none'
|
||||
}}
|
||||
>
|
||||
{[5, 10, 20, 50, 100].map(size => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '12px', padding: '6px 12px' }}
|
||||
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>
|
||||
Page {currentPage} of {totalPages || 1}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '12px', padding: '6px 12px' }}
|
||||
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="table-container">
|
||||
<table className="custom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Seller Name</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th>Joined Date</th>
|
||||
<th>Total Sales</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredSellers.map((seller) => (
|
||||
<tr key={seller.id}>
|
||||
<td style={{ fontWeight: '600' }}>{seller.name}</td>
|
||||
<td>{seller.email}</td>
|
||||
<td>
|
||||
<StatusBadge status={seller.status} />
|
||||
</td>
|
||||
<td>{seller.joined}</td>
|
||||
<td>{seller.revenue}</td>
|
||||
<td>
|
||||
<Link to={`/sellers/${seller.id}`} className="btn btn-secondary" style={{ fontSize: '12px', padding: '6px 12px' }}>
|
||||
👁️ View Details
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue