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
745 lines
39 KiB
JavaScript
745 lines
39 KiB
JavaScript
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>
|
||
);
|
||
}
|