Files
AdPlatform-Client/Client-Client/src/components/admin/ClientManagementPanel.jsx
Grae Jones fdb3e117a9
Some checks failed
Client Admin / build-deploy (push) Failing after 8s
Client Client / build-deploy (push) Failing after 3s
Client Registration / build-deploy (push) Failing after 20s
Client Tech / build-deploy (push) Failing after 1s
Client Home / build-deploy (push) Successful in 14s
First build
2026-03-21 17:54:42 -07:00

745 lines
39 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback, memo } from 'react';
import { useAdmin } from '../../context/AdminContext';
import { API_BASE_URL } from '../../auth/authConfig';
// ─── Helpers ────────────────────────────────────────────────
const fmt = (s) => (s || '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const daysSince = (dateStr) => {
if (!dateStr) return '—';
const days = Math.floor((Date.now() - new Date(dateStr).getTime()) / 86400000);
if (days === 0) return 'Today';
if (days === 1) return '1 day ago';
return `${days} days ago`;
};
const fmtDate = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
const fmtDateTime = (d) => d ? new Date(d).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }) : '—';
const fmtCurrency = (cents) => cents != null ? `$${(cents / 100).toFixed(2)}` : '—';
const STATUS_STYLES = {
Active: { bg: '#dcfce7', color: '#166534', border: '#86efac' },
Suspended: { bg: '#fef3c7', color: '#92400e', border: '#fcd34d' },
Cancelled: { bg: '#fee2e2', color: '#991b1b', border: '#fca5a5' },
};
// ═════════════════════════════════════════════════════════════
// MAIN COMPONENT — Switches between Pending and All Clients
// ═════════════════════════════════════════════════════════════
export default function ClientManagementPanel({ activeTab }) {
if (activeTab === 'pending') return <PendingTab />;
if (activeTab === 'allClients') return <AllClientsTab />;
return null;
}
// ═════════════════════════════════════════════════════════════
// PENDING TAB — Registration queue (external data source)
// ═════════════════════════════════════════════════════════════
function PendingTab() {
const { apiCall, sessionToken } = useAdmin();
const [applicants, setApplicants] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [approving, setApproving] = useState(null); // registrationId being approved
const [rejecting, setRejecting] = useState(null); // registrationId being rejected
const [rejectReason, setRejectReason] = useState('');
const fetchPending = useCallback(async () => {
if (!sessionToken) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE_URL}/api/registration/pending`, {
headers: { 'X-Session-Token': sessionToken }
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setApplicants(data.applicants || []);
} catch (err) {
console.info('[Clients] Registration endpoint not available:', err.message);
setApplicants([]);
// Don't show error for expected 404/connection — endpoint may not exist yet
if (!err.message.includes('404') && !err.message.includes('Failed to fetch'))
setError(err.message);
} finally {
setLoading(false);
}
}, [sessionToken]);
useEffect(() => { fetchPending(); }, [fetchPending]);
const handleApprove = async (applicant) => {
setApproving(applicant.registrationId);
const result = await apiCall('/api/admin/clients', 'POST', {
registrationId: applicant.registrationId,
name: applicant.businessName,
websiteUrl: applicant.websiteUrl,
businessCategory: applicant.businessCategory,
description: applicant.businessDescription,
contactName: applicant.contactName,
contactEmail: applicant.contactEmail,
contactPhone: applicant.contactPhone,
entraSubjectId: applicant.entraSubjectId,
clientCategory: applicant.clientCategory || 'General',
});
if (result.ok) {
fetchPending();
} else {
alert(result.error || 'Approval failed');
}
setApproving(null);
};
const handleReject = async (registrationId) => {
const result = await apiCall(`/api/registration/${registrationId}/reject`, 'POST', {
reason: rejectReason,
});
if (result.ok) {
setRejecting(null);
setRejectReason('');
fetchPending();
} else {
alert(result.error || 'Rejection failed');
}
};
if (loading) {
return (
<div className="loading-container">
<div className="spinner"></div>
<p>Checking for pending registrations</p>
</div>
);
}
return (
<div className="table-panel">
<div className="panel-header">
<div>
<h2>Pending Registrations</h2>
<span style={{ fontSize: 13, color: 'var(--text-dim)' }}>
New client applications awaiting review
</span>
</div>
<button className="btn-small" onClick={fetchPending}> Refresh</button>
</div>
{error && (
<div className="error-message" style={{ marginBottom: 16 }}>
<strong>Error:</strong> {error}
</div>
)}
{applicants.length === 0 && !error && (
<div className="empty-state">
<div className="empty-state-icon"></div>
<h3>All caught up!</h3>
<p>No pending registrations to review.</p>
<p style={{ fontSize: 12, color: 'var(--text-dim)', marginTop: 8 }}>
When the registration system sends new applicants, they'll appear here.
</p>
</div>
)}
{applicants.length > 0 && (
<div className="client-cards">
{applicants.map(app => (
<div key={app.registrationId} className="client-card">
<div className="client-card-header">
<div className="client-card-title">
<h3>{app.businessName}</h3>
{app.businessCategory && (
<span className="category-tag">
{fmt(app.businessCategory)}
</span>
)}
{app.clientCategory && app.clientCategory !== 'General' && (
<span className="category-tag category-tag-type">
{app.clientCategory === 'Franchisee' ? '🏪' : '🏗'} {app.clientCategory}
</span>
)}
</div>
<span className="client-card-date">
Registered {daysSince(app.registeredUtc)}
</span>
</div>
{app.businessDescription && (
<p className="client-card-desc">{app.businessDescription}</p>
)}
<div className="client-card-details">
{app.contactName && (
<div className="detail-item">
<span className="detail-label">Contact</span>
<span>{app.contactName}</span>
</div>
)}
{app.contactEmail && (
<div className="detail-item">
<span className="detail-label">Email</span>
<span>{app.contactEmail}</span>
</div>
)}
{app.contactPhone && (
<div className="detail-item">
<span className="detail-label">Phone</span>
<span>{app.contactPhone}</span>
</div>
)}
{app.websiteUrl && (
<div className="detail-item">
<span className="detail-label">Website</span>
<a href={app.websiteUrl} target="_blank" rel="noopener noreferrer">
{app.websiteUrl.replace(/^https?:\/\//, '')}
</a>
</div>
)}
<div className="detail-item">
<span className="detail-label">Payment</span>
<span className={app.paymentVerified ? 'text-success' : 'text-warning'}>
{app.paymentVerified ? ' Verified' : ' Unverified'}
</span>
</div>
</div>
<div className="client-card-actions">
{rejecting === app.registrationId ? (
<div className="reject-form">
<input
type="text"
placeholder="Reason for rejection…"
value={rejectReason}
onChange={e => setRejectReason(e.target.value)}
autoFocus
/>
<button className="btn-small btn-danger"
onClick={() => handleReject(app.registrationId)}
disabled={!rejectReason.trim()}>
Confirm Reject
</button>
<button className="btn-small"
onClick={() => { setRejecting(null); setRejectReason(''); }}>
Cancel
</button>
</div>
) : (
<>
<button
className="btn-primary"
onClick={() => handleApprove(app)}
disabled={approving === app.registrationId}
style={{ minWidth: 100 }}>
{approving === app.registrationId ? 'Approving' : 'Approve'}
</button>
<button
className="btn-small btn-danger"
onClick={() => setRejecting(app.registrationId)}>
Reject
</button>
</>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
// ═════════════════════════════════════════════════════════════
// ALL CLIENTS TAB — From spClientManagement.list
// ═════════════════════════════════════════════════════════════
function AllClientsTab() {
const { apiCall } = useAdmin();
const [clients, setClients] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [statusFilter, setStatusFilter] = useState('all');
const [search, setSearch] = useState('');
const [expandedId, setExpandedId] = useState(null);
const [detail, setDetail] = useState(null); // full detail for expanded client
const [detailLoading, setDetailLoading] = useState(false);
const fetchClients = useCallback(async () => {
setLoading(true);
setError(null);
const statusParam = statusFilter === 'all' ? '' : `?status=${statusFilter}`;
const result = await apiCall(`/api/admin/clients${statusParam}`);
if (result?.ok) {
setClients(Array.isArray(result.clients) ? result.clients : []);
} else {
setError(result?.error || 'Failed to load clients');
setClients([]);
}
setLoading(false);
}, [apiCall, statusFilter]);
useEffect(() => { fetchClients(); }, [fetchClients]);
const fetchDetail = useCallback(async (clientId) => {
setDetailLoading(true);
const result = await apiCall(`/api/admin/clients/${clientId}`);
if (result?.ok) {
setDetail(result);
}
setDetailLoading(false);
}, [apiCall]);
const handleExpand = (clientId) => {
if (expandedId === clientId) {
setExpandedId(null);
setDetail(null);
} else {
setExpandedId(clientId);
fetchDetail(clientId);
}
};
const handleStatusAction = async (action, clientId, reason) => {
const body = reason ? { reason } : {};
const result = await apiCall(`/api/admin/clients/${clientId}/${action}`, 'POST', body);
if (result?.ok) {
fetchClients();
fetchDetail(clientId);
} else {
alert(result?.error || `${action} failed`);
}
};
// Client-side search filter
const filtered = clients.filter(c => {
if (!search.trim()) return true;
const q = search.toLowerCase();
return (c.clientName || '').toLowerCase().includes(q)
|| (c.contactEmail || '').toLowerCase().includes(q)
|| (c.contactName || '').toLowerCase().includes(q);
});
return (
<div className="table-panel">
<div className="panel-header">
<div>
<h2>All Clients</h2>
<span style={{ fontSize: 13, color: 'var(--text-dim)' }}>
{filtered.length} client{filtered.length !== 1 ? 's' : ''}
{statusFilter !== 'all' ? ` (${fmt(statusFilter)})` : ''}
</span>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="text"
placeholder="Search name or email…"
value={search}
onChange={e => setSearch(e.target.value)}
className="client-search"
/>
<select
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
className="client-filter-select"
>
<option value="all">All Statuses</option>
<option value="Active">Active</option>
<option value="Suspended">Suspended</option>
<option value="Cancelled">Cancelled</option>
</select>
<button className="btn-small" onClick={fetchClients}>↻</button>
</div>
</div>
{error && (
<div className="error-message" style={{ marginBottom: 16 }}>
<strong>Error:</strong> {error}
</div>
)}
{loading && (
<div className="loading-container">
<div className="spinner"></div>
<p>Loading clients…</p>
</div>
)}
{!loading && filtered.length === 0 && (
<div className="empty-state">
<div className="empty-state-icon">📋</div>
<h3>No clients found</h3>
<p>{search ? 'Try a different search term.' : 'Approved clients will appear here.'}</p>
</div>
)}
{!loading && filtered.length > 0 && (
<table className="data-table">
<thead>
<tr>
<th style={{ width: 30 }}></th>
<th>Client</th>
<th>Category</th>
<th>Status</th>
<th>Contact</th>
<th>Tier</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{filtered.map(c => (
<ClientRow
key={c.clientId}
client={c}
expanded={expandedId === c.clientId}
detail={expandedId === c.clientId ? detail : null}
detailLoading={expandedId === c.clientId && detailLoading}
onToggle={() => handleExpand(c.clientId)}
onStatusAction={handleStatusAction}
onRefresh={fetchClients}
apiCall={apiCall}
/>
))}
</tbody>
</table>
)}
</div>
);
}
// ─── Client Row (table row + expandable detail) ─────────────
const ClientRow = memo(function ClientRow({
client: c, expanded, detail, detailLoading,
onToggle, onStatusAction, onRefresh, apiCall,
}) {
const [actionConfirm, setActionConfirm] = useState(null); // { action, label }
const [reason, setReason] = useState('');
const [editing, setEditing] = useState(false);
const [editFields, setEditFields] = useState({});
const [saving, setSaving] = useState(false);
const style = STATUS_STYLES[c.status] || {};
const handleConfirmedAction = async () => {
await onStatusAction(actionConfirm.action, c.clientId, reason);
setActionConfirm(null);
setReason('');
};
const handleSaveEdit = async () => {
setSaving(true);
const result = await apiCall(`/api/admin/clients/${c.clientId}`, 'PUT', {
...editFields,
clientCategory: editFields.clientCategory,
});
if (result?.ok) {
setEditing(false);
setEditFields({});
onRefresh();
} else {
alert(result?.error || 'Update failed');
}
setSaving(false);
};
const startEdit = () => {
setEditFields({
name: c.clientName || '',
contactName: c.contactName || '',
contactEmail: c.contactEmail || '',
contactPhone: c.contactPhone || '',
websiteUrl: c.websiteUrl || '',
description: c.description || '',
notes: c.notes || '',
clientCategory: c.clientCategoryName || 'General',
});
setEditing(true);
};
return (
<>
<tr className={`client-row ${expanded ? 'expanded' : ''}`} onClick={onToggle}>
<td className="expand-toggle">{expanded ? '' : ''}</td>
<td>
<div className="client-name-cell">
<strong>{c.clientName}</strong>
{c.websiteUrl && (
<span className="client-url">{c.websiteUrl.replace(/^https?:\/\//, '')}</span>
)}
</div>
</td>
<td>
{c.categoryIcon && <span style={{ marginRight: 4 }}>{c.categoryIcon}</span>}
{c.categoryName ? fmt(c.categoryName) : ''}
</td>
<td>
<span className="status-badge" style={{
background: style.bg, color: style.color, border: `1px solid ${style.border}`
}}>
{c.status}
</span>
</td>
<td>{c.contactEmail || ''}</td>
<td>{fmt(c.serviceTier || 'self_service')}</td>
<td>{fmtDate(c.createdUtc)}</td>
</tr>
{expanded && (
<tr className="client-detail-row">
<td colSpan="7">
<div className="client-detail">
{detailLoading && <p style={{ color: 'var(--text-dim)' }}>Loading detail…</p>}
{!detailLoading && !editing && (
<>
{/* Profile Section */}
<div className="detail-section">
<div className="detail-section-header">
<h4>Profile</h4>
<button className="btn-small" onClick={startEdit}>Edit</button>
</div>
<div className="detail-grid">
<DetailField label="Business Name" value={c.clientName} />
<DetailField label="Contact Name" value={c.contactName} />
<DetailField label="Email" value={c.contactEmail} />
<DetailField label="Phone" value={c.contactPhone} />
<DetailField label="Website" value={c.websiteUrl} link />
<DetailField label="Category"
value={c.categoryName ? `${c.categoryIcon || ''} ${fmt(c.categoryName)}` : null} />
<DetailField label="Account Type"
value={c.clientCategoryIcon && c.clientCategoryName ? `${c.clientCategoryIcon} ${c.clientCategoryName}` : (c.clientCategoryName || 'General')} />
<DetailField label="Description" value={c.description} wide />
</div>
</div>
{/* Service Config */}
<div className="detail-section">
<h4>Service Configuration</h4>
<div className="detail-grid">
<DetailField label="Tier" value={fmt(c.serviceTier || 'self_service')} />
<DetailField label="Monthly Fee" value={fmtCurrency(c.monthlyFeeCents)} />
<DetailField label="Ad Spend Margin"
value={c.adSpendMarginPct != null ? `${c.adSpendMarginPct}%` : null} />
</div>
</div>
{/* Admin Notes */}
<div className="detail-section">
<h4>Admin Notes</h4>
<p className="admin-notes">{c.notes || 'No notes.'}</p>
</div>
{/* Status Actions */}
<div className="detail-section">
<h4>Actions</h4>
{actionConfirm ? (
<div className="action-confirm">
<p>
<strong>{actionConfirm.label}</strong> this client?
{(actionConfirm.action === 'suspend' || actionConfirm.action === 'cancel') && (
<span> Active campaigns will be paused.</span>
)}
</p>
{(actionConfirm.action === 'suspend' || actionConfirm.action === 'cancel') && (
<input
type="text"
placeholder="Reason (required)…"
value={reason}
onChange={e => setReason(e.target.value)}
autoFocus
/>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button className="btn-primary" onClick={handleConfirmedAction}
disabled={(actionConfirm.action === 'suspend' || actionConfirm.action === 'cancel') && !reason.trim()}
style={{ minWidth: 80 }}>
Confirm
</button>
<button className="btn-cancel"
onClick={() => { setActionConfirm(null); setReason(''); }}>
Cancel
</button>
</div>
</div>
) : (
<div className="action-buttons">
{c.status === 'Active' && (
<>
<button className="btn-small"
style={{ background: '#fef3c7', color: '#92400e' }}
onClick={() => setActionConfirm({ action: 'suspend', label: 'Suspend' })}>
Suspend
</button>
<button className="btn-small btn-danger"
onClick={() => setActionConfirm({ action: 'cancel', label: 'Cancel' })}>
Cancel Account
</button>
</>
)}
{c.status === 'Suspended' && (
<>
<button className="btn-primary" style={{ fontSize: 12, padding: '5px 12px' }}
onClick={() => setActionConfirm({ action: 'reactivate', label: 'Reactivate' })}>
Reactivate
</button>
<button className="btn-small btn-danger"
onClick={() => setActionConfirm({ action: 'cancel', label: 'Cancel' })}>
Cancel Account
</button>
</>
)}
{c.status === 'Cancelled' && (
<p style={{ fontSize: 13, color: 'var(--text-dim)' }}>
Account cancelled {c.cancelledUtc ? `on ${fmtDate(c.cancelledUtc)}` : ''}.
{c.cancelledReason && <span> Reason: {c.cancelledReason}</span>}
</p>
)}
</div>
)}
</div>
{/* Status History */}
{detail?.statusHistory?.length > 0 && (
<div className="detail-section">
<h4>Status History</h4>
<div className="status-timeline">
{detail.statusHistory.map((entry, i) => (
<div key={i} className="timeline-entry">
<div className="timeline-dot" />
<div className="timeline-content">
<span className="timeline-action">
{entry.fromStatus
? `${entry.fromStatus} → ${entry.toStatus}`
: `Created as ${entry.toStatus}`
}
</span>
{entry.reason && (
<span className="timeline-reason">— {entry.reason}</span>
)}
<span className="timeline-meta">
{fmtDateTime(entry.changedUtc)}
{entry.changedByName && ` by ${entry.changedByName}`}
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Linked Users */}
{detail?.users?.length > 0 && (
<div className="detail-section">
<h4>Users ({detail.users.length})</h4>
<table className="data-table" style={{ fontSize: 12 }}>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{detail.users.map(u => (
<tr key={u.userId}>
<td>{u.displayName || ''}</td>
<td>{u.email || ''}</td>
<td>{u.role}</td>
<td>{u.status}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
{/* Edit Mode */}
{!detailLoading && editing && (
<div className="detail-section">
<h4>Edit Client</h4>
<div className="edit-form">
<div className="form-row-inline">
<EditField label="Business Name" value={editFields.name}
onChange={v => setEditFields(f => ({ ...f, name: v }))} />
<EditField label="Contact Name" value={editFields.contactName}
onChange={v => setEditFields(f => ({ ...f, contactName: v }))} />
</div>
<div className="form-row-inline">
<EditField label="Email" value={editFields.contactEmail}
onChange={v => setEditFields(f => ({ ...f, contactEmail: v }))} />
<EditField label="Phone" value={editFields.contactPhone}
onChange={v => setEditFields(f => ({ ...f, contactPhone: v }))} />
</div>
<div className="form-row-inline">
<EditField label="Website URL" value={editFields.websiteUrl}
onChange={v => setEditFields(f => ({ ...f, websiteUrl: v }))} />
</div>
<div className="form-row-inline">
<EditField label="Description" value={editFields.description}
onChange={v => setEditFields(f => ({ ...f, description: v }))} wide />
</div>
<div className="form-row-inline">
<EditField label="Admin Notes" value={editFields.notes}
onChange={v => setEditFields(f => ({ ...f, notes: v }))} wide />
</div>
<div className="form-row-inline">
<div className="form-group">
<label>Account Type</label>
<select
value={editFields.clientCategory || 'General'}
onChange={e => setEditFields(f => ({ ...f, clientCategory: e.target.value }))}
>
<option value="General">🏢 Independent Business</option>
<option value="Franchisee">🏪 Franchisee</option>
<option value="Franchisor">🏗️ Franchisor / Brand</option>
</select>
</div>
</div>
<div className="form-buttons" style={{ marginTop: 12 }}>
<button className="btn-cancel" onClick={() => setEditing(false)} disabled={saving}>
Cancel
</button>
<button className="btn-primary" onClick={handleSaveEdit} disabled={saving}>
{saving ? 'Saving' : 'Save Changes'}
</button>
</div>
</div>
</div>
)}
</div>
</td>
</tr>
)}
</>
);
});
// ─── Small helper components ────────────────────────────────
function DetailField({ label, value, link, wide }) {
return (
<div className={`detail-field ${wide ? 'detail-field-wide' : ''}`}>
<span className="detail-label">{label}</span>
{link && value ? (
<a href={value.startsWith('http') ? value : `https://${value}`}
target="_blank" rel="noopener noreferrer">
{value.replace(/^https?:\/\//, '')}
</a>
) : (
<span>{value || ''}</span>
)}
</div>
);
}
function EditField({ label, value, onChange, wide }) {
return (
<div className={`form-group ${wide ? 'form-group-wide' : ''}`}>
<label>{label}</label>
<input type="text" value={value || ''} onChange={e => onChange(e.target.value)} />
</div>
);
}