First build
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

This commit is contained in:
Grae Jones
2026-03-21 17:54:42 -07:00
parent 3647b304a3
commit fdb3e117a9
203 changed files with 35733 additions and 18189 deletions

View File

@@ -0,0 +1,744 @@
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>
);
}