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

@@ -1,6 +1,6 @@
{
"presets": [
"@babel/preset-env",
["@babel/preset-react", { "runtime": "automatic" }]
]
}
"presets": [
"@babel/preset-env",
["@babel/preset-react", { "runtime": "automatic" }]
]
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>AdPlatform Management</title><link rel="preconnect" href="https://fonts.googleapis.com"><link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"></head><body><div id="root"></div><script defer="defer" src="bundle.js"></script></body></html>
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>AdPlatform Management</title><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet"></head><body><div id="root"></div><script defer="defer" src="bundle.js"></script></body></html>

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,8 @@
"dependencies": {
"@azure/msal-browser": "^3.6.0",
"@azure/msal-react": "^2.0.12",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},

View File

@@ -5,7 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AdPlatform Management</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>

View File

@@ -1,43 +1,39 @@
import React, { useState } from 'react';
import React from 'react';
import { useAuth } from '../auth/AuthProvider';
import { AdminProvider } from '../context/AdminContext';
import Shell from '../components/Shell';
import SignInOverlay from '../components/SignInOverlay';
import RegistrationForm from '../components/RegistrationForm';
import Dashboard from '../components/Dashboard';
export default function App() {
const { isLoading, isSignedIn, isRegistered, needsRegistration } = useAuth();
const { authState } = useAuth();
if (isLoading) {
return (
<Shell>
<div className="loading-container">
<div className="spinner"></div>
<p>Loading...</p>
</div>
</Shell>
);
switch (authState) {
case 'active':
return (
<AdminProvider>
<Dashboard />
</AdminProvider>
);
case 'authenticating':
return (
<Shell>
<div className="loading-container">
<div className="spinner"></div>
<p>Signing in</p>
</div>
</Shell>
);
case 'needsRegistration':
case 'error':
case 'unauthenticated':
default:
return (
<Shell>
<SignInOverlay />
</Shell>
);
}
if (!isSignedIn) {
return (
<Shell>
<SignInOverlay />
</Shell>
);
}
if (needsRegistration && !isRegistered) {
return (
<Shell>
<RegistrationForm />
</Shell>
);
}
return (
<Shell>
<Dashboard />
</Shell>
);
}

View File

@@ -1,219 +1,157 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { PublicClientApplication, InteractionStatus } from '@azure/msal-browser';
import { MsalProvider, useMsal, useIsAuthenticated } from '@azure/msal-react';
import { msalConfig, loginRequest, API_BASE_URL, GATEWAY_API_URL } from './authConfig';
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { PublicClientApplication } from '@azure/msal-browser';
import { MsalProvider, useMsal } from '@azure/msal-react';
import { msalConfig, loginRequest, MGMT_SCOPE } from './authConfig';
// ─── MSAL instance (singleton) ───
const msalInstance = new PublicClientApplication(msalConfig);
const msalReady = msalInstance.initialize();
// ─── Context ─────────────────────────────────────────────────────────────────
const AuthContext = createContext(null);
export const useAuth = () => useContext(AuthContext);
export function useAuth() {
return useContext(AuthContext);
// ─── Decode roles from a JWT access token ────────────────────────────────────
function extractRole(accessToken) {
try {
const payload = JSON.parse(atob(accessToken.split('.')[1]));
const roles = payload.roles || [];
if (roles.includes('Staff.Admin')) return 'Admin';
if (roles.includes('Staff.Tech')) return 'Tech';
return null;
} catch {
return null;
}
}
function AuthProviderInner({ children }) {
const { instance, accounts, inProgress } = useMsal();
const isAuthenticated = useIsAuthenticated();
const [authState, setAuthState] = useState({
isLoading: true,
isSignedIn: false,
isRegistered: false,
needsRegistration: false,
user: null,
session: null,
clients: [],
error: null,
// ─── Inner provider (needs useMsal) ──────────────────────────────────────────
function AuthInner({ children }) {
const { instance } = useMsal();
const [authState, setAuthState] = useState('authenticating');
const [sessionUser, setSessionUser] = useState(null);
const [userRole, setUserRole] = useState(null); // 'Admin' | 'Tech' | null
const buildUser = (account, role = null) => ({
userId: account.localAccountId,
email: account.username,
displayName: account.name || account.username,
role,
});
// Eagerly acquire access token to extract role from claims
async function resolveRole(account) {
try {
const result = await instance.acquireTokenSilent({ scopes: [MGMT_SCOPE], account });
return extractRole(result.accessToken);
} catch {
return null;
}
}
// ─── Init: handle redirect result or restore active account ─────────────
useEffect(() => {
let cancelled = false;
async function init() {
await msalReady;
try {
const response = await instance.handleRedirectPromise();
if (response?.account && !cancelled) {
instance.setActiveAccount(response.account);
const role = await resolveRole(response.account);
if (cancelled) return;
setUserRole(role);
setSessionUser(buildUser(response.account, role));
setAuthState('active');
return;
}
} catch (err) {
console.warn('[Auth] Redirect error:', err.message);
}
if (cancelled) return;
const account = instance.getActiveAccount() || instance.getAllAccounts()[0];
if (account) {
instance.setActiveAccount(account);
const role = await resolveRole(account);
if (cancelled) return;
setUserRole(role);
setSessionUser(buildUser(account, role));
setAuthState('active');
} else {
setAuthState('unauthenticated');
}
}
init();
return () => { cancelled = true; };
}, [instance]);
// ─── Acquire a fresh access token for Management API calls ──────────────
const getAccessToken = useCallback(async () => {
const account = instance.getActiveAccount() || instance.getAllAccounts()[0];
if (!account) return null;
try {
const result = await instance.acquireTokenSilent({ scopes: [MGMT_SCOPE], account });
return result?.accessToken ?? null;
} catch (err) {
console.warn('[Auth] Silent token failed, redirecting to login:', err.message);
instance.loginRedirect({ scopes: [MGMT_SCOPE] });
return null;
}
}, [instance]);
// ─── Actions ─────────────────────────────────────────────────────────────
const signIn = useCallback(() => {
setAuthState('authenticating');
instance.loginRedirect(loginRequest).catch(err => {
console.error('[Auth] Login redirect error:', err);
setAuthState('unauthenticated');
});
}, [instance]);
const getIdToken = useCallback(async () => {
if (accounts.length === 0) return null;
try {
const response = await instance.acquireTokenSilent({
...loginRequest,
account: accounts[0],
});
return response.idToken;
} catch (error) {
console.error('Token acquisition failed:', error);
return null;
}
}, [instance, accounts]);
const signOut = useCallback(() => {
setSessionUser(null);
setUserRole(null);
setAuthState('unauthenticated');
instance.logoutRedirect({ postLogoutRedirectUri: window.location.origin });
}, [instance]);
const checkRegistration = useCallback(async () => {
const token = await getIdToken();
if (!token) return null;
const retrySignIn = useCallback(() => {
setAuthState('unauthenticated');
instance.logoutRedirect({ postLogoutRedirectUri: window.location.origin }).catch(() => {
setAuthState('unauthenticated');
});
}, [instance]);
try {
const response = await fetch(`${API_BASE_URL}/api/onboarding/status`, {
headers: { 'Authorization': `Bearer ${token}` }
});
return await response.json();
} catch (error) {
console.error('Registration check failed:', error);
return null;
}
}, [getIdToken]);
const value = {
authState,
sessionUser,
userRole, // 'Admin' | 'Tech' | null
isAdmin: userRole === 'Admin',
isTech: userRole === 'Tech',
isStaff: userRole !== null,
isAuthenticated: authState === 'active',
getAccessToken,
getIdToken: getAccessToken, // alias — AdminContext still uses this name
signIn,
signOut,
retrySignIn,
};
const register = useCallback(async (clientName) => {
const token = await getIdToken();
if (!token) throw new Error('Not authenticated');
const response = await fetch(`${API_BASE_URL}/api/onboarding/register`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ clientName }),
});
const data = await response.json();
if (!data.ok) throw new Error(data.error || 'Registration failed');
return data;
}, [getIdToken]);
const createSession = useCallback(async () => {
const token = await getIdToken();
if (!token) throw new Error('Not authenticated');
const response = await fetch(`${GATEWAY_API_URL}/api/auth/session`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
const data = await response.json();
if (!data.ok) throw new Error(data.error || 'Session creation failed');
return data.data || data;
}, [getIdToken]);
const signIn = useCallback(async () => {
try {
await instance.loginPopup(loginRequest);
} catch (error) {
console.error('Login failed:', error);
setAuthState(prev => ({ ...prev, error: error.message }));
}
}, [instance]);
const signOut = useCallback(async () => {
await instance.logoutPopup();
setAuthState({
isLoading: false,
isSignedIn: false,
isRegistered: false,
needsRegistration: false,
user: null,
session: null,
clients: [],
error: null,
});
}, [instance]);
useEffect(() => {
if (inProgress !== InteractionStatus.None) return;
const initAuth = async () => {
if (!isAuthenticated || accounts.length === 0) {
setAuthState(prev => ({ ...prev, isLoading: false, isSignedIn: false }));
return;
}
const account = accounts[0];
setAuthState(prev => ({
...prev,
isSignedIn: true,
user: { name: account.name, email: account.username },
}));
const status = await checkRegistration();
if (status?.ok) {
if (status.isRegistered) {
try {
const session = await createSession();
setAuthState(prev => ({
...prev,
isLoading: false,
isRegistered: true,
needsRegistration: false,
session,
clients: status.clients || [],
}));
} catch (error) {
setAuthState(prev => ({
...prev,
isLoading: false,
isRegistered: true,
error: error.message,
}));
}
} else {
setAuthState(prev => ({
...prev,
isLoading: false,
isRegistered: false,
needsRegistration: true,
}));
}
} else {
setAuthState(prev => ({
...prev,
isLoading: false,
error: 'Failed to check registration status',
}));
}
};
initAuth();
}, [isAuthenticated, accounts, inProgress, checkRegistration, createSession]);
const completeRegistration = useCallback(async (clientName) => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
try {
await register(clientName);
const session = await createSession();
setAuthState(prev => ({
...prev,
isLoading: false,
isRegistered: true,
needsRegistration: false,
session,
}));
return true;
} catch (error) {
setAuthState(prev => ({
...prev,
isLoading: false,
error: error.message,
}));
return false;
}
}, [register, createSession]);
const value = {
...authState,
signIn,
signOut,
getIdToken,
completeRegistration,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function AuthProvider({ children }) {
return (
<MsalProvider instance={msalInstance}>
<AuthProviderInner>{children}</AuthProviderInner>
</MsalProvider>
);
// ─── Outer wrapper ────────────────────────────────────────────────────────────
export default function AuthProvider({ children }) {
return (
<MsalProvider instance={msalInstance}>
<AuthInner>{children}</AuthInner>
</MsalProvider>
);
}
export { AuthProvider };

View File

@@ -1,25 +1,54 @@
/**
* MSAL Configuration for Entra External ID
*/
// ─── Entra org tenant — staff only ───────────────────────────────────────────
const TENANT_ID = '0be4c23a-6941-4bdb-b397-a4faf88de4b3';
// MediaPoint-Admin app registration clientId (Admin SPA — NOT the Graph API app)
// b0f29246... is the Graph API server app — replace this with the correct value
// from Entra portal → App Registrations → MediaPoint-Admin → Application (client) ID
const CLIENT_ID = 'b0f29246-91e7-4615-96db-5de9b6f8da2e'; // TODO: replace with MediaPoint-Admin clientId
const AUTHORITY = `https://login.microsoftonline.com/${TENANT_ID}`;
// ─── Management API resource ──────────────────────────────────────────────────
// This is the audience for access tokens sent to Management API.
// Scope requests this audience so tokens carry the `roles` claim.
export const MGMT_APP_ID = '4e4d69c3-558a-4a27-a689-17bd397175e5'; // MediaPoint-Management appId
export const MGMT_SCOPE = `api://${MGMT_APP_ID}/access_as_user`;
// ─── URLs ─────────────────────────────────────────────────────────────────────
export const MANAGEMENT_URL = 'https://adpmgmt.usimdev.com';
export const REGISTRATION_URL = 'https://adpregist.usimdev.com';
// ─── MSAL configuration ───────────────────────────────────────────────────────
export const msalConfig = {
auth: {
clientId: '154c9111-14a0-4c0f-8132-7bc68254a74e',
authority: 'https://login.microsoftonline.com/891f98f1-ed34-42a1-9b6c-28b0554d92c2',
redirectUri: window.location.origin,
postLogoutRedirectUri: window.location.origin,
},
cache: {
cacheLocation: 'sessionStorage',
storeAuthStateInCookie: false,
auth: {
clientId: CLIENT_ID,
authority: AUTHORITY,
redirectUri: window.location.origin,
postLogoutRedirectUri: window.location.origin,
knownAuthorities: ['login.microsoftonline.com'],
navigateToLoginRequestUrl: true,
},
cache: {
cacheLocation: 'localStorage',
storeAuthStateInCookie: false,
},
system: {
loggerOptions: {
loggerCallback: (level, message, containsPii) => {
if (containsPii) return;
if (level === 0) console.error(message);
else if (level === 1) console.warn(message);
},
logLevel: 1, // warn + error only
},
},
};
// Login request — include Management API scope so access tokens carry `roles`
export const loginRequest = {
scopes: ['openid', 'profile', 'email'],
scopes: ['openid', 'profile', 'email', MGMT_SCOPE],
prompt: 'select_account',
};
// Management API base URL
export const API_BASE_URL = 'https://usim-adp-management.lemonbeach-1e8e273b.westus.azurecontainerapps.io';
// Gateway API base URL
export const GATEWAY_API_URL = 'https://adsapi.usimdev.com';
// ─── Aliases ──────────────────────────────────────────────────────────────────
export const MGMT_BASE = MANAGEMENT_URL;

View File

@@ -1,119 +1,170 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../auth/AuthProvider';
import { API_BASE_URL } from '../auth/authConfig';
import ClientsPanel from './admin/ClientsPanel';
import UsersPanel from './admin/UsersPanel';
import React, { useRef } from 'react';
import { useAdmin, CATEGORY_LABELS, TAB_ENDPOINTS } from '../context/AdminContext';
import { TemplatesProvider } from '../context/TemplatesContext';
import { ObjectiveMappingsProvider } from '../context/ObjectiveMappingsContext';
import Sidebar from './Sidebar';
import ClientUsersPanel from './admin/ClientUsersPanel';
import SessionsPanel from './admin/SessionsPanel';
import TemplatesPanel from './admin/TemplatesPanel';
import ObjectiveMappingPanel from './admin/ObjectiveMappingPanel';
import CampaignsPanel from './admin/CampaignsPanel';
import IntelligencePanel from './admin/IntelligencePanel';
import ModifiersPanel from './admin/ModifiersPanel';
import ClientManagementPanel from './admin/ClientManagementPanel';
import ClientActivityPanel from './admin/ClientActivityPanel';
import ClientDocumentsPanel from './admin/ClientDocumentsPanel';
import DocumentsPanel from './admin/DocumentsPanel';
export default function Dashboard() {
const { session } = useAuth();
const [activeTab, setActiveTab] = useState('overview');
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [refreshKey, setRefreshKey] = useState(0);
const fetchData = useCallback(async (endpoint) => {
if (!session?.sessionToken) return null;
setLoading(true);
setError(null);
try {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
headers: { 'X-Session-Token': session.sessionToken }
});
const result = await response.json();
if (!result.ok) throw new Error(result.error || 'Request failed');
return result;
} catch (err) {
setError(err.message);
return null;
} finally {
setLoading(false);
}
}, [session]);
const refresh = () => setRefreshKey(k => k + 1);
useEffect(() => {
const loadData = async () => {
let result = null;
switch (activeTab) {
case 'overview':
result = await fetchData('/api/monitoring/health');
break;
case 'clients':
result = await fetchData('/api/admin/clients');
break;
case 'users':
result = await fetchData('/api/admin/users');
break;
case 'sessions':
result = await fetchData('/api/admin/sessions');
break;
}
setData(result);
};
loadData();
}, [activeTab, fetchData, refreshKey]);
const tabs = [
{ id: 'overview', label: 'Overview' },
{ id: 'clients', label: 'Clients' },
{ id: 'users', label: 'Users' },
{ id: 'sessions', label: 'Sessions' },
];
const {
user, userRole, isAuthenticated,
activeCategory, activeTab, tabs, collapsed,
setActiveCategory, setActiveTab, setCollapsed,
data, loading, error,
} = useAdmin();
const tabBarRef = useRef(null);
return (
<div className="dashboard">
<div className="dashboard-header">
<h1>Management Dashboard</h1>
<div className="dashboard-info">
<span className="info-item"><strong>Client:</strong> {session?.clientName}</span>
<span className="info-item"><strong>Role:</strong> {session?.role}</span>
<div className="dashboard-layout">
{/* Sidebar */}
<Sidebar
activeCategory={activeCategory}
onSelectCategory={setActiveCategory}
collapsed={collapsed}
onToggleCollapse={() => setCollapsed(c => !c)}
/>
{/* Main area */}
<div className="dashboard-main">
{/* Header with category title + tabs */}
<header className="dashboard-header">
<div className="dashboard-header-top">
<h1 className="dashboard-title">
{CATEGORY_LABELS[activeCategory] || activeCategory}
</h1>
<div className="dashboard-header-right">
<span className="dashboard-meta">
{user?.displayName && <span>{user.displayName}</span>}
{userRole && <span><strong>Role:</strong> {userRole}</span>}
</span>
</div>
</div>
{/* Horizontal tabs (within the current category) */}
{tabs.length > 1 && (
<div className="dashboard-tabs" ref={tabBarRef}>
{tabs.map(tab => (
<button
key={tab.id}
className={`tab-btn ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
)}
</header>
{/* Content area */}
<div className="dashboard-content">
{/* Loading state — full spinner only on initial load */}
{loading && !data && (
<div className="loading-container">
<div className="spinner"></div>
<p>Loading...</p>
</div>
)}
{/* Refreshing indicator — subtle, panels stay mounted */}
{loading && data && (
<div style={{ padding: '4px 12px', fontSize: '12px', color: '#888', textAlign: 'right' }}>
Refreshing
</div>
)}
{/* Error state */}
{error && !loading && (
<div className="error-message" style={{ margin: '0 0 16px 0' }}>
<strong>Error:</strong> {error}
</div>
)}
{/* Data-driven panels — stay mounted during refresh */}
{(data || (error && activeTab === 'templates') || activeTab === 'objectives'
|| activeTab === 'modifiers'
|| activeTab === 'performance' || activeTab === 'insights' || activeTab === 'analysis'
|| activeTab === 'pending' || activeTab === 'allClients'
|| activeTab === 'clientActivity'
|| activeTab === 'clientDocuments'
|| activeTab === 'documents') && (
<div className="data-panel" style={{ opacity: loading ? 0.6 : 1, transition: 'opacity 0.2s' }}>
{activeTab === 'overview' && data && <OverviewPanel data={data} />}
{activeTab === 'sessions' && data && <SessionsPanel />}
{activeTab === 'clientUsers' && data && <ClientUsersPanel />}
{activeTab === 'templates' && (
<TemplatesProvider>
<TemplatesPanel />
</TemplatesProvider>
)}
{activeTab === 'objectives' && (
<ObjectiveMappingsProvider>
<ObjectiveMappingPanel />
</ObjectiveMappingsProvider>
)}
{activeTab === 'modifiers' && <ModifiersPanel />}
{activeTab === 'campaigns' && data && <CampaignsPanel />}
{(activeTab === 'performance' || activeTab === 'insights' || activeTab === 'analysis') && (
<IntelligencePanel activeTab={activeTab} />
)}
{(activeTab === 'pending' || activeTab === 'allClients') && (
<ClientManagementPanel activeTab={activeTab} />
)}
{activeTab === 'clientActivity' && <ClientActivityPanel />}
{activeTab === 'clientDocuments' && <ClientDocumentsPanel />}
{activeTab === 'documents' && <DocumentsPanel />}
</div>
)}
{/* Placeholder for tabs without API endpoints */}
{!loading && !error && !data && !TAB_ENDPOINTS[activeTab]
&& activeTab !== 'pending' && activeTab !== 'allClients'
&& activeTab !== 'clientActivity'
&& activeTab !== 'clientDocuments'
&& activeTab !== 'performance' && activeTab !== 'insights' && activeTab !== 'analysis'
&& activeTab !== 'documents' && (
<div className="placeholder-panel">
<div className="placeholder-icon">🚧</div>
<h3>{activeTab.charAt(0).toUpperCase() + activeTab.slice(1)}</h3>
<p>This section is under development.</p>
</div>
)}
{/* Fallback */}
{!loading && !error && !data && activeTab !== 'templates'
&& activeTab !== 'modifiers'
&& activeTab !== 'pending' && activeTab !== 'allClients'
&& activeTab !== 'clientActivity'
&& activeTab !== 'clientDocuments'
&& activeTab !== 'performance' && activeTab !== 'insights' && activeTab !== 'analysis'
&& activeTab !== 'documents'
&& TAB_ENDPOINTS[activeTab] && (
<div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>
<p>No data loaded. Check the browser console for logs.</p>
</div>
)}
</div>
</div>
<nav className="dashboard-tabs">
{tabs.map(tab => (
<button
key={tab.id}
className={`tab-btn ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</nav>
<div className="dashboard-content">
{loading && (
<div className="loading-container">
<div className="spinner"></div>
<p>Loading...</p>
</div>
)}
{error && <div className="error-message">{error}</div>}
{!loading && !error && data && (
<div className="data-panel">
{activeTab === 'overview' && <OverviewPanel data={data} />}
{activeTab === 'clients' && <ClientsPanel data={data} sessionToken={session?.sessionToken} onRefresh={refresh} />}
{activeTab === 'users' && <UsersPanel data={data} sessionToken={session?.sessionToken} onRefresh={refresh} />}
{activeTab === 'sessions' && <SessionsPanel data={data} sessionToken={session?.sessionToken} onRefresh={refresh} />}
</div>
)}
</div>
</div>
);
}
// ─── Overview Panel (lightweight, stays local) ───────────────
function OverviewPanel({ data }) {
return (
<div className="overview-panel">
<h2>System Overview</h2>
<div className="stats-grid">
<StatCard label="Active Clients" value={data.activeClients} />
<StatCard label="Active Users" value={data.activeUsers} />
@@ -128,7 +179,7 @@ function OverviewPanel({ data }) {
function StatCard({ label, value }) {
return (
<div className="stat-card">
<div className="stat-value">{value ?? '-'}</div>
<div className="stat-value">{value ?? ''}</div>
<div className="stat-label">{label}</div>
</div>
);

View File

@@ -0,0 +1,143 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { MANAGEMENT_URL } from '../auth/authConfig';
// ─── Session-level cache — avoids repeat API calls ────────────
const _cache = new Map();
// ─── HelpIcon ─────────────────────────────────────────────────
// Usage: <HelpIcon helpKey="admin.campaigns.pacing" label="Budget Pacing" />
//
// helpKey format: {app}.{section}.{element}
// admin.campaigns.status admin.clients.approval
// client.wizard.budget client.wizard.audience
// ─────────────────────────────────────────────────────────────
export default function HelpIcon({ helpKey, label }) {
const [open, setOpen] = useState(false);
const [content, setContent] = useState(null); // { title, body }
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const modalRef = useRef(null);
// ── Fetch on open ──
const fetchContent = useCallback(async () => {
if (_cache.has(helpKey)) {
setContent(_cache.get(helpKey));
return;
}
setLoading(true);
setError(null);
try {
// /api/help is anonymous — no session token required
const res = await fetch(`${MANAGEMENT_URL}/api/help/${encodeURIComponent(helpKey)}`);
const data = await res.json();
if (data.ok) {
const entry = { title: data.title, body: data.body };
_cache.set(helpKey, entry);
setContent(entry);
} else {
setError(data.error || 'No help content available yet.');
}
} catch (err) {
setError('Could not load help content.');
} finally {
setLoading(false);
}
}, [helpKey]);
const handleOpen = () => {
setOpen(true);
fetchContent();
};
const handleClose = () => setOpen(false);
// ── Close on Escape or outside click ──
useEffect(() => {
if (!open) return;
const onKey = (e) => { if (e.key === 'Escape') handleClose(); };
const onClick = (e) => {
if (modalRef.current && !modalRef.current.contains(e.target)) handleClose();
};
document.addEventListener('keydown', onKey);
document.addEventListener('mousedown', onClick);
return () => {
document.removeEventListener('keydown', onKey);
document.removeEventListener('mousedown', onClick);
};
}, [open]);
return (
<>
{/* ── Trigger: floating icon + label ── */}
<button className="help-trigger" onClick={handleOpen} title={`Help: ${label || helpKey}`}>
<span className="help-trigger-icon">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"
stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<circle cx="8" cy="8" r="7" />
<path d="M6 6c0-1.1.9-2 2-2s2 .9 2 2c0 1-.6 1.5-1.3 2S8 9.5 8 10" />
<circle cx="8" cy="12.5" r=".75" fill="currentColor" stroke="none" />
</svg>
</span>
{label && <span className="help-trigger-label">{label}</span>}
</button>
{/* ── Modal ── */}
{open && (
<div className="help-overlay">
<div className="help-modal" ref={modalRef} role="dialog" aria-modal="true"
aria-label={content?.title || 'Help'}>
<div className="help-modal-header">
<div className="help-modal-title-row">
<span className="help-modal-icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"
stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<circle cx="8" cy="8" r="7" />
<path d="M6 6c0-1.1.9-2 2-2s2 .9 2 2c0 1-.6 1.5-1.3 2S8 9.5 8 10" />
<circle cx="8" cy="12.5" r=".75" fill="currentColor" stroke="none" />
</svg>
</span>
<h3 className="help-modal-title">
{loading ? 'Loading…' : (content?.title || error ? 'Help' : label)}
</h3>
</div>
<button className="help-modal-close" onClick={handleClose} aria-label="Close">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M3 3l10 10M13 3L3 13" />
</svg>
</button>
</div>
<div className="help-modal-body">
{loading && (
<div className="help-loading">
<div className="help-spinner" />
<span>Loading help</span>
</div>
)}
{!loading && error && (
<p className="help-error">{error}</p>
)}
{!loading && content && (
<div
className="help-content"
dangerouslySetInnerHTML={{ __html: content.body }}
/>
)}
</div>
<div className="help-modal-footer">
<span className="help-key-badge">{helpKey}</span>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { useAuth } from '../auth/AuthProvider';
export default function Shell({ children }) {
const { isSignedIn, user, signOut } = useAuth();
const { isAuthenticated: isSignedIn, sessionUser: user, signOut } = useAuth();
return (
<div className="shell">

View File

@@ -0,0 +1,170 @@
import React from 'react';
import { useAuth } from '../auth/AuthProvider';
// ─── SVG Icon components ──────────────────────────────────────────────────────
const Icons = {
dashboard: (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="2" width="7" height="8" rx="1.5" />
<rect x="11" y="2" width="7" height="5" rx="1.5" />
<rect x="2" y="12" width="7" height="6" rx="1.5" />
<rect x="11" y="9" width="7" height="9" rx="1.5" />
</svg>
),
clients: (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="7" cy="6" r="3" />
<circle cx="14" cy="7" r="2.5" />
<path d="M1.5 17c0-3 2.5-5 5.5-5s5.5 2 5.5 5" />
<path d="M12.5 12c1.5 0 4 1 4 3.5" />
</svg>
),
campaigns: (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 3L8 7H4a1 1 0 00-1 1v4a1 1 0 001 1h1l2 4h2l-2-4h1l8 4V3z" />
<path d="M16 3v14" />
</svg>
),
config: (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 5h14M3 10h14M3 15h14" />
<circle cx="7" cy="5" r="1.5" fill="currentColor" />
<circle cx="13" cy="10" r="1.5" fill="currentColor" />
<circle cx="9" cy="15" r="1.5" fill="currentColor" />
</svg>
),
intelligence: (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 16L7 8l4 5 3-7 3 10" />
<circle cx="7" cy="8" r="1.5" fill="currentColor" />
<circle cx="11" cy="13" r="1.5" fill="currentColor" />
<circle cx="14" cy="6" r="1.5" fill="currentColor" />
</svg>
),
billing: (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="16" height="12" rx="2" />
<path d="M2 8h16" />
<path d="M6 12h3" />
</svg>
),
settings: (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="10" cy="10" r="2.5" />
<path d="M10 2v2.5M10 15.5V18M18 10h-2.5M4.5 10H2M15.66 4.34l-1.77 1.77M6.11 13.89l-1.77 1.77M15.66 15.66l-1.77-1.77M6.11 6.11L4.34 4.34" />
</svg>
),
documents: (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M11.5 2H5a1.5 1.5 0 00-1.5 1.5v13A1.5 1.5 0 005 18h10a1.5 1.5 0 001.5-1.5V7z" />
<path d="M11.5 2v5H17" />
<line x1="6.5" y1="11" x2="13.5" y2="11" />
<line x1="6.5" y1="14" x2="13.5" y2="14" />
</svg>
),
chevron: (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 3l4 4-4 4" />
</svg>
),
};
// ─── Navigation structure ─────────────────────────────────────────────────────
// Each item maps to a category in the sidebar. Tabs within each category
// are defined in AdminContext.jsx (CATEGORY_TABS).
export const NAV_ITEMS = [
{ id: 'dashboard', label: 'Dashboard', icon: 'dashboard' },
{ id: 'clients', label: 'Clients', icon: 'clients', badge: null },
{ id: 'campaigns', label: 'Campaigns', icon: 'campaigns' },
{ id: 'intelligence', label: 'Intelligence', icon: 'intelligence' },
{ id: 'config', label: 'Configuration', icon: 'config' },
{ id: 'documents', label: 'Documents', icon: 'documents' },
{ divider: true },
{ id: 'billing', label: 'Billing', icon: 'billing', disabled: true },
{ id: 'settings', label: 'Settings', icon: 'settings', disabled: true },
];
export default function Sidebar({ activeCategory, onSelectCategory, collapsed, onToggleCollapse }) {
const { user, signOut } = useAuth();
return (
<aside className={`sidebar ${collapsed ? 'sidebar-collapsed' : ''}`}>
{/* Logo */}
<div className="sidebar-logo">
<span className="sidebar-logo-icon"></span>
{!collapsed && (
<>
<span className="sidebar-logo-text">AdPlatform</span>
<span className="sidebar-logo-badge">Mgmt</span>
</>
)}
</div>
{/* Nav items */}
<nav className="sidebar-nav">
{NAV_ITEMS.map((item, i) => {
if (item.divider) {
return <div key={`d-${i}`} className="sidebar-divider" />;
}
const isActive = activeCategory === item.id;
const isDisabled = item.disabled;
return (
<button
key={item.id}
className={`sidebar-item ${isActive ? 'sidebar-item-active' : ''} ${isDisabled ? 'sidebar-item-disabled' : ''}`}
onClick={() => !isDisabled && onSelectCategory(item.id)}
title={collapsed ? item.label : undefined}
>
<span className="sidebar-item-icon">{Icons[item.icon]}</span>
{!collapsed && (
<>
<span className="sidebar-item-label">{item.label}</span>
{item.badge != null && (
<span className="sidebar-badge">{item.badge}</span>
)}
{isDisabled && (
<span className="sidebar-soon">Soon</span>
)}
</>
)}
</button>
);
})}
</nav>
{/* Footer: user + collapse */}
<div className="sidebar-footer">
{!collapsed && user && (
<div className="sidebar-user">
<div className="sidebar-user-avatar">
{(user.name || user.email || '?')[0].toUpperCase()}
</div>
<div className="sidebar-user-info">
<span className="sidebar-user-name">{user.name || user.email}</span>
<button onClick={signOut} className="sidebar-user-signout">Sign Out</button>
</div>
</div>
)}
{collapsed && user && (
<div className="sidebar-user-collapsed" title={user.name || user.email}>
<div className="sidebar-user-avatar">
{(user.name || user.email || '?')[0].toUpperCase()}
</div>
</div>
)}
<button
className="sidebar-collapse-btn"
onClick={onToggleCollapse}
title={collapsed ? 'Expand' : 'Collapse'}
>
<span className={`sidebar-collapse-icon ${collapsed ? '' : 'sidebar-collapse-icon-flip'}`}>
{Icons.chevron}
</span>
{!collapsed && <span>Collapse</span>}
</button>
</div>
</aside>
);
}

View File

@@ -2,20 +2,54 @@ import React from 'react';
import { useAuth } from '../auth/AuthProvider';
export default function SignInOverlay() {
const { signIn, error } = useAuth();
const { authState, error, signIn, retrySignIn } = useAuth();
if (authState === 'needsRegistration') {
return (
<div className="signin-overlay">
<div className="signin-card">
<div className="signin-icon"></div>
<h1>AdPlatform</h1>
<p className="signin-subtitle">Account not found</p>
<div className="error-message">
Your account doesn't have access to AdPlatform yet.
Please contact your administrator or complete registration
before signing in.
</div>
<button onClick={retrySignIn} className="btn-signin btn-secondary">
Try a different account
</button>
</div>
</div>
);
}
if (authState === 'error') {
return (
<div className="signin-overlay">
<div className="signin-card">
<div className="signin-icon"></div>
<h1>AdPlatform</h1>
<p className="signin-subtitle">Sign-in failed</p>
{error && <div className="error-message">{error}</div>}
<button onClick={retrySignIn} className="btn-signin">
Try again
</button>
</div>
</div>
);
}
// unauthenticated — default sign-in prompt
return (
<div className="signin-overlay">
<div className="signin-card">
<div className="signin-icon"></div>
<h1>AdPlatform Management</h1>
<p className="signin-subtitle">Sign in to manage your advertising platform</p>
{error && <div className="error-message">{error}</div>}
<button onClick={signIn} className="btn-signin">Sign in with Microsoft</button>
<p className="signin-help">Use your organization account to sign in</p>
<h1>AdPlatform</h1>
<p className="signin-subtitle">Sign in to manage your advertising campaigns</p>
<button onClick={signIn} className="btn-signin">
Sign in with Microsoft
</button>
</div>
</div>
);

View File

@@ -0,0 +1,389 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAdmin } from '../../context/AdminContext';
/**
* CampaignsPanel — Admin view of all initiatives across all clients.
* Each initiative can have multiple channel campaigns (Google Ads, Meta, TikTok).
* Includes filters: status, client, date range.
*/
const STATUSES = [
{ value: '', label: 'All Statuses' },
{ value: 'draft', label: 'Draft' },
{ value: 'staged', label: 'Staged' },
{ value: 'active', label: 'Active' },
{ value: 'paused', label: 'Paused' },
{ value: 'completed', label: 'Completed' },
{ value: 'error', label: 'Error' },
];
export default function CampaignsPanel() {
const { data, apiCall, refresh } = useAdmin();
// Filter state
const [statusFilter, setStatusFilter] = useState('');
const [clientFilter, setClientFilter] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
// Client list for dropdown
const [clients, setClients] = useState([]);
// Filtered data (null = use initial data prop)
const [filteredData, setFilteredData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const hasFilters = statusFilter || clientFilter || dateFrom || dateTo;
const activeData = hasFilters ? filteredData : data;
const initiatives = activeData?.initiatives || [];
const totalCount = activeData?.totalCount ?? initiatives.length;
// Fetch client list for dropdown on mount
useEffect(() => {
const loadClients = async () => {
const result = await apiCall('/api/admin/clients/list', 'POST', {});
if (result.ok && result.clients) {
setClients(result.clients);
}
};
loadClients();
}, [apiCall]);
// Fetch with filters
const fetchFiltered = useCallback(async () => {
setLoading(true);
setError(null);
const params = new URLSearchParams();
if (statusFilter) params.set('status', statusFilter);
const body = {};
if (statusFilter) body.status = statusFilter;
if (clientFilter) body.clientId = clientFilter;
if (dateFrom) body.dateFrom = dateFrom;
if (dateTo) body.dateTo = dateTo;
const result = await apiCall('/api/admin/campaigns/list', 'POST', body);
if (result.ok) {
setFilteredData(result);
} else {
setError(result.error || 'Failed to load campaigns');
}
setLoading(false);
}, [apiCall, statusFilter, clientFilter, dateFrom, dateTo]);
// Refetch when filters change
useEffect(() => {
if (!hasFilters) {
setFilteredData(null);
return;
}
const timer = setTimeout(fetchFiltered, 200);
return () => clearTimeout(timer);
}, [hasFilters, fetchFiltered]);
const clearFilters = () => {
setStatusFilter('');
setClientFilter('');
setDateFrom('');
setDateTo('');
setFilteredData(null);
};
return (
<div className="table-panel">
<div className="panel-header">
<h2>Campaigns ({totalCount})</h2>
<button className="btn-action" onClick={hasFilters ? fetchFiltered : refresh}>
Refresh
</button>
</div>
{/* ─── Filter Bar ──────────────────────────────────── */}
<div className="filter-bar">
<div className="filter-group">
<label className="filter-label">Status</label>
<select
className="filter-select"
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
>
{STATUSES.map(s => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
<div className="filter-group">
<label className="filter-label">Client</label>
<select
className="filter-select"
value={clientFilter}
onChange={e => setClientFilter(e.target.value)}
>
<option value="">All Clients</option>
{clients.map(c => (
<option key={c.clientId} value={c.clientId}>
{c.clientName}
</option>
))}
</select>
</div>
<div className="filter-group">
<label className="filter-label">From</label>
<input
type="date"
className="filter-input"
value={dateFrom}
onChange={e => setDateFrom(e.target.value)}
/>
</div>
<div className="filter-group">
<label className="filter-label">To</label>
<input
type="date"
className="filter-input"
value={dateTo}
onChange={e => setDateTo(e.target.value)}
/>
</div>
{hasFilters && (
<button className="btn-clear-filters" onClick={clearFilters}>
Clear
</button>
)}
</div>
{/* ─── Loading / Error ─────────────────────────────── */}
{loading && (
<div style={{ padding: '20px', textAlign: 'center', color: '#6b7280' }}>
Loading...
</div>
)}
{error && (
<div className="error-message" style={{ margin: '12px 0' }}>
<strong>Error:</strong> {error}
</div>
)}
{/* ─── Table ───────────────────────────────────────── */}
{!loading && !error && initiatives.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">{hasFilters ? '🔍' : '📢'}</div>
<h3>{hasFilters ? 'No matching campaigns' : 'No campaigns yet'}</h3>
<p>
{hasFilters
? 'Try adjusting your filters to find what you\'re looking for.'
: 'Campaigns will appear here once clients create them through the wizard.'}
</p>
</div>
) : !loading && !error && (
<table className="data-table">
<thead>
<tr>
<th style={{ width: 28 }}></th>
<th>Client</th>
<th>Campaign</th>
<th>Objective</th>
<th>Budget</th>
<th>Channels</th>
<th>Status</th>
<th>Dates</th>
</tr>
</thead>
<tbody>
{initiatives.map(init => (
<InitiativeRow key={init.initiativeId} initiative={init} />
))}
</tbody>
</table>
)}
</div>
);
}
// ─── Initiative row with expandable channel details ──────────────────────────
function InitiativeRow({ initiative }) {
const [expanded, setExpanded] = useState(false);
const channels = initiative.channels || [];
return (
<>
<tr
className="initiative-row"
style={{ cursor: channels.length > 0 ? 'pointer' : 'default' }}
onClick={() => channels.length > 0 && setExpanded(!expanded)}
>
<td style={{ textAlign: 'center', color: '#9ca3af', fontSize: 12 }}>
{channels.length > 0 && (
<span style={{
display: 'inline-block',
transition: 'transform 0.15s',
transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
}}></span>
)}
</td>
<td>{initiative.clientName || '—'}</td>
<td style={{ fontWeight: 500 }}>{initiative.name || '—'}</td>
<td>
<span className="objective-badge">
{formatObjective(initiative.objective)}
</span>
</td>
<td>{formatBudget(initiative.totalBudget, initiative.budgetPeriod)}</td>
<td>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{channels.map((ch, i) => (
<ChannelBadge key={i} channel={ch} />
))}
{channels.length === 0 && <span style={{ color: '#9ca3af' }}></span>}
</div>
</td>
<td><InitiativeStatusBadge status={initiative.status} /></td>
<td style={{ fontSize: 13 }}>{formatDateRange(initiative.startDate, initiative.endDate)}</td>
</tr>
{/* Expanded channel detail rows */}
{expanded && channels.map((ch, i) => (
<tr key={`ch-${i}`} className="channel-detail-row">
<td></td>
<td colSpan="7">
<div className="channel-detail">
<ChannelIcon type={ch.channelType} />
<div className="channel-detail-info">
<span className="channel-detail-type">
{formatChannelName(ch.channelType)}
</span>
<span className="channel-detail-alloc">
{ch.allocationPct != null ? `${ch.allocationPct}%` : '—'}
{ch.channelBudget != null && (
<> · ${Number(ch.channelBudget).toLocaleString()}</>
)}
</span>
</div>
<ChannelStatusBadge status={ch.status} />
{ch.externalCampaignId && (
<span className="channel-detail-ext" title={ch.externalCampaignId}>
{truncateExtId(ch.externalCampaignId)}
</span>
)}
{ch.providerStatus && ch.providerStatus !== ch.status && (
<span className="channel-detail-provider">
Provider: {ch.providerStatus}
</span>
)}
</div>
</td>
</tr>
))}
</>
);
}
// ─── Badge components ────────────────────────────────────────────────────────
function ChannelBadge({ channel }) {
const colors = {
google_ads: { bg: '#e8f0fe', text: '#1a73e8', label: 'Google' },
meta: { bg: '#f3e8ff', text: '#7c3aed', label: 'Meta' },
tiktok: { bg: '#fff0f0', text: '#ff0050', label: 'TikTok' },
};
const c = colors[channel.channelType] || { bg: '#f3f4f6', text: '#6b7280', label: channel.channelType };
const isError = channel.status === 'error';
return (
<span
className="channel-badge"
style={{
background: isError ? '#fef2f2' : c.bg,
color: isError ? '#dc2626' : c.text,
opacity: isError ? 0.7 : 1,
}}
title={`${c.label}${channel.status} (${channel.allocationPct || 0}%)`}
>
{c.label}
{channel.allocationPct != null && (
<span style={{ opacity: 0.6, marginLeft: 3, fontSize: 10 }}>
{channel.allocationPct}%
</span>
)}
</span>
);
}
function InitiativeStatusBadge({ status }) {
const s = (status || '').toLowerCase();
let className = 'status-default';
if (s === 'active' || s === 'launched') className = 'status-active';
else if (s === 'staged' || s === 'pending' || s === 'draft') className = 'status-pending';
else if (s === 'paused') className = 'status-warning';
else if (s === 'error' || s === 'failed') className = 'status-error';
else if (s === 'completed' || s === 'ended') className = 'status-inactive';
return <span className={`status-badge ${className}`}>{status || '—'}</span>;
}
function ChannelStatusBadge({ status }) {
const s = (status || '').toLowerCase();
let className = 'status-default';
if (s === 'active' || s === 'submitted') className = 'status-active';
else if (s === 'pending' || s === 'draft' || s === 'pending_review') className = 'status-pending';
else if (s === 'error') className = 'status-error';
return <span className={`status-badge ${className}`} style={{ fontSize: 11 }}>{status || '—'}</span>;
}
function ChannelIcon({ type }) {
const icons = {
google_ads: '🔍',
meta: '📘',
tiktok: '🎵',
};
return <span style={{ fontSize: 16, marginRight: 6 }}>{icons[type] || '📢'}</span>;
}
// ─── Formatters ──────────────────────────────────────────────────────────────
function formatObjective(obj) {
if (!obj) return '—';
return obj.charAt(0).toUpperCase() + obj.slice(1).replace(/_/g, ' ');
}
function formatBudget(amount, period) {
if (amount == null) return '—';
const formatted = '$' + Number(amount).toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
if (period === 'monthly') return `${formatted}/mo`;
if (period === 'daily') return `${formatted}/day`;
return formatted;
}
function formatDateRange(start, end) {
if (!start && !end) return '—';
const fmt = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '…';
return `${fmt(start)} ${fmt(end)}`;
}
function formatChannelName(type) {
const names = {
google_ads: 'Google Ads',
meta: 'Meta Ads',
tiktok: 'TikTok Ads',
};
return names[type] || type;
}
function truncateExtId(id) {
if (!id) return '';
const parts = id.split('/');
if (parts.length >= 2) return `…/${parts.slice(-2).join('/')}`;
return id.length > 20 ? '…' + id.slice(-18) : id;
}

View File

@@ -0,0 +1,341 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAdmin } from '../../context/AdminContext';
// ─── Helpers ──────────────────────────────────────────────────────────────────
const fmtDateTime = (iso) => {
if (!iso) return '—';
return new Date(iso).toLocaleString('en-US', {
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', second: '2-digit',
});
};
const fmtDuration = (ms) => {
if (ms == null) return '—';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
};
function StatusBadge({ code }) {
if (!code) return <span style={{ color: '#94a3b8' }}></span>;
let bg, color;
if (code < 300) { bg = '#dcfce7'; color = '#166534'; }
else if (code < 400) { bg = '#dbeafe'; color = '#1e40af'; }
else if (code < 500) { bg = '#fef9c3'; color = '#854d0e'; }
else { bg = '#fee2e2'; color = '#991b1b'; }
return (
<span style={{
background: bg, color, padding: '2px 7px', borderRadius: 10,
fontSize: 11, fontWeight: 600,
}}>
{code}
</span>
);
}
function MethodBadge({ method }) {
if (!method) return null;
const colors = {
GET: { bg: '#eff6ff', color: '#1d4ed8' },
POST: { bg: '#f0fdf4', color: '#166534' },
PUT: { bg: '#fffbeb', color: '#92400e' },
PATCH: { bg: '#fdf4ff', color: '#6b21a8' },
DELETE: { bg: '#fff1f2', color: '#9f1239' },
};
const s = colors[method] || { bg: '#f1f5f9', color: '#475569' };
return (
<span style={{
background: s.bg, color: s.color, padding: '2px 7px', borderRadius: 10,
fontSize: 11, fontWeight: 700, letterSpacing: 0.4,
}}>
{method}
</span>
);
}
// ─── Component ────────────────────────────────────────────────────────────────
export default function ClientActivityPanel() {
const { apiCall } = useAdmin();
// Client picker state
const [clients, setClients] = useState([]);
const [clientsLoading, setClientsLoading] = useState(true);
const [selectedClientId, setSelectedClientId] = useState('');
const [selectedClientName, setSelectedClientName] = useState('');
// Activity list state
const [items, setItems] = useState([]);
const [totalCount, setTotalCount] = useState(0);
const [page, setPage] = useState(1);
const pageSize = 50;
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// Filters
const [pathFilter, setPathFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
// ── Load client list for picker ──────────────────────────────────────────
useEffect(() => {
(async () => {
setClientsLoading(true);
try {
const result = await apiCall('/api/admin/clients/list', 'POST', { page: 1, pageSize: 200 });
if (result?.ok && Array.isArray(result.clients)) {
const active = result.clients.filter(c => c.cltStatus === 'Active' || c.status === 'Active');
setClients(active.length ? active : result.clients);
}
} catch { /* ignore */ }
finally { setClientsLoading(false); }
})();
}, [apiCall]);
// ── Load activity for selected client ────────────────────────────────────
const loadActivity = useCallback(async (clientId, pg = 1) => {
if (!clientId) return;
setLoading(true);
setError(null);
try {
const body = {
clientId,
page: pg,
pageSize,
pathContains: pathFilter || undefined,
statusCode: statusFilter ? parseInt(statusFilter) : undefined,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
};
const result = await apiCall('/api/admin/client-activity/list', 'POST', body);
if (result?.ok) {
setItems(Array.isArray(result.items) ? result.items : []);
setTotalCount(result.totalCount ?? 0);
setPage(pg);
} else {
setError(result?.error || 'Failed to load activity');
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [apiCall, pathFilter, statusFilter, dateFrom, dateTo]);
const handleClientChange = (e) => {
const cid = e.target.value;
const name = clients.find(c => (c.cltId || c.clientId) === cid)?.cltName || cid;
setSelectedClientId(cid);
setSelectedClientName(name);
setPage(1);
setItems([]);
setTotalCount(0);
if (cid) loadActivity(cid, 1);
};
const handleSearch = () => {
if (selectedClientId) loadActivity(selectedClientId, 1);
};
const totalPages = Math.ceil(totalCount / pageSize);
// ── Render ────────────────────────────────────────────────────────────────
return (
<div className="data-panel" style={{ maxWidth: 1100 }}>
{/* Header */}
<div className="panel-header">
<div>
<h3 className="panel-title">Client Activity</h3>
<p className="panel-subtitle">
Gateway request log from <code>tbAccessLog</code>
</p>
</div>
</div>
{/* Client picker + filters */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginBottom: 20, alignItems: 'flex-end' }}>
<div style={{ flex: '0 0 220px' }}>
<label className="form-label" style={{ marginBottom: 4 }}>Client</label>
<select
className="form-select"
value={selectedClientId}
onChange={handleClientChange}
disabled={clientsLoading}
>
<option value="">
{clientsLoading ? 'Loading…' : '— select a client —'}
</option>
{clients.map(c => {
const id = c.cltId || c.clientId;
const name = c.cltName || c.name;
return <option key={id} value={id}>{name}</option>;
})}
</select>
</div>
<div style={{ flex: '1 1 160px', minWidth: 140 }}>
<label className="form-label" style={{ marginBottom: 4 }}>Path contains</label>
<input
className="form-input"
type="text"
placeholder="/api/initiative"
value={pathFilter}
onChange={e => setPathFilter(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
/>
</div>
<div style={{ flex: '0 0 100px' }}>
<label className="form-label" style={{ marginBottom: 4 }}>Status code</label>
<select
className="form-select"
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
>
<option value="">All</option>
<option value="200">200</option>
<option value="400">400</option>
<option value="401">401</option>
<option value="403">403</option>
<option value="404">404</option>
<option value="500">500</option>
</select>
</div>
<div style={{ flex: '0 0 160px' }}>
<label className="form-label" style={{ marginBottom: 4 }}>From</label>
<input
className="form-input"
type="date"
value={dateFrom}
onChange={e => setDateFrom(e.target.value)}
/>
</div>
<div style={{ flex: '0 0 160px' }}>
<label className="form-label" style={{ marginBottom: 4 }}>To</label>
<input
className="form-input"
type="date"
value={dateTo}
onChange={e => setDateTo(e.target.value)}
/>
</div>
<button
className="btn-action"
onClick={handleSearch}
disabled={!selectedClientId || loading}
style={{ alignSelf: 'flex-end' }}
>
{loading ? 'Loading…' : 'Search'}
</button>
</div>
{/* Empty prompt */}
{!selectedClientId && (
<div className="empty-state">
Select a client to view their activity log.
</div>
)}
{/* Error */}
{error && <div className="error-message">{error}</div>}
{/* Summary bar */}
{selectedClientId && !loading && items.length > 0 && (
<div style={{
display: 'flex', gap: 20, padding: '8px 12px', background: '#f8fafc',
borderRadius: 6, marginBottom: 12, fontSize: 13, color: '#475569',
border: '1px solid #e2e8f0',
}}>
<span><strong>{selectedClientName}</strong></span>
<span>{totalCount.toLocaleString()} request{totalCount !== 1 ? 's' : ''}</span>
{totalPages > 1 && <span>Page {page} of {totalPages}</span>}
</div>
)}
{/* Activity table */}
{selectedClientId && !loading && items.length > 0 && (
<>
<table className="data-table" style={{ fontSize: 12 }}>
<thead>
<tr>
<th>Time</th>
<th>Method</th>
<th>Path</th>
<th>Status</th>
<th>Duration</th>
<th>User</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{items.map(item => (
<tr key={item.id}
style={item.statusCode >= 500 ? { background: '#fff1f2' }
: item.statusCode >= 400 ? { background: '#fffbeb' }
: undefined}
>
<td style={{ whiteSpace: 'nowrap', color: '#64748b' }}>
{fmtDateTime(item.timestamp)}
</td>
<td><MethodBadge method={item.method} /></td>
<td style={{ fontFamily: 'monospace', fontSize: 11, maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<span title={item.path + (item.queryString || '')}>
{item.path}
{item.queryString && (
<span style={{ color: '#94a3b8' }}>{item.queryString}</span>
)}
</span>
</td>
<td><StatusBadge code={item.statusCode} /></td>
<td style={{ color: '#64748b', whiteSpace: 'nowrap' }}>
{fmtDuration(item.durationMs)}
</td>
<td style={{ color: '#64748b', fontSize: 11 }}>
{item.userEmail || item.userDisplayName || '—'}
</td>
<td style={{ color: '#94a3b8', fontSize: 11, fontFamily: 'monospace' }}>
{item.ipAddress || '—'}
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination */}
{totalPages > 1 && (
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'center' }}>
<button
className="btn-cancel"
disabled={page <= 1}
onClick={() => loadActivity(selectedClientId, page - 1)}
>
Prev
</button>
<span style={{ padding: '6px 12px', fontSize: 13, color: '#475569' }}>
{page} / {totalPages}
</span>
<button
className="btn-cancel"
disabled={page >= totalPages}
onClick={() => loadActivity(selectedClientId, page + 1)}
>
Next
</button>
</div>
)}
</>
)}
{/* Empty result */}
{selectedClientId && !loading && !error && items.length === 0 && (
<div className="empty-state">
No activity found for this client with the current filters.
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,396 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useAdmin } from '../../context/AdminContext';
// ─── Constants ────────────────────────────────────────────────────────────────
const CATEGORIES = ['Business', 'Technical', 'Legal', 'Operations', 'Financial'];
const CATEGORY_COLORS = {
Business: { bg: '#dbeafe', text: '#1e40af' },
Technical: { bg: '#dcfce7', text: '#166534' },
Legal: { bg: '#fef9c3', text: '#854d0e' },
Operations: { bg: '#ede9fe', text: '#5b21b6' },
Financial: { bg: '#fce7f3', text: '#9d174d' },
};
// ─── Helpers ──────────────────────────────────────────────────────────────────
const formatBytes = (bytes) => {
if (!bytes) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(1)} MB`;
};
const formatDate = (iso) => {
if (!iso) return '—';
return new Date(iso).toLocaleString('en-US', {
month: 'short', day: 'numeric', year: 'numeric',
hour: 'numeric', minute: '2-digit',
});
};
function FileIcon({ mimeType }) {
if (!mimeType) return <span>📄</span>;
if (mimeType.includes('pdf')) return <span>📕</span>;
if (mimeType.includes('word') || mimeType.includes('document')) return <span>📘</span>;
if (mimeType.includes('sheet') || mimeType.includes('excel')) return <span>📗</span>;
if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) return <span>📙</span>;
if (mimeType.includes('image')) return <span>🖼</span>;
if (mimeType.includes('zip') || mimeType.includes('compressed')) return <span>📦</span>;
return <span>📄</span>;
}
function CategoryBadge({ category }) {
const style = CATEGORY_COLORS[category] || { bg: '#f1f5f9', text: '#475569' };
return (
<span style={{
background: style.bg, color: style.text,
padding: '2px 8px', borderRadius: 12,
fontSize: 11, fontWeight: 600, whiteSpace: 'nowrap',
}}>
{category || 'Uncategorized'}
</span>
);
}
// ─── Component ────────────────────────────────────────────────────────────────
export default function ClientDocumentsPanel() {
const { apiCall } = useAdmin();
// Client picker
const [clients, setClients] = useState([]);
const [clientsLoading, setClientsLoading] = useState(true);
const [selectedClientId, setSelectedClientId] = useState('');
const [selectedClientName, setSelectedClientName] = useState('');
// Document list
const [docs, setDocs] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [downloading, setDownloading] = useState(null);
const [deleteId, setDeleteId] = useState(null);
const [deleting, setDeleting] = useState(false);
// Upload form
const [showUpload, setShowUpload] = useState(false);
const [uploadFile, setUploadFile] = useState(null);
const [uploadCat, setUploadCat] = useState('Business');
const [uploadDesc, setUploadDesc] = useState('');
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState(null);
const fileInputRef = useRef();
// ── Load client list ──────────────────────────────────────────────────────
useEffect(() => {
(async () => {
setClientsLoading(true);
try {
const result = await apiCall('/api/admin/clients/list', 'POST', { page: 1, pageSize: 200 });
if (result?.ok && Array.isArray(result.clients)) {
setClients(result.clients);
}
} catch { /* ignore */ }
finally { setClientsLoading(false); }
})();
}, [apiCall]);
// ── Load documents for selected client ────────────────────────────────────
const loadDocs = useCallback(async (clientId) => {
if (!clientId) return;
setLoading(true);
setError(null);
try {
const result = await apiCall('/api/admin/client-documents/list', 'POST', { clientId });
if (result?.ok && Array.isArray(result.documents)) {
setDocs(result.documents);
} else {
setError(result?.error || result?.message || 'Failed to load documents');
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [apiCall]);
const handleClientChange = (e) => {
const cid = e.target.value;
const name = clients.find(c => (c.cltId || c.clientId) === cid)?.cltName || cid;
setSelectedClientId(cid);
setSelectedClientName(name);
setDocs([]);
setError(null);
setShowUpload(false);
if (cid) loadDocs(cid);
};
// ── Upload ────────────────────────────────────────────────────────────────
const handleUpload = async () => {
if (!uploadFile) { setUploadError('Please select a file'); return; }
if (!selectedClientId) { setUploadError('No client selected'); return; }
setUploading(true);
setUploadError(null);
try {
const formData = new FormData();
formData.append('file', uploadFile);
formData.append('clientId', selectedClientId);
formData.append('category', uploadCat);
formData.append('description', uploadDesc);
const result = await apiCall('/api/admin/client-documents/upload', {
method: 'POST',
body: formData,
});
if (!result?.ok) throw new Error(result?.error || result?.message || 'Upload failed');
setShowUpload(false);
setUploadFile(null);
setUploadDesc('');
setUploadCat('Business');
await loadDocs(selectedClientId);
} catch (err) {
setUploadError(err.message);
} finally {
setUploading(false);
}
};
// ── Download ──────────────────────────────────────────────────────────────
const handleDownload = async (doc) => {
setDownloading(doc.docId);
try {
const result = await apiCall(`/api/admin/client-documents/${doc.docId}/download`, {
rawResponse: true,
});
if (!result) throw new Error('No response');
const blob = await result.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = doc.docFileName;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
alert(`Download failed: ${err.message}`);
} finally {
setDownloading(null);
}
};
// ── Delete ────────────────────────────────────────────────────────────────
const handleDelete = async () => {
if (!deleteId) return;
setDeleting(true);
try {
const result = await apiCall(`/api/admin/client-documents/${deleteId}`, { method: 'DELETE' });
if (!result?.ok) throw new Error(result?.error || result?.message || 'Delete failed');
setDeleteId(null);
await loadDocs(selectedClientId);
} catch (err) {
alert(`Delete failed: ${err.message}`);
} finally {
setDeleting(false);
}
};
// ── Render ────────────────────────────────────────────────────────────────
return (
<div className="data-panel" style={{ maxWidth: 1000 }}>
{/* Header */}
<div className="panel-header">
<div>
<h3 className="panel-title">Client Documents</h3>
<p className="panel-subtitle">Documents shared with or uploaded by clients</p>
</div>
{selectedClientId && (
<button
className="btn-action"
onClick={() => { setShowUpload(true); setUploadError(null); }}
>
Upload for {selectedClientName}
</button>
)}
</div>
{/* Client picker */}
<div style={{ marginBottom: 20 }}>
<label className="form-label" style={{ marginBottom: 6 }}>Client</label>
<select
className="form-select"
value={selectedClientId}
onChange={handleClientChange}
disabled={clientsLoading}
style={{ maxWidth: 320 }}
>
<option value="">
{clientsLoading ? 'Loading…' : '— select a client —'}
</option>
{clients.map(c => {
const id = c.cltId || c.clientId;
const name = c.cltName || c.name;
return <option key={id} value={id}>{name}</option>;
})}
</select>
</div>
{/* Upload form */}
{showUpload && (
<div className="admin-form" style={{ marginBottom: 20 }}>
<div className="form-title">Upload Document for {selectedClientName}</div>
<div className="form-group">
<label className="form-label">File</label>
<div
style={{
border: `2px dashed ${uploadFile ? 'var(--accent)' : '#cbd5e1'}`,
borderRadius: 6, padding: '16px 20px', textAlign: 'center',
cursor: 'pointer', background: uploadFile ? '#eff6ff' : '#fff',
transition: 'all 0.2s',
}}
onClick={() => fileInputRef.current?.click()}
>
{uploadFile ? (
<div style={{ color: 'var(--accent)', fontWeight: 600 }}>
📎 {uploadFile.name}
<span style={{ color: '#64748b', fontWeight: 400, marginLeft: 8 }}>
({formatBytes(uploadFile.size)})
</span>
</div>
) : (
<div style={{ color: '#94a3b8' }}>Click to choose a file</div>
)}
<input
ref={fileInputRef}
type="file"
style={{ display: 'none' }}
onChange={e => setUploadFile(e.target.files?.[0] || null)}
/>
</div>
</div>
<div className="form-group">
<label className="form-label">Category</label>
<select className="form-select" value={uploadCat} onChange={e => setUploadCat(e.target.value)}>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Description (optional)</label>
<input
className="form-input"
type="text"
placeholder="Brief description…"
value={uploadDesc}
onChange={e => setUploadDesc(e.target.value)}
/>
</div>
{uploadError && <div className="error-message" style={{ marginBottom: 12 }}>{uploadError}</div>}
<div className="form-buttons">
<button className="btn-action" onClick={handleUpload} disabled={uploading || !uploadFile}>
{uploading ? 'Uploading…' : 'Upload'}
</button>
<button className="btn-cancel" onClick={() => { setShowUpload(false); setUploadFile(null); setUploadError(null); }} disabled={uploading}>
Cancel
</button>
</div>
</div>
)}
{/* Empty prompt */}
{!selectedClientId && (
<div className="empty-state">Select a client to view their documents.</div>
)}
{error && <div className="error-message">{error}</div>}
{loading && <div className="loading-message">Loading documents</div>}
{/* Document list */}
{selectedClientId && !loading && !error && (
<>
{docs.length === 0 ? (
<div className="empty-state">
No documents for {selectedClientName} yet. Click Upload to add the first one.
</div>
) : (
<table className="data-table">
<thead>
<tr>
<th style={{ width: 36 }}></th>
<th>File</th>
<th>Category</th>
<th>Size</th>
<th>Uploaded By</th>
<th>Date</th>
<th style={{ width: 100 }}>Actions</th>
</tr>
</thead>
<tbody>
{docs.map(doc => (
<tr key={doc.docId}>
<td style={{ textAlign: 'center', fontSize: 18 }}>
<FileIcon mimeType={doc.docMimeType} />
</td>
<td>
<div style={{ fontWeight: 500, color: '#1e293b' }}>{doc.docFileName}</div>
{doc.docDescription && (
<div style={{ fontSize: 12, color: '#64748b', marginTop: 2 }}>
{doc.docDescription}
</div>
)}
</td>
<td><CategoryBadge category={doc.docCategory} /></td>
<td style={{ color: '#64748b', fontSize: 13 }}>{formatBytes(doc.docFileSize)}</td>
<td style={{ color: '#64748b', fontSize: 13 }}>{doc.docUploadedBy || '—'}</td>
<td style={{ color: '#64748b', fontSize: 13 }}>{formatDate(doc.docUploadedAt)}</td>
<td>
<div style={{ display: 'flex', gap: 6 }}>
<button
className="btn-icon"
title="Download"
disabled={downloading === doc.docId}
onClick={() => handleDownload(doc)}
>
{downloading === doc.docId ? '…' : '↓'}
</button>
<button
className="btn-icon btn-icon-danger"
title="Delete"
onClick={() => setDeleteId(doc.docId)}
>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
{/* Delete confirm */}
{deleteId && (
<div className="modal-overlay">
<div className="modal-box">
<h4 className="modal-title">Delete Document?</h4>
<p className="modal-body">This will permanently remove the document. This cannot be undone.</p>
<div className="modal-buttons">
<button className="btn-danger" onClick={handleDelete} disabled={deleting}>
{deleting ? 'Deleting…' : 'Delete'}
</button>
<button className="btn-cancel" onClick={() => setDeleteId(null)} disabled={deleting}>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,762 @@
import React, { useState, useEffect, useCallback, memo } from 'react';
import { useAdmin } from '../../context/AdminContext';
// ─── 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 } = useAdmin();
const [applicants, setApplicants] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [approving, setApproving] = useState(null);
const [approvalResult, setApprovalResult] = useState(null);
const [rejecting, setRejecting] = useState(null);
const [rejectReason, setRejectReason] = useState('');
const fetchPending = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await apiCall('/api/registration/pending');
setApplicants(data?.applicants || []);
if (!data?.ok && data?.error && !data.error.includes('404') && !data.error.includes('502'))
setError(data.error);
} catch (err) {
console.info('[Clients] Registration endpoint not available:', err.message);
setApplicants([]);
} finally {
setLoading(false);
}
}, [apiCall]);
useEffect(() => { fetchPending(); }, [fetchPending]);
const handleApprove = async (applicant) => {
setApproving(applicant.registrationId);
setApprovalResult(null);
// Only registrationId needed — server fetches all data including OID from Registration
const result = await apiCall('/api/admin/clients', 'POST', {
registrationId: applicant.registrationId,
});
if (result.ok) {
setApprovalResult({ name: result.name, providerResults: result.providerResults || [] });
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>
)}
{approvalResult && (
<div style={{
background: '#f0fdf4', border: '1px solid #86efac', borderRadius: 8,
padding: '12px 16px', marginBottom: 16
}}>
<div style={{ fontWeight: 600, color: '#166534', marginBottom: 6 }}>
{approvalResult.name} approved
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{approvalResult.providerResults.map(p => {
const icon = p.status === 'Succeeded' ? '✓'
: p.status === 'Skipped' ? '⏳' : '⚠';
const color = p.status === 'Succeeded' ? '#166534'
: p.status === 'Skipped' ? '#92400e' : '#991b1b';
return (
<span key={p.provider} style={{ fontSize: 12, color }}>
{icon} {p.provider.charAt(0).toUpperCase() + p.provider.slice(1)}
{p.externalAccountId ? ` (${p.externalAccountId})` : ''}
{p.error ? `${p.error}` : ''}
</span>
);
})}
</div>
<button style={{
marginTop: 8, fontSize: 11, background: 'none', border: 'none',
color: '#166534', cursor: 'pointer', textDecoration: 'underline', padding: 0
}} onClick={() => setApprovalResult(null)}>Dismiss</button>
</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 body = statusFilter && statusFilter !== 'all' ? { status: statusFilter } : {};
const result = await apiCall('/api/admin/clients/list', 'POST', body);
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>
);
}

View File

@@ -0,0 +1,159 @@
import React, { useState, useEffect } from 'react';
import { useAdmin } from '../../context/AdminContext';
// ─── Client Users Panel ───────────────────────────────────────────────────────
// Manages tbClientUser / tbClientUserRole — client planet only.
// No admin-plane concepts here.
// ─────────────────────────────────────────────────────────────────────────────
export default function ClientUsersPanel() {
const { data, apiCall, refresh } = useAdmin();
const [showForm, setShowForm] = useState(false);
const users = data.users || [];
const deactivate = async (userId) => {
if (!confirm('Deactivate this client user?')) return;
const result = await apiCall(`/api/admin/client-users/${userId}`, 'DELETE');
if (result.ok) refresh();
else alert(result.error || 'Failed to deactivate user');
};
return (
<div className="table-panel">
<div className="panel-header">
<div>
<h2>Client Users ({data.totalCount ?? users.length})</h2>
<span style={{ fontSize: 13, color: 'var(--text-dim)' }}>
Users on the client platform
</span>
</div>
<button className="btn-action" onClick={() => setShowForm(!showForm)}>
{showForm ? 'Cancel' : '+ Add User'}
</button>
</div>
{showForm && (
<CreateClientUserForm
onSuccess={() => { setShowForm(false); refresh(); }}
onCancel={() => setShowForm(false)}
apiCall={apiCall}
/>
)}
<table className="data-table">
<thead>
<tr>
<th>Email</th>
<th>Name</th>
<th>Status</th>
<th>Clients</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.userId}>
<td>{u.email}</td>
<td>{u.displayName || '—'}</td>
<td><StatusBadge status={u.status} /></td>
<td>{u.clients?.map(c => `${c.clientName} (${c.role})`).join(', ') || '—'}</td>
<td>{u.createdAt ? new Date(u.createdAt).toLocaleDateString() : '—'}</td>
<td>
<button className="btn-small btn-danger" onClick={() => deactivate(u.userId)}>
Deactivate
</button>
</td>
</tr>
))}
{users.length === 0 && (
<tr><td colSpan="6" className="empty-row">No client users found</td></tr>
)}
</tbody>
</table>
</div>
);
}
function CreateClientUserForm({ onSuccess, onCancel, apiCall }) {
const [email, setEmail] = useState('');
const [displayName, setDisplayName] = useState('');
const [clientId, setClientId] = useState('');
const [role, setRole] = useState('User');
const [clients, setClients] = useState([]);
const [loading, setLoading] = useState(false);
const [loadingClients, setLoadingClients] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
apiCall('/api/admin/clients/list', 'POST', { pageSize: 100 }).then(r => {
if (r.ok) setClients(r.clients || []);
setLoadingClients(false);
});
}, [apiCall]);
const handleSubmit = async () => {
if (!email.trim()) return;
setLoading(true);
setError(null);
const result = await apiCall('/api/admin/client-users', 'POST', {
email: email.trim(),
displayName: displayName || null,
clientId: clientId || null,
role,
});
if (result.ok) onSuccess();
else setError(result.error || 'Failed to create user');
setLoading(false);
};
return (
<div className="admin-form">
<h3>Create Client User</h3>
{error && <div className="error-message">{error}</div>}
<div className="form-group">
<label>Email *</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)}
placeholder="user@example.com" disabled={loading} autoFocus />
</div>
<div className="form-group">
<label>Display Name</label>
<input type="text" value={displayName} onChange={e => setDisplayName(e.target.value)}
placeholder="Jane Smith" disabled={loading} />
</div>
<div className="form-group">
<label>Assign to Client</label>
<select value={clientId} onChange={e => setClientId(e.target.value)}
disabled={loading || loadingClients}>
<option value=""> No client (assign later) </option>
{clients.filter(c => c.status === 'Active').map(c => (
<option key={c.clientId} value={c.clientId}>{c.clientName}</option>
))}
</select>
</div>
{clientId && (
<div className="form-group">
<label>Role</label>
<select value={role} onChange={e => setRole(e.target.value)} disabled={loading}>
<option value="Admin">Admin</option>
<option value="User">User</option>
<option value="ReadOnly">Read Only</option>
</select>
</div>
)}
<div className="form-buttons">
<button className="btn-cancel" onClick={onCancel} disabled={loading}>Cancel</button>
<button className="btn-primary" onClick={handleSubmit} disabled={loading || !email.trim()}>
{loading ? 'Creating…' : 'Create User'}
</button>
</div>
</div>
);
}
function StatusBadge({ status }) {
const style = status === 'Active'
? { background: '#dcfce7', color: '#166534', border: '1px solid #86efac' }
: { background: '#f3f4f6', color: '#6b7280', border: '1px solid #d1d5db' };
return <span className="status-badge" style={style}>{status}</span>;
}

View File

@@ -1,147 +0,0 @@
import React, { useState } from 'react';
import { API_BASE_URL } from '../../auth/authConfig';
export default function ClientsPanel({ data, sessionToken, onRefresh }) {
const [showForm, setShowForm] = useState(false);
const clients = data.clients || [];
return (
<div className="table-panel">
<div className="panel-header">
<h2>Clients ({data.totalCount})</h2>
<button className="btn-action" onClick={() => setShowForm(!showForm)}>
{showForm ? 'Cancel' : '+ Add Client'}
</button>
</div>
{showForm && (
<CreateClientForm
sessionToken={sessionToken}
onSuccess={() => { setShowForm(false); onRefresh(); }}
onCancel={() => setShowForm(false)}
/>
)}
<table className="data-table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Users</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{clients.map(client => (
<tr key={client.clientId}>
<td>{client.clientName}</td>
<td><StatusBadge status={client.status} /></td>
<td>{client.userCount}</td>
<td>{new Date(client.createdAt).toLocaleDateString()}</td>
<td>
<button
className="btn-small btn-danger"
onClick={() => deleteClient(client.clientId, sessionToken, onRefresh)}
>
Deactivate
</button>
</td>
</tr>
))}
{clients.length === 0 && (
<tr><td colSpan="5" className="empty-row">No clients found</td></tr>
)}
</tbody>
</table>
</div>
);
}
function CreateClientForm({ sessionToken, onSuccess, onCancel }) {
const [clientName, setClientName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const response = await fetch(`${API_BASE_URL}/api/admin/clients`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Session-Token': sessionToken,
},
body: JSON.stringify({ clientName }),
});
const data = await response.json();
if (data.ok) {
onSuccess();
} else {
setError(data.error || 'Failed to create client');
}
} catch (err) {
setError('Network error: ' + err.message);
} finally {
setLoading(false);
}
};
return (
<div className="admin-form">
<h3>Create Client</h3>
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Client Name</label>
<input
type="text"
value={clientName}
onChange={(e) => setClientName(e.target.value)}
placeholder="e.g., Acme Corporation"
disabled={loading}
required
autoFocus
/>
</div>
<div className="form-buttons">
<button type="button" className="btn-cancel" onClick={onCancel} disabled={loading}>Cancel</button>
<button type="submit" className="btn-primary" disabled={loading || !clientName.trim()}>
{loading ? 'Creating...' : 'Create Client'}
</button>
</div>
</form>
</div>
);
}
async function deleteClient(clientId, sessionToken, onRefresh) {
if (!confirm('Are you sure you want to deactivate this client?')) return;
try {
const response = await fetch(`${API_BASE_URL}/api/admin/clients/${clientId}`, {
method: 'DELETE',
headers: { 'X-Session-Token': sessionToken },
});
const data = await response.json();
if (data.ok) {
onRefresh();
} else {
alert(data.error || 'Failed to deactivate client');
}
} catch (err) {
alert('Network error: ' + err.message);
}
}
function StatusBadge({ status }) {
const statusClass = {
'Active': 'status-active',
'Inactive': 'status-inactive',
}[status] || 'status-default';
return <span className={`status-badge ${statusClass}`}>{status}</span>;
}

View File

@@ -0,0 +1,425 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useAdmin } from '../../context/AdminContext';
// ─── Constants ────────────────────────────────────────────────────────────────
const CATEGORIES = ['Business', 'Technical', 'Legal', 'Operations', 'Financial'];
const CATEGORY_COLORS = {
Business: { bg: '#dbeafe', text: '#1e40af' },
Technical: { bg: '#dcfce7', text: '#166534' },
Legal: { bg: '#fef9c3', text: '#854d0e' },
Operations: { bg: '#ede9fe', text: '#5b21b6' },
Financial: { bg: '#fce7f3', text: '#9d174d' },
};
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatBytes(bytes) {
if (!bytes) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(1)} MB`;
}
function formatDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString('en-US', {
month: 'short', day: 'numeric', year: 'numeric',
hour: 'numeric', minute: '2-digit',
});
}
function FileIcon({ mimeType }) {
if (!mimeType) return <span>📄</span>;
if (mimeType.includes('pdf')) return <span>📕</span>;
if (mimeType.includes('word') || mimeType.includes('document')) return <span>📘</span>;
if (mimeType.includes('sheet') || mimeType.includes('excel')) return <span>📗</span>;
if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) return <span>📙</span>;
if (mimeType.includes('image')) return <span>🖼</span>;
if (mimeType.includes('zip') || mimeType.includes('compressed')) return <span>📦</span>;
return <span>📄</span>;
}
function CategoryBadge({ category }) {
const style = CATEGORY_COLORS[category] || { bg: '#f1f5f9', text: '#475569' };
return (
<span style={{
background: style.bg, color: style.text,
padding: '2px 8px', borderRadius: 12,
fontSize: 11, fontWeight: 600, letterSpacing: 0.3,
whiteSpace: 'nowrap',
}}>
{category || 'Uncategorized'}
</span>
);
}
// ─── Component ────────────────────────────────────────────────────────────────
export default function DocumentsPanel() {
const { apiCall } = useAdmin();
const [docs, setDocs] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState(null);
const [deleteId, setDeleteId] = useState(null);
const [deleting, setDeleting] = useState(false);
const [downloading, setDownloading] = useState(null);
const [filterCat, setFilterCat] = useState('');
const [search, setSearch] = useState('');
// Upload form
const [showUpload, setShowUpload] = useState(false);
const [uploadFile, setUploadFile] = useState(null);
const [uploadCat, setUploadCat] = useState('Technical');
const [uploadDesc, setUploadDesc] = useState('');
const fileInputRef = useRef();
// ─── Load ─────────────────────────────────────────────────────────────────
const loadDocs = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await apiCall('/api/documents/list', 'POST', {});
if (result?.ok && Array.isArray(result.documents)) {
setDocs(result.documents);
} else {
setError(result?.error || 'Failed to load documents');
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [apiCall]);
useEffect(() => { loadDocs(); }, [loadDocs]);
// ─── Upload ───────────────────────────────────────────────────────────────
const handleUpload = async () => {
if (!uploadFile) { setUploadError('Please select a file'); return; }
setUploading(true);
setUploadError(null);
try {
const formData = new FormData();
formData.append('file', uploadFile);
formData.append('category', uploadCat);
formData.append('description', uploadDesc);
// apiCall doesn't support FormData directly — use raw fetch with the
// same auth header that apiCall would use
const result = await apiCall('/api/documents', {
method: 'POST',
body: formData,
// Don't set Content-Type — browser sets multipart boundary
skipJson: true,
});
if (!result?.ok) throw new Error(result?.error || 'Upload failed');
setShowUpload(false);
setUploadFile(null);
setUploadDesc('');
setUploadCat('Technical');
await loadDocs();
} catch (err) {
setUploadError(err.message);
} finally {
setUploading(false);
}
};
// ─── Download ─────────────────────────────────────────────────────────────
const handleDownload = async (doc) => {
setDownloading(doc.docId);
try {
const result = await apiCall(`/api/documents/${doc.docId}/download`, {
rawResponse: true,
});
if (!result) throw new Error('No response');
const blob = await result.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = doc.docFileName;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
alert(`Download failed: ${err.message}`);
} finally {
setDownloading(null);
}
};
// ─── Delete ───────────────────────────────────────────────────────────────
const handleDelete = async () => {
if (!deleteId) return;
setDeleting(true);
try {
const result = await apiCall(`/api/documents/${deleteId}`, { method: 'DELETE' });
if (!result?.ok) throw new Error(result?.error || 'Delete failed');
setDeleteId(null);
await loadDocs();
} catch (err) {
alert(`Delete failed: ${err.message}`);
} finally {
setDeleting(false);
}
};
// ─── Filter ───────────────────────────────────────────────────────────────
const filtered = docs.filter(d => {
const matchCat = !filterCat || d.docCategory === filterCat;
const matchSrch = !search ||
d.docFileName.toLowerCase().includes(search.toLowerCase()) ||
(d.docDescription || '').toLowerCase().includes(search.toLowerCase()) ||
(d.docUploadedBy || '').toLowerCase().includes(search.toLowerCase());
return matchCat && matchSrch;
});
const categories = [...new Set(docs.map(d => d.docCategory).filter(Boolean))];
// ─── Render ───────────────────────────────────────────────────────────────
return (
<div className="data-panel" style={{ maxWidth: 1000 }}>
{/* Header */}
<div className="panel-header">
<div>
<h3 className="panel-title">Documents</h3>
<p className="panel-subtitle">
{loading ? 'Loading…' : `${docs.length} document${docs.length !== 1 ? 's' : ''}`}
</p>
</div>
<button
className="btn-action"
onClick={() => { setShowUpload(true); setUploadError(null); }}
>
Upload Document
</button>
</div>
{/* Upload form */}
{showUpload && (
<div className="admin-form" style={{ marginBottom: 20 }}>
<div className="form-title">Upload New Document</div>
{/* File picker */}
<div className="form-group">
<label className="form-label">File</label>
<div
style={{
border: `2px dashed ${uploadFile ? 'var(--accent)' : '#cbd5e1'}`,
borderRadius: 6, padding: '16px 20px', textAlign: 'center',
cursor: 'pointer', background: uploadFile ? '#eff6ff' : '#fff',
transition: 'all 0.2s',
}}
onClick={() => fileInputRef.current?.click()}
>
{uploadFile ? (
<div style={{ color: 'var(--accent)', fontWeight: 600 }}>
📎 {uploadFile.name}
<span style={{ color: '#64748b', fontWeight: 400, marginLeft: 8 }}>
({formatBytes(uploadFile.size)})
</span>
</div>
) : (
<div style={{ color: '#94a3b8' }}>
Click to choose a file, or drag and drop
</div>
)}
<input
ref={fileInputRef}
type="file"
style={{ display: 'none' }}
onChange={e => setUploadFile(e.target.files?.[0] || null)}
/>
</div>
</div>
{/* Category */}
<div className="form-group">
<label className="form-label">Category</label>
<select
className="form-select"
value={uploadCat}
onChange={e => setUploadCat(e.target.value)}
>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
{/* Description */}
<div className="form-group">
<label className="form-label">Description (optional)</label>
<input
className="form-input"
type="text"
placeholder="Brief description…"
value={uploadDesc}
onChange={e => setUploadDesc(e.target.value)}
/>
</div>
{uploadError && (
<div className="error-message" style={{ marginBottom: 12 }}>{uploadError}</div>
)}
<div className="form-buttons">
<button
className="btn-action"
onClick={handleUpload}
disabled={uploading || !uploadFile}
>
{uploading ? 'Uploading…' : 'Upload'}
</button>
<button
className="btn-cancel"
onClick={() => { setShowUpload(false); setUploadFile(null); setUploadError(null); }}
disabled={uploading}
>
Cancel
</button>
</div>
</div>
)}
{/* Filters */}
{!loading && !error && docs.length > 0 && (
<div className="filter-bar" style={{ marginBottom: 16, display: 'flex', gap: 10 }}>
<input
className="filter-input"
type="text"
placeholder="Search by name, description, uploader…"
value={search}
onChange={e => setSearch(e.target.value)}
style={{ flex: 1 }}
/>
<select
className="filter-select"
value={filterCat}
onChange={e => setFilterCat(e.target.value)}
style={{ minWidth: 140 }}
>
<option value="">All Categories</option>
{categories.map(c => <option key={c} value={c}>{c}</option>)}
</select>
{(search || filterCat) && (
<button
className="btn-cancel"
onClick={() => { setSearch(''); setFilterCat(''); }}
>
Clear
</button>
)}
</div>
)}
{/* States */}
{loading && <div className="loading-message">Loading documents</div>}
{error && <div className="error-message">{error}</div>}
{/* Document list */}
{!loading && !error && (
<>
{filtered.length === 0 ? (
<div className="empty-state">
{docs.length === 0
? 'No documents uploaded yet. Click Upload Document to add the first one.'
: 'No documents match your current filter.'}
</div>
) : (
<table className="data-table">
<thead>
<tr>
<th style={{ width: 36 }}></th>
<th>File</th>
<th>Category</th>
<th>Size</th>
<th>Uploaded By</th>
<th>Date</th>
<th style={{ width: 100 }}>Actions</th>
</tr>
</thead>
<tbody>
{filtered.map(doc => (
<tr key={doc.docId}>
<td style={{ textAlign: 'center', fontSize: 18 }}>
<FileIcon mimeType={doc.docMimeType} />
</td>
<td>
<div style={{ fontWeight: 500, color: '#1e293b' }}>
{doc.docFileName}
</div>
{doc.docDescription && (
<div style={{ fontSize: 12, color: '#64748b', marginTop: 2 }}>
{doc.docDescription}
</div>
)}
</td>
<td><CategoryBadge category={doc.docCategory} /></td>
<td style={{ color: '#64748b', fontSize: 13 }}>
{formatBytes(doc.docFileSize)}
</td>
<td style={{ color: '#64748b', fontSize: 13 }}>
{doc.docUploadedBy || '—'}
</td>
<td style={{ color: '#64748b', fontSize: 13 }}>
{formatDate(doc.docUploadedAt)}
</td>
<td>
<div style={{ display: 'flex', gap: 6 }}>
<button
className="btn-icon"
title="Download"
disabled={downloading === doc.docId}
onClick={() => handleDownload(doc)}
>
{downloading === doc.docId ? '…' : '↓'}
</button>
<button
className="btn-icon btn-icon-danger"
title="Delete"
onClick={() => setDeleteId(doc.docId)}
>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
{/* Delete confirm dialog */}
{deleteId && (
<div className="modal-overlay">
<div className="modal-box">
<h4 className="modal-title">Delete Document?</h4>
<p className="modal-body">
This will permanently delete the document. This action cannot be undone.
</p>
<div className="modal-buttons">
<button
className="btn-danger"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? 'Deleting…' : 'Delete'}
</button>
<button
className="btn-cancel"
onClick={() => setDeleteId(null)}
disabled={deleting}
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,345 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useAdmin } from '../../context/AdminContext';
// ─── HelpPanel ────────────────────────────────────────────────
// Admin CRUD for contextual help content.
// Manages HelpContent table via /api/admin/help.
//
// Key naming convention: {app}.{section}.{element}
// client.wizard.budget client.wizard.objective
// client.wizard.audience client.wizard.channels
// admin.campaigns.pacing admin.clients.approval
// ─────────────────────────────────────────────────────────────
export default function HelpPanel() {
const { apiCall } = useAdmin();
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [toast, setToast] = useState(null); // { msg, type }
const [showInactive, setShowInactive] = useState(false);
// Edit state
const [editKey, setEditKey] = useState(null); // helpKey being edited, or 'new'
const [form, setForm] = useState({ helpKey: '', title: '', body: '', isActive: true });
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(null); // helpKey being deleted
const [confirmDelete, setConfirmDelete] = useState(null);
const bodyRef = useRef(null);
// ── Toast ──
const showToast = useCallback((msg, type = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 3000);
}, []);
// ── Fetch list ──
const loadItems = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await apiCall('/api/admin/help/list', 'POST', { includeInactive: showInactive });
if (res.ok) setItems(res.items || []);
else setError(res.error || 'Failed to load help content');
} catch {
setError('Network error loading help content');
} finally {
setLoading(false);
}
}, [apiCall, showInactive]);
useEffect(() => { loadItems(); }, [loadItems]);
// ── Start editing ──
const startEdit = (item) => {
setEditKey(item.helpKey);
setForm({
helpKey: item.helpKey,
title: item.title,
body: item.body,
isActive: item.isActive,
});
};
const startNew = () => {
setEditKey('new');
setForm({ helpKey: '', title: '', body: '', isActive: true });
};
const cancelEdit = () => {
setEditKey(null);
setForm({ helpKey: '', title: '', body: '', isActive: true });
};
// ── Save ──
const handleSave = async () => {
if (!form.helpKey.trim()) return showToast('Help key is required', 'error');
if (!form.title.trim()) return showToast('Title is required', 'error');
if (!form.body.trim()) return showToast('Body content is required', 'error');
setSaving(true);
try {
const res = await apiCall('/api/admin/help', 'POST', {
helpKey: form.helpKey.trim().toLowerCase(),
title: form.title.trim(),
body: form.body.trim(),
isActive: form.isActive,
});
if (res.ok) {
showToast(`Saved "${form.helpKey}"`);
cancelEdit();
loadItems();
} else {
showToast(res.error || 'Save failed', 'error');
}
} catch {
showToast('Network error saving help content', 'error');
} finally {
setSaving(false);
}
};
// ── Delete ──
const handleDelete = async (helpKey) => {
setDeleting(helpKey);
try {
const res = await apiCall(`/api/admin/help/${encodeURIComponent(helpKey)}`, 'DELETE');
if (res.ok) {
showToast(`Deleted "${helpKey}"`);
setConfirmDelete(null);
loadItems();
} else {
showToast(res.error || 'Delete failed', 'error');
}
} catch {
showToast('Network error deleting entry', 'error');
} finally {
setDeleting(null);
}
};
// ── Key suggestions ──
const KEY_SUGGESTIONS = [
'client.wizard.objective', 'client.wizard.audience',
'client.wizard.budget', 'client.wizard.channels',
'client.wizard.creative', 'client.wizard.review',
'admin.campaigns.status', 'admin.campaigns.pacing',
'admin.clients.approval', 'admin.clients.status',
];
const unusedSuggestions = KEY_SUGGESTIONS.filter(
k => !items.find(i => i.helpKey === k)
);
// ── Render ──
return (
<div className="help-panel">
{/* Toast */}
{toast && (
<div className={`help-panel-toast ${toast.type === 'error' ? 'help-panel-toast-error' : ''}`}>
{toast.msg}
</div>
)}
{/* Header */}
<div className="panel-toolbar">
<div className="panel-toolbar-left">
<h2 className="panel-title">Help Content</h2>
<span className="panel-count">{items.length} entries</span>
</div>
<div className="panel-toolbar-right">
<label className="toggle-label">
<input
type="checkbox"
checked={showInactive}
onChange={e => setShowInactive(e.target.checked)}
/>
Show inactive
</label>
<button className="btn-primary" onClick={startNew}>
+ New Entry
</button>
</div>
</div>
{/* New / edit form */}
{editKey && (
<div className="help-edit-card">
<div className="help-edit-header">
<h3>{editKey === 'new' ? 'New Help Entry' : `Editing: ${editKey}`}</h3>
<button className="btn-ghost" onClick={cancelEdit}>Cancel</button>
</div>
{/* Help key */}
<div className="help-form-row">
<label className="help-form-label">
Help Key
<span className="help-form-hint">Lowercase, dots/hyphens only</span>
</label>
{editKey === 'new' ? (
<div>
<input
className="form-input"
placeholder="e.g. client.wizard.budget"
value={form.helpKey}
onChange={e => setForm(f => ({ ...f, helpKey: e.target.value.toLowerCase() }))}
style={{ width: '100%', marginBottom: 6 }}
/>
{unusedSuggestions.length > 0 && (
<div className="help-key-suggestions">
<span className="help-form-hint">Suggested:</span>
{unusedSuggestions.slice(0, 5).map(k => (
<button
key={k}
className="help-key-chip"
onClick={() => setForm(f => ({ ...f, helpKey: k }))}
>{k}</button>
))}
</div>
)}
</div>
) : (
<div className="help-key-display">{form.helpKey}</div>
)}
</div>
{/* Title */}
<div className="help-form-row">
<label className="help-form-label">Title</label>
<input
className="form-input"
placeholder="Modal heading shown to users"
value={form.title}
onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
style={{ width: '100%' }}
/>
</div>
{/* Body */}
<div className="help-form-row">
<label className="help-form-label">
Body (HTML supported)
<span className="help-form-hint">
&lt;p&gt; &lt;ul&gt; &lt;li&gt; &lt;strong&gt; &lt;h4&gt; tags render in popup
</span>
</label>
<textarea
ref={bodyRef}
className="form-input help-body-textarea"
placeholder="<p>Your help text here…</p>"
value={form.body}
onChange={e => setForm(f => ({ ...f, body: e.target.value }))}
rows={8}
style={{ width: '100%', fontFamily: 'var(--font-mono, monospace)', fontSize: 13 }}
/>
</div>
{/* Active toggle */}
<div className="help-form-row help-form-row-inline">
<label className="toggle-label">
<input
type="checkbox"
checked={form.isActive}
onChange={e => setForm(f => ({ ...f, isActive: e.target.checked }))}
/>
Active (visible to users)
</label>
<button
className="btn-primary"
onClick={handleSave}
disabled={saving}
>
{saving ? 'Saving…' : 'Save Entry'}
</button>
</div>
</div>
)}
{/* Loading / error */}
{loading && <div className="loading-state">Loading help content</div>}
{error && !loading && (
<div className="error-message">{error}</div>
)}
{/* Entries table */}
{!loading && !error && items.length === 0 && (
<div className="empty-state">
<p>No help entries yet.</p>
<p style={{ fontSize: 13, marginTop: 6, color: 'var(--text-secondary, #666)' }}>
Click <strong>+ New Entry</strong> to create your first help entry,
or run the seed data in HelpContent.sql to get started quickly.
</p>
</div>
)}
{!loading && items.length > 0 && (
<div className="help-table-wrap">
<table className="data-table">
<thead>
<tr>
<th>Help Key</th>
<th>Title</th>
<th style={{ width: 70 }}>Active</th>
<th style={{ width: 80 }}>Updated</th>
<th style={{ width: 100 }}></th>
</tr>
</thead>
<tbody>
{items.map(item => (
<tr key={item.helpKey}
className={!item.isActive ? 'help-row-inactive' : ''}>
<td>
<code className="help-key-code">{item.helpKey}</code>
</td>
<td>{item.title}</td>
<td>
<span className={`status-badge ${item.isActive ? 'status-active' : 'status-inactive'}`}>
{item.isActive ? 'Active' : 'Off'}
</span>
</td>
<td className="text-dim">
{item.updatedAt
? new Date(item.updatedAt).toLocaleDateString()
: '—'}
</td>
<td>
<div className="row-actions">
<button
className="btn-ghost btn-sm"
onClick={() => startEdit(item)}
disabled={editKey === item.helpKey}
>Edit</button>
{confirmDelete === item.helpKey ? (
<>
<button
className="btn-danger btn-sm"
onClick={() => handleDelete(item.helpKey)}
disabled={deleting === item.helpKey}
>
{deleting === item.helpKey ? '…' : 'Confirm'}
</button>
<button
className="btn-ghost btn-sm"
onClick={() => setConfirmDelete(null)}
></button>
</>
) : (
<button
className="btn-ghost btn-sm btn-ghost-danger"
onClick={() => setConfirmDelete(item.helpKey)}
>Del</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,702 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAdmin } from '../../context/AdminContext';
import '../../styles/intelligence-panel.css';
/**
* IntelligencePanel — Campaign performance monitoring, optimization insights,
* and post-campaign analysis. Manages its own data fetching.
*
* Attempts live API calls to /api/admin/reporting/*; falls back to
* emulated data when the endpoint isn't wired up yet.
*/
// ─── Channel metadata ────────────────────────────────────────────────────────
const CHANNEL_META = {
google_ads: { label: 'Google', color: '#1a73e8', bg: '#e8f0fe' },
meta: { label: 'Meta', color: '#7c3aed', bg: '#f3e8ff' },
tiktok: { label: 'TikTok', color: '#ff0050', bg: '#fff0f0' },
};
const SEVERITY_META = {
critical: { label: 'Critical', color: '#dc2626', bg: '#fef2f2', icon: '🔴' },
warning: { label: 'Warning', color: '#d97706', bg: '#fffbeb', icon: '🟡' },
info: { label: 'Info', color: '#2563eb', bg: '#eff6ff', icon: '🔵' },
};
// ─── Formatters ──────────────────────────────────────────────────────────────
const fmtCurrency = (v) => v == null ? '—' : '$' + Number(v).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 });
const fmtNumber = (v) => v == null ? '—' : Number(v).toLocaleString();
const fmtPct = (v) => v == null ? '—' : Number(v).toFixed(2) + '%';
const fmtDec = (v, d = 2) => v == null ? '—' : '$' + Number(v).toFixed(d);
// ─── Main Panel ──────────────────────────────────────────────────────────────
export default function IntelligencePanel({ activeTab }) {
return (
<div className="intel-panel">
{activeTab === 'performance' && <PerformanceView />}
{activeTab === 'insights' && <InsightsView />}
{activeTab === 'analysis' && <AnalysisView />}
</div>
);
}
// ═════════════════════════════════════════════════════════════════════════════
// PERFORMANCE VIEW
// ═════════════════════════════════════════════════════════════════════════════
function PerformanceView() {
const { apiCall } = useAdmin();
const [summary, setSummary] = useState(null);
const [campaigns, setCampaigns] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [clientFilter, setClientFilter] = useState('');
const [clients, setClients] = useState([]);
const [emulated, setEmulated] = useState(false);
// Load clients for filter dropdown
useEffect(() => {
(async () => {
const r = await apiCall('/api/admin/clients/list', 'POST', {});
if (r.ok && r.clients) setClients(r.clients);
})();
}, [apiCall]);
const loadData = useCallback(async () => {
setLoading(true);
setError(null);
setEmulated(false);
try {
const reportBody = {};
if (dateFrom) reportBody.dateFrom = dateFrom;
if (dateTo) reportBody.dateTo = dateTo;
if (clientFilter) reportBody.clientId = clientFilter;
const [summaryRes, campaignsRes] = await Promise.all([
apiCall('/api/admin/reporting/summary', 'POST', reportBody),
apiCall('/api/admin/reporting/campaigns', 'POST', reportBody),
]);
if (summaryRes.ok) {
setSummary(summaryRes);
setCampaigns(campaignsRes.ok ? campaignsRes : null);
} else {
// API not wired yet — use emulated data
setSummary(EMULATED_SUMMARY);
setCampaigns(EMULATED_CAMPAIGNS);
setEmulated(true);
}
} catch {
setSummary(EMULATED_SUMMARY);
setCampaigns(EMULATED_CAMPAIGNS);
setEmulated(true);
}
setLoading(false);
}, [apiCall, dateFrom, dateTo, clientFilter]);
useEffect(() => { loadData(); }, [loadData]);
if (loading && !summary) {
return <div className="intel-loading"><div className="spinner"></div><p>Loading performance data...</p></div>;
}
const s = summary || {};
const campaignList = campaigns?.campaigns || [];
return (
<div className="intel-performance">
{emulated && <EmulatedBanner />}
{/* ── Filter Bar ── */}
<div className="filter-bar">
<div className="filter-group">
<label className="filter-label">From</label>
<input type="date" className="filter-input" value={dateFrom} onChange={e => setDateFrom(e.target.value)} />
</div>
<div className="filter-group">
<label className="filter-label">To</label>
<input type="date" className="filter-input" value={dateTo} onChange={e => setDateTo(e.target.value)} />
</div>
<div className="filter-group">
<label className="filter-label">Client</label>
<select className="filter-select" value={clientFilter} onChange={e => setClientFilter(e.target.value)}>
<option value="">All Clients</option>
{clients.map(c => <option key={c.clientId} value={c.clientId}>{c.clientName}</option>)}
</select>
</div>
<button className="btn-action" onClick={loadData} style={{ alignSelf: 'flex-end' }}> Refresh</button>
</div>
{/* ── KPI Cards ── */}
<div className="intel-kpi-grid">
<KpiCard label="Active Campaigns" value={s.activeCampaigns} format="number" />
<KpiCard label="Total Spend" value={s.totalSpend} format="currency" />
<KpiCard label="Impressions" value={s.totalImpressions} format="number" />
<KpiCard label="Clicks" value={s.totalClicks} format="number" />
<KpiCard label="Conversions" value={s.totalConversions} format="number" />
<KpiCard
label="Avg CTR"
value={s.totalImpressions > 0 ? (s.totalClicks / s.totalImpressions * 100) : 0}
format="pct"
/>
</div>
{/* ── Channel Breakdown ── */}
{s.channelBreakdown && s.channelBreakdown.length > 0 && (
<div className="intel-channel-breakdown">
<h3>Channel Breakdown</h3>
<div className="intel-channel-cards">
{s.channelBreakdown.map(ch => {
const meta = CHANNEL_META[ch.channel] || { label: ch.channel, color: '#6b7280', bg: '#f3f4f6' };
return (
<div key={ch.channel} className="intel-channel-card" style={{ borderTopColor: meta.color }}>
<div className="intel-channel-header">
<span className="intel-channel-badge" style={{ background: meta.bg, color: meta.color }}>{meta.label}</span>
<span className="intel-channel-count">{ch.campaignCount} campaign{ch.campaignCount !== 1 ? 's' : ''}</span>
</div>
<div className="intel-channel-metrics">
<div><span className="metric-val">{fmtCurrency(ch.spend)}</span><span className="metric-lbl">Spend</span></div>
<div><span className="metric-val">{fmtNumber(ch.impressions)}</span><span className="metric-lbl">Impressions</span></div>
<div><span className="metric-val">{fmtNumber(ch.clicks)}</span><span className="metric-lbl">Clicks</span></div>
<div><span className="metric-val">{fmtNumber(ch.conversions)}</span><span className="metric-lbl">Conversions</span></div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* ── Campaign Performance Table ── */}
<div className="intel-campaigns-table">
<h3>Campaign Performance</h3>
{campaignList.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">📊</div>
<h3>No performance data yet</h3>
<p>Metrics will appear here once campaigns are running and the polling service collects data.</p>
</div>
) : (
<table className="data-table">
<thead>
<tr>
<th>Campaign</th>
<th>Client</th>
<th>Status</th>
<th>Channels</th>
<th style={{ textAlign: 'right' }}>Spend</th>
<th style={{ textAlign: 'right' }}>Impressions</th>
<th style={{ textAlign: 'right' }}>Clicks</th>
<th style={{ textAlign: 'right' }}>CTR</th>
<th style={{ textAlign: 'right' }}>CPC</th>
<th style={{ textAlign: 'right' }}>Conv.</th>
<th>Pacing</th>
</tr>
</thead>
<tbody>
{campaignList.map(c => (
<CampaignPerfRow key={c.initiativeId} campaign={c} />
))}
</tbody>
</table>
)}
</div>
</div>
);
}
// ─── Campaign Performance Row ────────────────────────────────────────────────
function CampaignPerfRow({ campaign }) {
const c = campaign;
const channels = c.channels || [];
const pacing = c.budgetPacing || 0;
return (
<tr>
<td style={{ fontWeight: 500 }}>{c.name}</td>
<td>{c.clientName || '—'}</td>
<td><StatusBadge status={c.status} /></td>
<td>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{channels.map((ch, i) => {
const meta = CHANNEL_META[ch.channelType] || { label: ch.channelType, bg: '#f3f4f6', color: '#6b7280' };
return (
<span key={i} className="channel-badge" style={{ background: meta.bg, color: meta.color }}>
{meta.label}
</span>
);
})}
</div>
</td>
<td style={{ textAlign: 'right' }}>{fmtCurrency(c.spend)}</td>
<td style={{ textAlign: 'right' }}>{fmtNumber(c.impressions)}</td>
<td style={{ textAlign: 'right' }}>{fmtNumber(c.clicks)}</td>
<td style={{ textAlign: 'right' }}>{fmtPct(c.ctr)}</td>
<td style={{ textAlign: 'right' }}>{fmtDec(c.cpc)}</td>
<td style={{ textAlign: 'right' }}>{fmtNumber(c.conversions)}</td>
<td><PacingBar pct={pacing} /></td>
</tr>
);
}
// ═════════════════════════════════════════════════════════════════════════════
// INSIGHTS VIEW
// ═════════════════════════════════════════════════════════════════════════════
function InsightsView() {
const { apiCall } = useAdmin();
const [insights, setInsights] = useState(null);
const [loading, setLoading] = useState(true);
const [severityFilter, setSeverityFilter] = useState('');
const [emulated, setEmulated] = useState(false);
const loadData = useCallback(async () => {
setLoading(true);
setEmulated(false);
const insightsBody = {};
if (severityFilter) insightsBody.severity = severityFilter;
const res = await apiCall('/api/admin/reporting/insights', 'POST', insightsBody);
if (res.ok) {
setInsights(res);
} else {
setInsights(EMULATED_INSIGHTS);
setEmulated(true);
}
setLoading(false);
}, [apiCall, severityFilter]);
useEffect(() => { loadData(); }, [loadData]);
const insightList = insights?.insights || [];
const totalActive = insights?.totalActive ?? insightList.length;
if (loading && !insights) {
return <div className="intel-loading"><div className="spinner"></div><p>Loading insights...</p></div>;
}
return (
<div className="intel-insights">
{emulated && <EmulatedBanner />}
<div className="intel-insights-header">
<div className="intel-insights-summary">
<span className="intel-insights-count">{totalActive}</span>
<span>active insight{totalActive !== 1 ? 's' : ''}</span>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<select className="filter-select" value={severityFilter} onChange={e => setSeverityFilter(e.target.value)}>
<option value="">All Severities</option>
<option value="critical">Critical</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
</select>
<button className="btn-action" onClick={loadData}> Refresh</button>
</div>
</div>
{insightList.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon"></div>
<h3>All clear</h3>
<p>No active optimization recommendations at this time.</p>
</div>
) : (
<div className="intel-insights-list">
{insightList.map(insight => (
<InsightCard key={insight.insightId} insight={insight} />
))}
</div>
)}
</div>
);
}
function InsightCard({ insight }) {
const sev = SEVERITY_META[insight.severity] || SEVERITY_META.info;
const [expanded, setExpanded] = useState(false);
return (
<div className="intel-insight-card" style={{ borderLeftColor: sev.color }}>
<div className="intel-insight-top" onClick={() => setExpanded(!expanded)} style={{ cursor: 'pointer' }}>
<div className="intel-insight-meta">
<span className="intel-insight-severity" style={{ background: sev.bg, color: sev.color }}>
{sev.icon} {sev.label}
</span>
<span className="intel-insight-type">{formatInsightType(insight.insightType)}</span>
</div>
<h4 className="intel-insight-title">{insight.title}</h4>
<div className="intel-insight-context">
<span>{insight.clientName}</span>
<span>·</span>
<span>{insight.campaignName}</span>
<span className="intel-insight-expand">{expanded ? '▾' : '▸'}</span>
</div>
</div>
{expanded && (
<div className="intel-insight-body">
<p className="intel-insight-desc">{insight.description}</p>
{insight.recommendation && (
<div className="intel-insight-rec">
<strong>Recommendation:</strong> {insight.recommendation}
</div>
)}
</div>
)}
</div>
);
}
// ═════════════════════════════════════════════════════════════════════════════
// ANALYSIS VIEW
// ═════════════════════════════════════════════════════════════════════════════
function AnalysisView() {
const { apiCall } = useAdmin();
const [analysis, setAnalysis] = useState(null);
const [loading, setLoading] = useState(true);
const [emulated, setEmulated] = useState(false);
const loadData = useCallback(async () => {
setLoading(true);
setEmulated(false);
const res = await apiCall('/api/admin/reporting/analysis', 'POST', {});
if (res.ok) {
setAnalysis(res);
} else {
setAnalysis(EMULATED_ANALYSIS);
setEmulated(true);
}
setLoading(false);
}, [apiCall]);
useEffect(() => { loadData(); }, [loadData]);
const campaignList = analysis?.campaigns || [];
if (loading && !analysis) {
return <div className="intel-loading"><div className="spinner"></div><p>Loading analysis...</p></div>;
}
return (
<div className="intel-analysis">
{emulated && <EmulatedBanner />}
<div className="panel-header">
<h2>Post-Campaign Analysis ({campaignList.length})</h2>
<button className="btn-action" onClick={loadData}> Refresh</button>
</div>
{campaignList.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">📈</div>
<h3>No completed campaigns</h3>
<p>Post-campaign analysis will appear here once campaigns finish their run.</p>
</div>
) : (
<div className="intel-analysis-list">
{campaignList.map(c => (
<AnalysisCard key={c.initiativeId} campaign={c} />
))}
</div>
)}
</div>
);
}
function AnalysisCard({ campaign }) {
const c = campaign;
const channels = c.channels || [];
const [expanded, setExpanded] = useState(false);
return (
<div className="intel-analysis-card">
<div className="intel-analysis-top" onClick={() => setExpanded(!expanded)} style={{ cursor: 'pointer' }}>
<div className="intel-analysis-header">
<div>
<h4>{c.name}</h4>
<span className="text-muted">{c.clientName} · {c.objective}</span>
</div>
<span className="intel-analysis-expand">{expanded ? '▾' : '▸'}</span>
</div>
<div className="intel-analysis-kpis">
<div className="intel-analysis-kpi">
<span className="metric-val">{fmtCurrency(c.totalSpend)}</span>
<span className="metric-lbl">Spent of {fmtCurrency(c.totalBudget)}</span>
</div>
<div className="intel-analysis-kpi">
<span className="metric-val">{fmtNumber(c.totalImpressions)}</span>
<span className="metric-lbl">Impressions</span>
</div>
<div className="intel-analysis-kpi">
<span className="metric-val">{fmtNumber(c.totalClicks)}</span>
<span className="metric-lbl">Clicks</span>
</div>
<div className="intel-analysis-kpi">
<span className="metric-val">{fmtPct(c.ctr)}</span>
<span className="metric-lbl">CTR</span>
</div>
<div className="intel-analysis-kpi">
<span className="metric-val">{fmtNumber(c.totalConversions)}</span>
<span className="metric-lbl">Conversions</span>
</div>
<div className="intel-analysis-kpi">
<span className="metric-val">{fmtDec(c.costPerConversion)}</span>
<span className="metric-lbl">Cost/Conv.</span>
</div>
</div>
{/* Budget utilization bar */}
<div className="intel-analysis-budget">
<PacingBar pct={c.budgetUtilization || 0} label="Budget Utilization" showLabel />
</div>
</div>
{expanded && channels.length > 0 && (
<div className="intel-analysis-channels">
<h5>Channel Performance</h5>
<table className="data-table">
<thead>
<tr>
<th>Channel</th>
<th style={{ textAlign: 'right' }}>Allocated</th>
<th style={{ textAlign: 'right' }}>Actual Spend</th>
<th style={{ textAlign: 'right' }}>Impressions</th>
<th style={{ textAlign: 'right' }}>Clicks</th>
<th style={{ textAlign: 'right' }}>Conv.</th>
</tr>
</thead>
<tbody>
{channels.map((ch, i) => {
const meta = CHANNEL_META[ch.channel] || { label: ch.channel, bg: '#f3f4f6', color: '#6b7280' };
return (
<tr key={i}>
<td>
<span className="channel-badge" style={{ background: meta.bg, color: meta.color }}>
{meta.label}
</span>
</td>
<td style={{ textAlign: 'right' }}>{ch.allocatedPct != null ? ch.allocatedPct + '%' : '—'}</td>
<td style={{ textAlign: 'right' }}>{fmtCurrency(ch.spend)}</td>
<td style={{ textAlign: 'right' }}>{fmtNumber(ch.impressions)}</td>
<td style={{ textAlign: 'right' }}>{fmtNumber(ch.clicks)}</td>
<td style={{ textAlign: 'right' }}>{fmtNumber(ch.conversions)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
// ═════════════════════════════════════════════════════════════════════════════
// SHARED COMPONENTS
// ═════════════════════════════════════════════════════════════════════════════
function KpiCard({ label, value, format = 'number' }) {
let display = '—';
if (value != null) {
if (format === 'currency') display = fmtCurrency(value);
else if (format === 'pct') display = fmtPct(value);
else display = fmtNumber(value);
}
return (
<div className="intel-kpi-card">
<div className="intel-kpi-value">{display}</div>
<div className="intel-kpi-label">{label}</div>
</div>
);
}
function StatusBadge({ status }) {
const s = (status || '').toLowerCase();
let className = 'status-default';
if (s === 'active' || s === 'launched') className = 'status-active';
else if (s === 'staged' || s === 'pending' || s === 'draft') className = 'status-pending';
else if (s === 'paused') className = 'status-warning';
else if (s === 'error') className = 'status-error';
else if (s === 'completed' || s === 'ended') className = 'status-inactive';
return <span className={`status-badge ${className}`}>{status || '—'}</span>;
}
function PacingBar({ pct, label, showLabel }) {
const clamped = Math.min(Math.max(pct, 0), 100);
const barColor = clamped > 95 ? '#dc2626' : clamped > 75 ? '#d97706' : '#16a34a';
return (
<div className="intel-pacing">
{showLabel && label && <span className="intel-pacing-label">{label}</span>}
<div className="intel-pacing-track">
<div className="intel-pacing-fill" style={{ width: clamped + '%', background: barColor }} />
</div>
<span className="intel-pacing-pct">{clamped.toFixed(0)}%</span>
</div>
);
}
function EmulatedBanner() {
return (
<div className="intel-emulated-banner">
<strong>Preview Mode</strong> Showing emulated data. Deploy the <code>spAdminReporting</code> stored procedure
and connect the metrics polling service to see live campaign data.
</div>
);
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function formatInsightType(type) {
if (!type) return '';
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
// ═════════════════════════════════════════════════════════════════════════════
// EMULATED DATA
// Used when the reporting API endpoints aren't connected yet.
// ═════════════════════════════════════════════════════════════════════════════
const EMULATED_SUMMARY = {
ok: true,
activeCampaigns: 4,
totalSpend: 12480,
totalImpressions: 847200,
totalClicks: 18430,
totalConversions: 312,
channelBreakdown: [
{ channel: 'google_ads', campaignCount: 3, spend: 6240, impressions: 412000, clicks: 9800, conversions: 186 },
{ channel: 'meta', campaignCount: 3, spend: 4120, impressions: 325000, clicks: 6230, conversions: 98 },
{ channel: 'tiktok', campaignCount: 2, spend: 2120, impressions: 110200, clicks: 2400, conversions: 28 },
],
};
const EMULATED_CAMPAIGNS = {
ok: true,
totalCount: 4,
campaigns: [
{
initiativeId: 1, name: 'Spring Product Launch', clientName: 'Acme Corp', objective: 'conversions',
status: 'active', totalBudget: 5000, budgetPeriod: 'monthly', spend: 3820, impressions: 312000,
clicks: 7200, ctr: 2.31, cpc: 0.53, conversions: 142, budgetPacing: 76.4,
channels: [
{ channelType: 'google_ads', status: 'active', allocationPct: 50, spend: 1910, impressions: 156000, clicks: 3900, conversions: 82 },
{ channelType: 'meta', status: 'active', allocationPct: 30, spend: 1146, impressions: 112000, clicks: 2300, conversions: 42 },
{ channelType: 'tiktok', status: 'active', allocationPct: 20, spend: 764, impressions: 44000, clicks: 1000, conversions: 18 },
],
},
{
initiativeId: 2, name: 'Brand Awareness Q1', clientName: 'TechStart Inc', objective: 'awareness',
status: 'active', totalBudget: 3000, budgetPeriod: 'monthly', spend: 2860, impressions: 285000,
clicks: 4800, ctr: 1.68, cpc: 0.60, conversions: 64, budgetPacing: 95.3,
channels: [
{ channelType: 'google_ads', status: 'active', allocationPct: 40, spend: 1144, impressions: 114000, clicks: 2100, conversions: 32 },
{ channelType: 'meta', status: 'active', allocationPct: 60, spend: 1716, impressions: 171000, clicks: 2700, conversions: 32 },
],
},
{
initiativeId: 3, name: 'Summer Sale Traffic', clientName: 'Acme Corp', objective: 'traffic',
status: 'active', totalBudget: 4000, budgetPeriod: 'monthly', spend: 3480, impressions: 168000,
clicks: 4230, ctr: 2.52, cpc: 0.82, conversions: 76, budgetPacing: 87.0,
channels: [
{ channelType: 'google_ads', status: 'active', allocationPct: 60, spend: 2088, impressions: 100800, clicks: 2800, conversions: 52 },
{ channelType: 'meta', status: 'active', allocationPct: 40, spend: 1392, impressions: 67200, clicks: 1430, conversions: 24 },
],
},
{
initiativeId: 4, name: 'TikTok Gen-Z Push', clientName: 'FreshBrand Co', objective: 'engagement',
status: 'active', totalBudget: 2500, budgetPeriod: 'monthly', spend: 2320, impressions: 82200,
clicks: 2200, ctr: 2.68, cpc: 1.05, conversions: 30, budgetPacing: 92.8,
channels: [
{ channelType: 'tiktok', status: 'active', allocationPct: 70, spend: 1624, impressions: 57540, clicks: 1600, conversions: 22 },
{ channelType: 'meta', status: 'active', allocationPct: 30, spend: 696, impressions: 24660, clicks: 600, conversions: 8 },
],
},
],
};
const EMULATED_INSIGHTS = {
ok: true,
totalActive: 4,
insights: [
{
insightId: 1, initiativeId: 2, campaignName: 'Brand Awareness Q1', clientName: 'TechStart Inc',
insightType: 'budget_pacing', severity: 'critical', status: 'active',
title: 'Budget nearly exhausted — 95% spent with 8 days remaining',
description: 'At the current spend rate of ~$102/day, the remaining $140 budget will be depleted in approximately 1.4 days. The campaign end date is 8 days away.',
recommendation: 'Consider increasing the monthly budget to $3,800 to maintain delivery through the end date, or pause the campaign and restart next period.',
createdAt: new Date().toISOString(),
},
{
insightId: 2, initiativeId: 4, campaignName: 'TikTok Gen-Z Push', clientName: 'FreshBrand Co',
insightType: 'underperformer', severity: 'warning', status: 'active',
title: 'Meta channel underperforming vs. TikTok allocation',
description: 'The Meta channel is consuming 30% of budget but delivering only 27% of conversions with a higher CPC ($1.16 vs $1.01). TikTok is outperforming on engagement metrics.',
recommendation: 'Consider shifting 10% of Meta allocation to TikTok to improve overall conversion efficiency for this audience segment.',
createdAt: new Date().toISOString(),
},
{
insightId: 3, initiativeId: 1, campaignName: 'Spring Product Launch', clientName: 'Acme Corp',
insightType: 'optimization', severity: 'info', status: 'active',
title: 'Google Ads CPC trending down — opportunity to increase volume',
description: 'Over the past 7 days, Google Ads CPC has decreased from $0.61 to $0.49 while maintaining conversion rate. This suggests room to increase daily spend cap.',
recommendation: 'Increase Google Ads daily budget by 15% to capture additional conversions while CPC remains favorable.',
createdAt: new Date().toISOString(),
},
{
insightId: 4, initiativeId: 3, campaignName: 'Summer Sale Traffic', clientName: 'Acme Corp',
insightType: 'reallocation', severity: 'info', status: 'active',
title: 'Channel allocation aligns well with performance data',
description: 'The current 60/40 Google/Meta split is delivering proportional results. Google is slightly outperforming on conversions per dollar, consistent with search-intent traffic campaigns.',
recommendation: 'No changes recommended. Current allocation is performing within expected parameters.',
createdAt: new Date().toISOString(),
},
],
};
const EMULATED_ANALYSIS = {
ok: true,
campaigns: [
{
initiativeId: 100, name: 'Holiday Campaign 2024', clientName: 'Acme Corp', objective: 'conversions',
totalBudget: 8000, startDate: '2024-11-15', endDate: '2024-12-31',
totalSpend: 7640, totalImpressions: 524000, totalClicks: 12800, totalConversions: 384,
ctr: 2.44, cpc: 0.60, costPerConversion: 19.90, budgetUtilization: 95.5,
channels: [
{ channel: 'google_ads', allocatedPct: 50, spend: 3820, impressions: 262000, clicks: 7200, conversions: 228 },
{ channel: 'meta', allocatedPct: 35, spend: 2674, impressions: 196000, clicks: 4200, conversions: 118 },
{ channel: 'tiktok', allocatedPct: 15, spend: 1146, impressions: 66000, clicks: 1400, conversions: 38 },
],
},
{
initiativeId: 101, name: 'Back to School Drive', clientName: 'TechStart Inc', objective: 'traffic',
totalBudget: 3500, startDate: '2024-08-01', endDate: '2024-09-15',
totalSpend: 3320, totalImpressions: 198000, totalClicks: 5600, totalConversions: 89,
ctr: 2.83, cpc: 0.59, costPerConversion: 37.30, budgetUtilization: 94.9,
channels: [
{ channel: 'google_ads', allocatedPct: 55, spend: 1826, impressions: 108900, clicks: 3200, conversions: 56 },
{ channel: 'meta', allocatedPct: 45, spend: 1494, impressions: 89100, clicks: 2400, conversions: 33 },
],
},
],
};

View File

@@ -0,0 +1,382 @@
import React, { useState, useEffect, useCallback, memo } from 'react';
import { useAdmin } from '../../context/AdminContext';
// ── Constants ──
const CHANNEL_META = {
google_ads: { label: 'Google Ads', color: '#4285F4' },
meta: { label: 'Meta Ads', color: '#0668E1' },
tiktok: { label: 'TikTok Ads', color: '#010101' },
};
const FACTOR_META = {
age_skew: {
label: 'Age Skew',
icon: '👤',
description: 'Adjusts channel mix based on audience age profile',
values: {
young: { label: 'Young (1834)', icon: '🎯' },
mature: { label: 'Mature (45+)', icon: '🏛️' },
},
},
market_scope: {
label: 'Market Scope',
icon: '📍',
description: 'Adjusts channel mix based on geographic targeting scope',
values: {
local: { label: 'Local', icon: '🏘️' },
regional: { label: 'Regional', icon: '🗺️' },
national: { label: 'National', icon: '🌐' },
},
},
};
const fmt = (s) => (s || '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
// ── Main Panel ──
export default function ModifiersPanel() {
const { apiCall } = useAdmin();
const [modifiers, setModifiers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [saving, setSaving] = useState(null);
const [dirty, setDirty] = useState({});
const [toast, setToast] = useState(null);
// Preview state
const [previewCategory, setPreviewCategory] = useState('restaurant');
const [previewObjective, setPreviewObjective] = useState('awareness');
const [previewAgeSkew, setPreviewAgeSkew] = useState('young');
const [previewMarketScope, setPreviewMarketScope] = useState('local');
const [previewResult, setPreviewResult] = useState(null);
const [previewLoading, setPreviewLoading] = useState(false);
// Toast auto-dismiss
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => setToast(null), 3000);
return () => clearTimeout(t);
}, [toast]);
// Load modifiers
const loadModifiers = useCallback(async () => {
setLoading(true);
setError(null);
const res = await apiCall('/api/admin/modifiers');
if (res.ok) {
setModifiers(res.modifiers || []);
} else {
setError(res.error || 'Failed to load modifiers');
}
setLoading(false);
}, [apiCall]);
useEffect(() => { loadModifiers(); }, [loadModifiers]);
// Track local edits
const updateLocal = (id, field, value) => {
setDirty(prev => ({
...prev,
[id]: { ...(prev[id] || {}), [field]: value }
}));
};
// Get effective value (dirty or original)
const getVal = (mod, field) => {
return dirty[mod.id]?.[field] !== undefined ? dirty[mod.id][field] : mod[field];
};
// Save a single modifier
const saveMod = async (mod) => {
const changes = dirty[mod.id];
if (!changes) return;
setSaving(mod.id);
const res = await apiCall(`/api/admin/modifiers/${mod.id}`, 'PUT', changes);
if (res.ok) {
setModifiers(prev => prev.map(m =>
m.id === mod.id ? { ...m, ...changes } : m
));
setDirty(prev => {
const next = { ...prev };
delete next[mod.id];
return next;
});
setToast('Modifier saved');
} else {
setError(res.error || 'Save failed');
}
setSaving(null);
};
// Save all dirty modifiers in a group
const saveGroup = async (mods) => {
for (const m of mods) {
if (dirty[m.id]) await saveMod(m);
}
};
// Preview recommendation
const runPreview = async () => {
setPreviewLoading(true);
const res = await apiCall('/api/admin/modifiers/preview', 'POST', {
businessCategory: previewCategory,
objective: previewObjective,
ageSkew: previewAgeSkew || null,
marketScope: previewMarketScope || null,
});
if (res.ok) {
setPreviewResult(res);
} else {
setPreviewResult({ warning: res.error || 'Preview failed' });
}
setPreviewLoading(false);
};
// Group modifiers by factorType → factorValue
const grouped = {};
modifiers.forEach(mod => {
const key = mod.factorType;
if (!grouped[key]) grouped[key] = {};
const subKey = mod.factorValue;
if (!grouped[key][subKey]) grouped[key][subKey] = [];
grouped[key][subKey].push(mod);
});
const hasDirtyCount = Object.keys(dirty).length;
if (loading) {
return (
<div style={{ padding: 40, textAlign: 'center', color: '#888' }}>
<div className="spinner"></div>
<p>Loading modifiers</p>
</div>
);
}
return (
<div className="modifiers-panel">
{/* Toast */}
{toast && <div className="mod-toast">{toast}</div>}
{error && (
<div className="error-message" style={{ margin: '0 0 16px 0' }}>
<strong>Error:</strong> {error}
<button onClick={() => setError(null)} className="mod-dismiss-btn"></button>
</div>
)}
<div className="mod-intro">
<p>
Audience modifiers adjust the base allocation template percentages based on the target audience's
age profile and geographic scope. Positive values increase a channel's share; negative values decrease it.
Results are normalized to 100% at recommendation time.
</p>
{hasDirtyCount > 0 && (
<span className="mod-dirty-badge">{hasDirtyCount} unsaved change{hasDirtyCount !== 1 ? 's' : ''}</span>
)}
</div>
{/* ── Factor Groups ── */}
{Object.entries(FACTOR_META).map(([factorType, meta]) => (
<div key={factorType} className="mod-group">
<div className="mod-group-header">
<span className="mod-group-icon">{meta.icon}</span>
<div>
<h3 className="mod-group-title">{meta.label}</h3>
<p className="mod-group-desc">{meta.description}</p>
</div>
</div>
{Object.entries(meta.values).map(([factorValue, valueMeta]) => {
const mods = grouped[factorType]?.[factorValue] || [];
const groupDirty = mods.some(m => dirty[m.id]);
return (
<div key={factorValue} className="mod-value-section">
<div className="mod-value-header">
<span className="mod-value-icon">{valueMeta.icon}</span>
<span className="mod-value-label">{valueMeta.label}</span>
{groupDirty && (
<button
className="mod-save-group-btn"
onClick={() => saveGroup(mods)}
disabled={saving !== null}
>
{saving ? 'Saving…' : 'Save Changes'}
</button>
)}
</div>
<div className="mod-slider-list">
{mods.map(mod => (
<ModifierRow
key={mod.id}
mod={mod}
pctAdj={getVal(mod, 'pctAdjustment')}
rationale={getVal(mod, 'rationale')}
isActive={getVal(mod, 'isActive')}
isDirty={!!dirty[mod.id]}
isSaving={saving === mod.id}
onChangePct={(v) => updateLocal(mod.id, 'pctAdjustment', v)}
onChangeRationale={(v) => updateLocal(mod.id, 'rationale', v)}
onToggleActive={() => updateLocal(mod.id, 'isActive', !getVal(mod, 'isActive'))}
onSave={() => saveMod(mod)}
/>
))}
{mods.length === 0 && (
<div className="mod-empty">No modifiers configured for {valueMeta.label}</div>
)}
</div>
</div>
);
})}
</div>
))}
{/* ── Preview Section ── */}
<div className="mod-group" style={{ marginTop: 24 }}>
<div className="mod-group-header">
<span className="mod-group-icon">🧪</span>
<div>
<h3 className="mod-group-title">Preview Recommendation</h3>
<p className="mod-group-desc">Test how modifiers affect the channel mix for a given scenario</p>
</div>
</div>
<div className="mod-preview-controls">
<div className="mod-preview-field">
<label>Category</label>
<input value={previewCategory} onChange={e => setPreviewCategory(e.target.value)} placeholder="restaurant" />
</div>
<div className="mod-preview-field">
<label>Objective</label>
<input value={previewObjective} onChange={e => setPreviewObjective(e.target.value)} placeholder="awareness" />
</div>
<div className="mod-preview-field">
<label>Age Skew</label>
<select value={previewAgeSkew} onChange={e => setPreviewAgeSkew(e.target.value)}>
<option value="">None</option>
<option value="young">Young</option>
<option value="mature">Mature</option>
</select>
</div>
<div className="mod-preview-field">
<label>Market Scope</label>
<select value={previewMarketScope} onChange={e => setPreviewMarketScope(e.target.value)}>
<option value="">None</option>
<option value="local">Local</option>
<option value="regional">Regional</option>
<option value="national">National</option>
</select>
</div>
<button className="mod-preview-btn" onClick={runPreview} disabled={previewLoading}>
{previewLoading ? 'Running…' : 'Run Preview'}
</button>
</div>
{previewResult && (
<div className="mod-preview-result">
{previewResult.warning && !previewResult.channels && (
<div style={{ color: '#b45309', fontSize: 13, padding: '8px 0' }}> {previewResult.warning}</div>
)}
{previewResult.channels && (
<div className="mod-preview-channels">
<div className="mod-preview-row mod-preview-header-row">
<span className="mod-preview-ch-dot-spacer" />
<span className="mod-preview-ch-name" style={{ fontWeight: 600, fontSize: 11, textTransform: 'uppercase', color: '#64748b' }}>Channel</span>
<span className="mod-preview-ch-val" style={{ fontWeight: 600, fontSize: 11, textTransform: 'uppercase', color: '#64748b' }}>Base</span>
<span className="mod-preview-ch-arrow" />
<span className="mod-preview-ch-val" style={{ fontWeight: 600, fontSize: 11, textTransform: 'uppercase', color: '#64748b' }}>Adjusted</span>
<span className="mod-preview-ch-diff-spacer" />
</div>
{previewResult.channels.map((ch, i) => {
const meta = CHANNEL_META[ch.channelType] || {};
const diff = ch.recommendedPct - ch.basePct;
return (
<div key={i} className="mod-preview-row">
<span className="mod-preview-ch-dot" style={{ background: meta.color || '#999' }} />
<span className="mod-preview-ch-name">{meta.label || fmt(ch.channelType)}</span>
<span className="mod-preview-ch-val">{ch.basePct}%</span>
<span className="mod-preview-ch-arrow"></span>
<span className="mod-preview-ch-val" style={{ fontWeight: 700 }}>{ch.recommendedPct}%</span>
{diff !== 0 ? (
<span className={`mod-preview-ch-diff ${diff > 0 ? 'pos' : 'neg'}`}>
{diff > 0 ? '+' : ''}{diff}
</span>
) : (
<span className="mod-preview-ch-diff-spacer" />
)}
</div>
);
})}
</div>
)}
</div>
)}
</div>
</div>
);
}
// ── Modifier Row with Slider ──
const ModifierRow = memo(function ModifierRow({
mod, pctAdj, rationale, isActive, isDirty, isSaving,
onChangePct, onChangeRationale, onToggleActive, onSave,
}) {
const meta = CHANNEL_META[mod.channelType] || {};
const isPositive = pctAdj > 0;
const isNegative = pctAdj < 0;
return (
<div className={`mod-row ${!isActive ? 'mod-row-disabled' : ''} ${isDirty ? 'mod-row-dirty' : ''}`}>
<div className="mod-row-top">
<span className="mod-ch-dot" style={{ background: meta.color || '#999' }} />
<span className="mod-ch-name">{meta.label || fmt(mod.channelType)}</span>
<div className="mod-slider-wrap">
<span className="mod-slider-tick mod-slider-tick-neg">-30</span>
<input
type="range"
min={-30}
max={30}
value={pctAdj}
onChange={e => onChangePct(parseInt(e.target.value))}
className="mod-slider"
style={{
'--slider-color': isPositive ? '#10b981' : isNegative ? '#ef4444' : '#94a3b8',
}}
/>
<span className="mod-slider-tick mod-slider-tick-pos">+30</span>
</div>
<span className={`mod-pct-badge ${isPositive ? 'pos' : isNegative ? 'neg' : ''}`}>
{pctAdj > 0 ? '+' : ''}{pctAdj}%
</span>
<label className="mod-toggle" title={isActive ? 'Active' : 'Disabled'}>
<input type="checkbox" checked={isActive} onChange={onToggleActive} />
<span className="mod-toggle-track" />
</label>
{isDirty && (
<button className="mod-save-btn" onClick={onSave} disabled={isSaving} title="Save">
{isSaving ? '…' : '✓'}
</button>
)}
</div>
<div className="mod-row-bottom">
<input
className="mod-rationale"
value={rationale || ''}
onChange={e => onChangeRationale(e.target.value)}
placeholder="Rationale…"
maxLength={200}
/>
</div>
</div>
);
});

View File

@@ -0,0 +1,931 @@
// ============================================================
// components/admin/ObjectiveMappingPanel.jsx
// Redesigned: "Objective Translations" — Rosetta Stone layout
//
// Primary: Interactive coverage matrix (objectives × channels)
// Secondary: Objective cards with channel translation blocks
// ============================================================
import React, { useState, useMemo, useCallback, memo } from 'react';
import { useObjectiveMappings, PROVIDER_OBJECTIVES } from '../../context/ObjectiveMappingsContext';
// ── Styles ──
const styles = {
panel: {
padding: '24px',
maxWidth: '1200px',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: '24px',
flexWrap: 'wrap',
gap: '12px',
},
headerLeft: {
display: 'flex',
flexDirection: 'column',
gap: '4px',
},
title: {
fontSize: '20px',
fontWeight: 600,
color: '#111827',
margin: 0,
},
subtitle: {
fontSize: '13px',
color: '#6B7280',
margin: 0,
},
statsRow: {
display: 'flex',
gap: '16px',
alignItems: 'center',
},
statBadge: (color) => ({
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '4px 10px',
borderRadius: '6px',
fontSize: '12px',
fontWeight: 500,
background: color + '14',
color: color,
}),
// ── Coverage matrix ──
matrixSection: {
marginBottom: '32px',
},
sectionTitle: {
fontSize: '14px',
fontWeight: 600,
color: '#374151',
marginBottom: '12px',
display: 'flex',
alignItems: 'center',
gap: '8px',
},
matrixTable: {
width: '100%',
borderCollapse: 'separate',
borderSpacing: '0',
border: '1px solid #E5E7EB',
borderRadius: '8px',
overflow: 'hidden',
},
matrixTh: {
padding: '10px 16px',
fontSize: '12px',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: '#6B7280',
background: '#F9FAFB',
textAlign: 'center',
borderBottom: '1px solid #E5E7EB',
},
matrixThLeft: {
padding: '10px 16px',
fontSize: '12px',
fontWeight: 600,
color: '#6B7280',
background: '#F9FAFB',
textAlign: 'left',
borderBottom: '1px solid #E5E7EB',
borderRight: '1px solid #E5E7EB',
},
matrixObjCell: (color) => ({
padding: '10px 16px',
borderBottom: '1px solid #E5E7EB',
borderRight: '1px solid #E5E7EB',
background: '#fff',
display: 'flex',
alignItems: 'center',
gap: '8px',
}),
objDot: (color) => ({
width: '8px',
height: '8px',
borderRadius: '50%',
background: color,
flexShrink: 0,
}),
matrixCell: (filled) => ({
padding: '8px',
borderBottom: '1px solid #E5E7EB',
borderRight: '1px solid #E5E7EB',
textAlign: 'center',
cursor: 'pointer',
transition: 'background 0.15s',
background: filled ? '#F0FDF4' : '#fff',
verticalAlign: 'middle',
}),
matrixCellFilled: {
fontSize: '11px',
fontWeight: 500,
color: '#065F46',
lineHeight: '1.3',
},
matrixCellEmpty: {
color: '#D1D5DB',
fontSize: '18px',
},
// ── Objective cards ──
cardsSection: {
display: 'flex',
flexDirection: 'column',
gap: '16px',
},
card: {
border: '1px solid #E5E7EB',
borderRadius: '10px',
overflow: 'hidden',
background: '#fff',
},
cardHeader: (color) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '14px 20px',
borderBottom: '1px solid #E5E7EB',
background: color + '08',
borderLeft: `4px solid ${color}`,
}),
cardTitle: {
display: 'flex',
alignItems: 'center',
gap: '10px',
},
cardTitleText: {
fontSize: '15px',
fontWeight: 600,
color: '#111827',
textTransform: 'capitalize',
},
cardCount: (color) => ({
fontSize: '11px',
padding: '2px 8px',
borderRadius: '10px',
background: color + '1A',
color: color,
fontWeight: 500,
}),
cardBody: {
display: 'flex',
gap: '0',
flexWrap: 'wrap',
},
// ── Translation block ──
translationBlock: (channelColor) => ({
flex: '1 1 280px',
padding: '16px 20px',
borderRight: '1px solid #F3F4F6',
borderBottom: '1px solid #F3F4F6',
position: 'relative',
}),
translationHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '8px',
},
channelLabel: (color) => ({
fontSize: '12px',
fontWeight: 600,
color: color,
display: 'flex',
alignItems: 'center',
gap: '6px',
}),
providerValue: {
fontSize: '13px',
fontWeight: 500,
color: '#111827',
marginBottom: '4px',
},
providerLabel: {
fontSize: '12px',
color: '#6B7280',
marginBottom: '10px',
},
capsRow: {
display: 'flex',
gap: '6px',
flexWrap: 'wrap',
marginBottom: '6px',
},
capBadge: (supported) => ({
fontSize: '10px',
fontWeight: 500,
padding: '2px 7px',
borderRadius: '4px',
background: supported ? '#ECFDF5' : '#F9FAFB',
color: supported ? '#065F46' : '#9CA3AF',
border: `1px solid ${supported ? '#D1FAE5' : '#E5E7EB'}`,
}),
notes: {
fontSize: '11px',
color: '#9CA3AF',
fontStyle: 'italic',
marginTop: '4px',
},
blockActions: {
display: 'flex',
gap: '6px',
},
iconBtn: {
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '2px 4px',
fontSize: '13px',
color: '#9CA3AF',
borderRadius: '4px',
transition: 'color 0.15s, background 0.15s',
},
addTranslationBlock: {
flex: '1 1 280px',
padding: '16px 20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRight: '1px solid #F3F4F6',
borderBottom: '1px solid #F3F4F6',
minHeight: '80px',
},
addBtn: {
background: 'none',
border: '2px dashed #D1D5DB',
borderRadius: '8px',
padding: '8px 16px',
color: '#9CA3AF',
fontSize: '12px',
fontWeight: 500,
cursor: 'pointer',
transition: 'all 0.15s',
width: '100%',
},
// ── Form / modal ──
overlay: {
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
},
formCard: {
background: '#fff',
borderRadius: '12px',
width: '520px',
maxHeight: '85vh',
overflowY: 'auto',
boxShadow: '0 20px 60px rgba(0,0,0,0.15)',
},
formHeader: {
padding: '20px 24px',
borderBottom: '1px solid #E5E7EB',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
},
formTitle: {
fontSize: '16px',
fontWeight: 600,
color: '#111827',
margin: 0,
},
formBody: {
padding: '20px 24px',
display: 'flex',
flexDirection: 'column',
gap: '16px',
},
formFooter: {
padding: '16px 24px',
borderTop: '1px solid #E5E7EB',
display: 'flex',
justifyContent: 'flex-end',
gap: '10px',
},
fieldGroup: {
display: 'flex',
flexDirection: 'column',
gap: '4px',
},
fieldLabel: {
fontSize: '12px',
fontWeight: 600,
color: '#374151',
},
fieldInput: {
padding: '8px 12px',
border: '1px solid #D1D5DB',
borderRadius: '6px',
fontSize: '13px',
outline: 'none',
transition: 'border-color 0.15s',
},
fieldSelect: {
padding: '8px 12px',
border: '1px solid #D1D5DB',
borderRadius: '6px',
fontSize: '13px',
outline: 'none',
background: '#fff',
},
checkRow: {
display: 'flex',
alignItems: 'center',
gap: '8px',
},
checkLabel: {
fontSize: '13px',
color: '#374151',
},
btnPrimary: {
padding: '8px 18px',
background: '#2563EB',
color: '#fff',
border: 'none',
borderRadius: '6px',
fontSize: '13px',
fontWeight: 500,
cursor: 'pointer',
},
btnSecondary: {
padding: '8px 18px',
background: '#fff',
color: '#374151',
border: '1px solid #D1D5DB',
borderRadius: '6px',
fontSize: '13px',
fontWeight: 500,
cursor: 'pointer',
},
btnDanger: {
padding: '8px 18px',
background: '#FEE2E2',
color: '#DC2626',
border: '1px solid #FECACA',
borderRadius: '6px',
fontSize: '13px',
fontWeight: 500,
cursor: 'pointer',
},
loading: {
padding: '60px',
textAlign: 'center',
color: '#9CA3AF',
fontSize: '14px',
},
error: {
padding: '16px',
background: '#FEF2F2',
border: '1px solid #FECACA',
borderRadius: '8px',
color: '#DC2626',
fontSize: '13px',
},
emptyState: {
padding: '40px',
textAlign: 'center',
color: '#9CA3AF',
fontSize: '14px',
border: '2px dashed #E5E7EB',
borderRadius: '8px',
},
};
// ── Capability labels ──
const CAPABILITY_FLAGS = [
{ key: 'supportsObjectiveChange', short: 'Objective', label: 'Change Objective' },
{ key: 'supportsBudgetChange', short: 'Budget', label: 'Change Budget' },
{ key: 'supportsTargetingChange', short: 'Targeting', label: 'Change Targeting' },
{ key: 'supportsStatusToggle', short: 'Status', label: 'Toggle Status' },
];
// ════════════════════════════════════════════════════════════
// Main Panel
// ════════════════════════════════════════════════════════════
export default function ObjectiveMappingPanel() {
const {
channels, objectives, mappings, channelMap, objectiveColorMap,
coverageMatrix, coverageStats, loading, metaError,
createMapping, updateMapping, deleteMapping, getProviderObjectives,
} = useObjectiveMappings();
const [formState, setFormState] = useState(null); // null | { mode: 'create'|'edit', presets?, mapping? }
const activeChannels = useMemo(() => channels.filter(c => c.isActive), [channels]);
const activeObjectives = useMemo(
() => objectives.filter(o => o.isActive).sort((a, b) => a.sortOrder - b.sortOrder),
[objectives],
);
// ── Open form helpers ──
const openCreate = useCallback((presets = {}) => {
setFormState({ mode: 'create', presets });
}, []);
const openEdit = useCallback((mapping) => {
setFormState({ mode: 'edit', mapping });
}, []);
const closeForm = useCallback(() => setFormState(null), []);
// ── Loading / error ──
if (loading) {
return <div style={styles.loading}>Loading objective translations...</div>;
}
if (metaError) {
return <div style={styles.error}>Error loading metadata: {metaError}</div>;
}
return (
<div style={styles.panel}>
{/* ── Header ── */}
<div style={styles.header}>
<div style={styles.headerLeft}>
<h2 style={styles.title}>Objective Translations</h2>
<p style={styles.subtitle}>
Maps platform objectives to provider-specific campaign types across channels
</p>
</div>
<div style={styles.statsRow}>
<span style={styles.statBadge('#10B981')}>
{coverageStats.filled} mapped
</span>
{coverageStats.gaps > 0 && (
<span style={styles.statBadge('#EF4444')}>
{coverageStats.gaps} gap{coverageStats.gaps !== 1 ? 's' : ''}
</span>
)}
<span style={styles.statBadge('#6B7280')}>
{coverageStats.pct}% coverage
</span>
</div>
</div>
{/* ── Coverage Matrix ── */}
<div style={styles.matrixSection}>
<div style={styles.sectionTitle}>
<span>Coverage Matrix</span>
<span style={{ fontSize: '11px', color: '#9CA3AF', fontWeight: 400 }}>
Click any cell to {'\u00B7'} add or edit
</span>
</div>
<CoverageMatrix
objectives={activeObjectives}
channels={activeChannels}
matrix={coverageMatrix}
colorMap={objectiveColorMap}
onClickCell={(objective, channel, mapping) => {
if (mapping) {
openEdit(mapping);
} else {
openCreate({ channelType: channel.code, platformObjective: objective.name });
}
}}
/>
</div>
{/* ── Objective Cards ── */}
<div style={styles.sectionTitle}>
<span>Translations by Objective</span>
</div>
<div style={styles.cardsSection}>
{activeObjectives.map(obj => (
<ObjectiveCard
key={obj.name}
objective={obj}
channels={activeChannels}
channelMap={channelMap}
matrix={coverageMatrix}
colorMap={objectiveColorMap}
onEdit={openEdit}
onAdd={(channelCode) => openCreate({
platformObjective: obj.name,
channelType: channelCode,
})}
onDelete={deleteMapping}
/>
))}
{activeObjectives.length === 0 && (
<div style={styles.emptyState}>
No active objectives defined. Add objectives in the Allocation Templates panel.
</div>
)}
</div>
{/* ── Form overlay ── */}
{formState && (
<MappingFormOverlay
mode={formState.mode}
presets={formState.presets}
mapping={formState.mapping}
channels={activeChannels}
objectives={activeObjectives}
getProviderObjectives={getProviderObjectives}
onCreate={createMapping}
onUpdate={updateMapping}
onClose={closeForm}
/>
)}
</div>
);
}
// ════════════════════════════════════════════════════════════
// Coverage Matrix (table)
// ════════════════════════════════════════════════════════════
const CoverageMatrix = memo(function CoverageMatrix({ objectives, channels, matrix, colorMap, onClickCell }) {
if (!objectives.length || !channels.length) {
return <div style={styles.emptyState}>Add channels and objectives to see the coverage matrix</div>;
}
return (
<table style={styles.matrixTable}>
<thead>
<tr>
<th style={styles.matrixThLeft}>Objective</th>
{channels.map(ch => (
<th key={ch.code} style={styles.matrixTh}>
<span style={{ color: ch.color || '#6B7280' }}>
{ch.icon || ''} {ch.label}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{objectives.map(obj => {
const color = colorMap[obj.name] || '#6B7280';
return (
<tr key={obj.name}>
<td style={{ borderBottom: '1px solid #E5E7EB', borderRight: '1px solid #E5E7EB', padding: '10px 16px', background: '#fff' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={styles.objDot(color)} />
<span style={{ fontSize: '13px', fontWeight: 500, color: '#111827', textTransform: 'capitalize' }}>
{obj.name}
</span>
</div>
</td>
{channels.map(ch => {
const mapping = matrix[obj.name]?.[ch.code];
return (
<td
key={ch.code}
style={styles.matrixCell(!!mapping)}
onClick={() => onClickCell(obj, ch, mapping)}
onMouseEnter={e => { e.currentTarget.style.background = mapping ? '#DCFCE7' : '#F3F4F6'; }}
onMouseLeave={e => { e.currentTarget.style.background = mapping ? '#F0FDF4' : '#fff'; }}
title={mapping
? `${mapping.providerObjectiveLabel}\nClick to edit`
: `No mapping — click to add`
}
>
{mapping ? (
<div style={styles.matrixCellFilled}>
{mapping.providerObjectiveLabel || mapping.providerObjective}
</div>
) : (
<div style={styles.matrixCellEmpty}>+</div>
)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
);
});
// ════════════════════════════════════════════════════════════
// Objective Card
// ════════════════════════════════════════════════════════════
const ObjectiveCard = memo(function ObjectiveCard({
objective, channels, channelMap, matrix, colorMap, onEdit, onAdd, onDelete,
}) {
const color = colorMap[objective.name] || '#6B7280';
const row = matrix[objective.name] || {};
const filledChannels = channels.filter(ch => row[ch.code]);
const missingChannels = channels.filter(ch => !row[ch.code]);
const [confirmDelete, setConfirmDelete] = useState(null);
const handleDelete = async (mappingId) => {
const result = await onDelete(mappingId);
if (result?.ok) setConfirmDelete(null);
};
return (
<div style={styles.card}>
<div style={styles.cardHeader(color)}>
<div style={styles.cardTitle}>
<span style={styles.objDot(color)} />
<span style={styles.cardTitleText}>{objective.name}</span>
<span style={styles.cardCount(color)}>
{filledChannels.length}/{channels.length} channels
</span>
</div>
</div>
<div style={styles.cardBody}>
{/* Filled translations */}
{filledChannels.map(ch => {
const mapping = row[ch.code];
const chMeta = channelMap[ch.code] || {};
const chColor = chMeta.color || '#6B7280';
return (
<div key={ch.code} style={styles.translationBlock(chColor)}>
<div style={styles.translationHeader}>
<div style={styles.channelLabel(chColor)}>
{chMeta.icon || ''} {chMeta.label || ch.code}
</div>
<div style={styles.blockActions}>
<button
style={styles.iconBtn}
onClick={() => onEdit(mapping)}
title="Edit"
onMouseEnter={e => { e.target.style.color = '#2563EB'; }}
onMouseLeave={e => { e.target.style.color = '#9CA3AF'; }}
>
</button>
{confirmDelete === mapping.mappingId ? (
<>
<button
style={{ ...styles.iconBtn, color: '#DC2626', fontSize: '11px' }}
onClick={() => handleDelete(mapping.mappingId)}
>
Confirm
</button>
<button
style={{ ...styles.iconBtn, fontSize: '11px' }}
onClick={() => setConfirmDelete(null)}
>
Cancel
</button>
</>
) : (
<button
style={styles.iconBtn}
onClick={() => setConfirmDelete(mapping.mappingId)}
title="Delete"
onMouseEnter={e => { e.target.style.color = '#DC2626'; }}
onMouseLeave={e => { e.target.style.color = '#9CA3AF'; }}
>
🗑
</button>
)}
</div>
</div>
<div style={styles.providerValue}>
{mapping.providerObjective}
</div>
<div style={styles.providerLabel}>
{mapping.providerObjectiveLabel}
</div>
<div style={styles.capsRow}>
{CAPABILITY_FLAGS.map(cap => (
<span key={cap.key} style={styles.capBadge(mapping[cap.key])}>
{mapping[cap.key] ? '✓' : '✗'} {cap.short}
</span>
))}
</div>
{mapping.notes && (
<div style={styles.notes}>{mapping.notes}</div>
)}
</div>
);
})}
{/* Missing channel slots */}
{missingChannels.map(ch => {
const chMeta = channelMap[ch.code] || {};
return (
<div key={ch.code} style={styles.addTranslationBlock}>
<button
style={styles.addBtn}
onClick={() => onAdd(ch.code)}
onMouseEnter={e => {
e.target.style.borderColor = '#9CA3AF';
e.target.style.color = '#374151';
}}
onMouseLeave={e => {
e.target.style.borderColor = '#D1D5DB';
e.target.style.color = '#9CA3AF';
}}
>
+ Add {chMeta.label || ch.code} translation
</button>
</div>
);
})}
</div>
</div>
);
});
// ════════════════════════════════════════════════════════════
// Mapping Form Overlay (create / edit)
// ════════════════════════════════════════════════════════════
function MappingFormOverlay({
mode, presets, mapping, channels, objectives,
getProviderObjectives, onCreate, onUpdate, onClose,
}) {
const isEdit = mode === 'edit';
const initial = isEdit ? mapping : (presets || {});
const [form, setForm] = useState({
channelType: initial.channelType || '',
platformObjective: initial.platformObjective || '',
providerObjective: initial.providerObjective || '',
providerObjectiveLabel: initial.providerObjectiveLabel || '',
supportsObjectiveChange: initial.supportsObjectiveChange ?? false,
supportsBudgetChange: initial.supportsBudgetChange ?? true,
supportsTargetingChange: initial.supportsTargetingChange ?? true,
supportsStatusToggle: initial.supportsStatusToggle ?? true,
notes: initial.notes || '',
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const providerOpts = useMemo(
() => getProviderObjectives(form.channelType),
[form.channelType, getProviderObjectives],
);
const handleChange = (field, value) => {
setForm(prev => ({ ...prev, [field]: value }));
// Auto-fill label when picking from provider objective dropdown
if (field === 'providerObjective') {
const opt = providerOpts.find(o => o.value === value);
if (opt) {
setForm(prev => ({ ...prev, providerObjective: value, providerObjectiveLabel: opt.label }));
}
}
};
const handleSubmit = async () => {
setError(null);
if (!form.channelType || !form.platformObjective || !form.providerObjective || !form.providerObjectiveLabel) {
setError('All required fields must be filled');
return;
}
setSaving(true);
try {
let result;
if (isEdit) {
result = await onUpdate(mapping.mappingId, form);
} else {
result = await onCreate(form);
}
if (result?.ok) {
onClose();
} else {
setError(result?.error || 'Operation failed');
}
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
return (
<div style={styles.overlay} onClick={onClose}>
<div style={styles.formCard} onClick={e => e.stopPropagation()}>
{/* Header */}
<div style={styles.formHeader}>
<h3 style={styles.formTitle}>
{isEdit ? 'Edit Translation' : 'Add Translation'}
</h3>
<button style={styles.iconBtn} onClick={onClose}></button>
</div>
{/* Body */}
<div style={styles.formBody}>
{error && <div style={styles.error}>{error}</div>}
{/* Channel */}
<div style={styles.fieldGroup}>
<label style={styles.fieldLabel}>Channel *</label>
<select
style={styles.fieldSelect}
value={form.channelType}
onChange={e => handleChange('channelType', e.target.value)}
disabled={isEdit}
>
<option value="">Select channel...</option>
{channels.map(ch => (
<option key={ch.code} value={ch.code}>{ch.label}</option>
))}
</select>
</div>
{/* Platform Objective */}
<div style={styles.fieldGroup}>
<label style={styles.fieldLabel}>Platform Objective *</label>
<select
style={styles.fieldSelect}
value={form.platformObjective}
onChange={e => handleChange('platformObjective', e.target.value)}
disabled={isEdit}
>
<option value="">Select objective...</option>
{objectives.map(obj => (
<option key={obj.name} value={obj.name}>{obj.name}</option>
))}
</select>
</div>
{/* Provider Objective */}
<div style={styles.fieldGroup}>
<label style={styles.fieldLabel}>Provider Objective *</label>
{providerOpts.length > 0 ? (
<select
style={styles.fieldSelect}
value={form.providerObjective}
onChange={e => handleChange('providerObjective', e.target.value)}
>
<option value="">Select provider objective...</option>
{providerOpts.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label} ({opt.value})</option>
))}
</select>
) : (
<input
style={styles.fieldInput}
value={form.providerObjective}
onChange={e => handleChange('providerObjective', e.target.value)}
placeholder="e.g. MAXIMIZE_CONVERSIONS"
/>
)}
</div>
{/* Provider Objective Label */}
<div style={styles.fieldGroup}>
<label style={styles.fieldLabel}>Provider Objective Label *</label>
<input
style={styles.fieldInput}
value={form.providerObjectiveLabel}
onChange={e => handleChange('providerObjectiveLabel', e.target.value)}
placeholder="Human-readable label"
/>
</div>
{/* Capability Flags */}
<div style={styles.fieldGroup}>
<label style={styles.fieldLabel}>Capability Flags</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '4px' }}>
{CAPABILITY_FLAGS.map(cap => (
<label key={cap.key} style={styles.checkRow}>
<input
type="checkbox"
checked={form[cap.key] || false}
onChange={e => handleChange(cap.key, e.target.checked)}
/>
<span style={styles.checkLabel}>{cap.label}</span>
</label>
))}
</div>
</div>
{/* Notes */}
<div style={styles.fieldGroup}>
<label style={styles.fieldLabel}>Notes</label>
<input
style={styles.fieldInput}
value={form.notes}
onChange={e => handleChange('notes', e.target.value)}
placeholder="Optional notes..."
/>
</div>
</div>
{/* Footer */}
<div style={styles.formFooter}>
<button style={styles.btnSecondary} onClick={onClose} disabled={saving}>
Cancel
</button>
<button style={styles.btnPrimary} onClick={handleSubmit} disabled={saving}>
{saving ? 'Saving...' : isEdit ? 'Update' : 'Create'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,14 +1,32 @@
import React from 'react';
import { API_BASE_URL } from '../../auth/authConfig';
import { useAdmin } from '../../context/AdminContext';
export default function SessionsPanel({ data, sessionToken, onRefresh }) {
export default function SessionsPanel() {
const { data, apiCall, refresh } = useAdmin();
const sessions = data.sessions || [];
const revokeSession = async (sessionId) => {
if (!confirm('Are you sure you want to revoke this session?')) return;
const result = await apiCall(`/api/admin/sessions/${sessionId}/revoke`, 'POST');
if (result.ok) refresh();
else alert(result.error || 'Failed to revoke session');
};
const cleanupSessions = async () => {
if (!confirm('This will delete all expired sessions older than 30 days. Continue?')) return;
const result = await apiCall('/api/admin/sessions/cleanup', 'POST', { daysOld: 30 });
if (result.ok) {
alert(`Cleaned up ${result.rowsDeleted} expired sessions`);
refresh();
}
else alert(result.error || 'Failed to cleanup sessions');
};
return (
<div className="table-panel">
<div className="panel-header">
<h2>Active Sessions</h2>
<button className="btn-action" onClick={() => cleanupSessions(sessionToken, onRefresh)}>
<button className="btn-action" onClick={cleanupSessions}>
Cleanup Expired
</button>
</div>
@@ -38,7 +56,7 @@ export default function SessionsPanel({ data, sessionToken, onRefresh }) {
<td>
<button
className="btn-small btn-danger"
onClick={() => revokeSession(session.sessionId, sessionToken, onRefresh)}
onClick={() => revokeSession(session.sessionId)}
>
Revoke
</button>
@@ -53,42 +71,3 @@ export default function SessionsPanel({ data, sessionToken, onRefresh }) {
</div>
);
}
async function revokeSession(sessionId, sessionToken, onRefresh) {
if (!confirm('Are you sure you want to revoke this session?')) return;
try {
const response = await fetch(`${API_BASE_URL}/api/admin/sessions/${sessionId}/revoke`, {
method: 'POST',
headers: { 'X-Session-Token': sessionToken },
});
const data = await response.json();
if (data.ok) {
onRefresh();
} else {
alert(data.error || 'Failed to revoke session');
}
} catch (err) {
alert('Network error: ' + err.message);
}
}
async function cleanupSessions(sessionToken, onRefresh) {
if (!confirm('This will delete all expired sessions older than 30 days. Continue?')) return;
try {
const response = await fetch(`${API_BASE_URL}/api/admin/sessions/cleanup?daysOld=30`, {
method: 'POST',
headers: { 'X-Session-Token': sessionToken },
});
const data = await response.json();
if (data.ok) {
alert(`Cleaned up ${data.rowsDeleted} expired sessions`);
onRefresh();
} else {
alert(data.error || 'Failed to cleanup sessions');
}
} catch (err) {
alert('Network error: ' + err.message);
}
}

View File

@@ -0,0 +1,658 @@
import React, { useState, useRef, useEffect, memo } from 'react';
import Picker from '@emoji-mart/react';
import emojiData from '@emoji-mart/data';
import { useAdmin } from '../../context/AdminContext';
import { useTemplates, CHANNELS, CHANNEL_META } from '../../context/TemplatesContext';
import { MANAGEMENT_URL } from '../../auth/authConfig';
// ─── Helpers ────────────────────────────────────────────────
const fmt = (s) => (s || '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const truncate = (str, max) => !str ? '—' : str.length > max ? str.substring(0, max) + '…' : str;
// ─── Donut Chart (SVG) — memoized ────────────────────────────
const DonutChart = memo(function DonutChart({ allocations, size = 110, thickness = 20 }) {
const center = size / 2;
const radius = (size - thickness) / 2;
const total = allocations.reduce((s, a) => s + (a.recommendedPct || 0), 0);
if (total === 0) {
return (
<svg width={size} height={size}>
<circle cx={center} cy={center} r={radius} fill="none"
stroke="var(--border)" strokeWidth={thickness} />
</svg>
);
}
let cumulative = 0;
const segments = allocations.map((a) => {
const startAngle = (cumulative / total) * 2 * Math.PI - Math.PI / 2;
cumulative += (a.recommendedPct || 0);
const endAngle = (cumulative / total) * 2 * Math.PI - Math.PI / 2;
const largeArc = (a.recommendedPct / total) * 360 > 180 ? 1 : 0;
return {
...a,
d: `M ${center + radius * Math.cos(startAngle)} ${center + radius * Math.sin(startAngle)} A ${radius} ${radius} 0 ${largeArc} 1 ${center + radius * Math.cos(endAngle)} ${center + radius * Math.sin(endAngle)}`
};
});
const isBalanced = Math.abs(total - 100) < 0.01;
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
{segments.map((seg, i) => (
<path key={i} d={seg.d} fill="none"
stroke={CHANNEL_META[seg.channelType]?.color || '#999'}
strokeWidth={thickness} strokeLinecap="butt" />
))}
<text x={center} y={center - 4} textAnchor="middle"
style={{ fontSize: 15, fontWeight: 700, fill: isBalanced ? 'var(--success)' : 'var(--danger)' }}>
{total}%
</text>
<text x={center} y={center + 12} textAnchor="middle"
style={{ fontSize: 10, fill: 'var(--text-dim)' }}>
total
</text>
</svg>
);
});
// ─── Icon Picker Popover (emoji-mart) — memoized ─────────────
const IconPicker = memo(function IconPicker({ value, onChange }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
const btnRef = useRef(null);
const [above, setAbove] = useState(false);
useEffect(() => {
if (!open) return;
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
useEffect(() => {
if (open && btnRef.current) {
const rect = btnRef.current.getBoundingClientRect();
setAbove(rect.bottom + 360 > window.innerHeight);
}
}, [open]);
return (
<span ref={ref} style={{ position: 'relative' }}>
<button ref={btnRef} onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
className="icon-picker-btn">{value}</button>
{open && (
<div className="icon-picker-popover" style={{ [above ? 'bottom' : 'top']: 34 }}>
<Picker
data={emojiData}
onEmojiSelect={(emoji) => { onChange(emoji.native); setOpen(false); }}
theme="light"
previewPosition="none"
skinTonePosition="search"
maxFrequentRows={1}
navPosition="bottom"
perLine={7}
emojiSize={22}
emojiButtonSize={30}
/>
</div>
)}
</span>
);
});
// ─── Color Picker — memoized ────────────────────────────────
const ColorPicker = memo(function ColorPicker({ value, onChange }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
const presets = ['#7dca7d','#4fc3f7','#ffb74d','#ce93d8','#ef5350','#90a4ae','#4db6ac','#ff8a65','#aed581','#7986cb'];
useEffect(() => {
if (!open) return;
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
return (
<span ref={ref} style={{ position: 'relative' }}>
<button onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
className="color-picker-btn"
style={{ backgroundColor: value || '#999' }} />
{open && (
<div className="color-picker-grid">
{presets.map(c => (
<button key={c}
className={`color-picker-option ${c === value ? 'active' : ''}`}
style={{ backgroundColor: c }}
onClick={(e) => { e.stopPropagation(); onChange(c); setOpen(false); }} />
))}
</div>
)}
</span>
);
});
// ─── Channel Badge — memoized ───────────────────────────────
const ChannelBadge = memo(function ChannelBadge({ channel }) {
const meta = CHANNEL_META[channel] || {};
return (
<span className="channel-badge" style={{ borderColor: meta.color || '#666' }}>
{meta.label || channel}
</span>
);
});
// ─── Template Create/Edit Form — uses both contexts ─────────
const TemplateForm = memo(function TemplateForm({ editData, defaultCategory, defaultObjective, availableChannels, onSuccess, onCancel }) {
const { apiCall } = useAdmin();
const { categories, objectives } = useTemplates();
const channelOptions = availableChannels || CHANNELS;
const lockCatObj = !!availableChannels;
const [channelType, setChannelType] = useState(editData?.channelType || (channelOptions.length === 1 ? channelOptions[0] : ''));
const [businessCategory, setBusinessCategory] = useState(editData?.businessCategory || defaultCategory || '');
const [objective, setObjective] = useState(editData?.objective || defaultObjective || '');
const [recommendedPct, setRecommendedPct] = useState(editData?.recommendedPct ?? '');
const [minBudgetRequired, setMinBudgetRequired] = useState(editData?.minBudgetRequired ?? 0);
const [rationale, setRationale] = useState(editData?.rationale || '');
const [isActive, setIsActive] = useState(editData?.isActive ?? true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const isEdit = !!editData;
const formTitle = lockCatObj
? `Add Channel to ${fmt(defaultCategory)} \u00B7 ${fmt(defaultObjective)}`
: (isEdit ? 'Edit Template' : 'Create Template');
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError(null);
const url = isEdit
? `/api/admin/templates/${editData.templateId}`
: `/api/admin/templates`;
const result = await apiCall(url, isEdit ? 'PUT' : 'POST', {
channelType, businessCategory, objective,
recommendedPct: parseFloat(recommendedPct),
minBudgetRequired: parseFloat(minBudgetRequired) || 0,
rationale: rationale || null,
...(isEdit ? { isActive } : {})
});
if (result.ok) onSuccess();
else setError(result.error || 'Operation failed');
setLoading(false);
};
return (
<div className="admin-form">
<h3>{formTitle}</h3>
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-row-inline">
<div className="form-group">
<label>Channel</label>
<select value={channelType} onChange={e => setChannelType(e.target.value)} required disabled={loading}>
<option value="">Select</option>
{channelOptions.map(c => <option key={c} value={c}>{CHANNEL_META[c]?.label || c}</option>)}
</select>
</div>
{!lockCatObj && (
<div className="form-group">
<label>Category</label>
<select value={businessCategory} onChange={e => setBusinessCategory(e.target.value)} required disabled={loading}>
<option value="">Select</option>
{categories.map(c => <option key={c.name} value={c.name}>{c.icon} {fmt(c.name)}</option>)}
</select>
</div>
)}
{!lockCatObj && (
<div className="form-group">
<label>Objective</label>
<select value={objective} onChange={e => setObjective(e.target.value)} required disabled={loading}>
<option value="">Select</option>
{objectives.map(o => <option key={o.name} value={o.name}>{fmt(o.name)}</option>)}
</select>
</div>
)}
</div>
<div className="form-row-inline">
<div className="form-group">
<label>Recommended %</label>
<input type="number" min="0" max="100" step="0.01" value={recommendedPct}
onChange={e => setRecommendedPct(e.target.value)} required disabled={loading} />
</div>
<div className="form-group">
<label>Min Budget ($)</label>
<input type="number" min="0" step="1" value={minBudgetRequired}
onChange={e => setMinBudgetRequired(e.target.value)} disabled={loading} />
</div>
{isEdit && (
<div className="form-group">
<label>Active</label>
<select value={isActive ? 'true' : 'false'} onChange={e => setIsActive(e.target.value === 'true')} disabled={loading}>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</div>
)}
</div>
<div className="form-row-inline">
<div className="form-group" style={{ flex: '1 1 100%' }}>
<label>Rationale</label>
<input type="text" maxLength="500" value={rationale}
onChange={e => setRationale(e.target.value)} disabled={loading}
placeholder="Why this allocation is recommended…" />
</div>
</div>
<div className="form-buttons">
<button type="button" className="btn-cancel" onClick={onCancel} disabled={loading}>Cancel</button>
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? 'Saving...' : isEdit ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
);
});
// =============================================================
// MAIN COMPONENT — pure presentation, all state from context
// =============================================================
export default function TemplatesPanel() {
const { refresh } = useAdmin();
const {
templates, categories, objectives,
catIconMap, objColorMap, catCounts,
filtered, grouped,
selectedCategory, setSelectedCategory,
selectedObjective, setSelectedObjective,
editMode, setEditMode,
showForm, setShowForm,
editingId, setEditingId,
addingToGroup, setAddingToGroup,
showNewCat, setShowNewCat,
newCatName, setNewCatName,
newCatIcon, setNewCatIcon,
handleUpdateCategory, handleDeleteCategory, handleCreateCategory,
handleUpdateObjective, handleDeleteTemplate,
} = useTemplates();
const { apiCall } = useAdmin();
// ── Forecast Validation State ──
const [validating, setValidating] = useState(null); // "category|objective" key
const [validationResults, setValidationResults] = useState({}); // keyed by "category|objective"
const handleValidateWithApi = async (category, objective, currentTemplates) => {
const key = `${category}|${objective}`;
setValidating(key);
try {
const data = await apiCall('/api/forecast/channel-estimate', 'POST', {
objective,
businessCategory: category,
keywords: [category, objective],
monthlyBudget: 1500,
channels: currentTemplates.map(t => t.channelType),
});
if (data?.channels) {
const apiAllocations = {};
data.channels.forEach(ch => {
apiAllocations[ch.provider] = {
pct: ch.allocationPercent,
score: ch.qualityScore,
label: ch.strengthLabel,
impressions: ch.estimates?.impressions,
clicks: ch.estimates?.clicks,
};
});
setValidationResults(prev => ({ ...prev, [key]: { ok: true, apiAllocations, timestamp: Date.now() } }));
} else {
setValidationResults(prev => ({ ...prev, [key]: { ok: false, error: data?.error || 'Forecast unavailable' } }));
}
} catch (e) {
setValidationResults(prev => ({ ...prev, [key]: { ok: false, error: e.message } }));
}
setValidating(null);
};
return (
<div className="templates-layout">
{/* ═══ SIDEBAR ═══ */}
<aside className="templates-sidebar">
{/* ── Categories ── */}
<div className="sidebar-section">
<div className="sidebar-section-header">
<span className="sidebar-section-title">Categories</span>
<button className="btn-icon-sm" onClick={() => setEditMode(!editMode)}
title={editMode ? 'Done editing' : 'Edit categories'}>
{editMode ? '✓' : '✎'}
</button>
</div>
<div className="sidebar-list sidebar-list-scrollable">
{categories.map(cat => (
<div key={cat.categoryId || cat.name} className="sidebar-item-wrapper">
<button
className={`sidebar-item ${selectedCategory === cat.name ? 'active' : ''}`}
onClick={() => setSelectedCategory(cat.name)}>
{editMode ? (
<IconPicker value={cat.icon || '📋'}
onChange={(icon) => handleUpdateCategory(cat.categoryId, { icon })} />
) : (
<span className="sidebar-item-icon">{cat.icon || '📋'}</span>
)}
<span className="sidebar-item-label">{fmt(cat.name)}</span>
<span className="sidebar-item-count">{catCounts[cat.name] || 0}</span>
</button>
{editMode && (
<button
className="btn-icon-xs btn-danger-icon"
title={catCounts[cat.name] ? `Has ${catCounts[cat.name]} templates — remove them first` : 'Delete category'}
disabled={!!catCounts[cat.name]}
onClick={(e) => { e.stopPropagation(); handleDeleteCategory(cat.categoryId, cat.name); }}>
</button>
)}
</div>
))}
</div>
{/* Add New — always visible */}
{!showNewCat ? (
<button className="sidebar-item sidebar-add-btn" onClick={() => setShowNewCat(true)}>
<span className="sidebar-item-icon">+</span>
<span className="sidebar-item-label">Add Category</span>
</button>
) : (
<div className="sidebar-new-item">
<IconPicker value={newCatIcon || '📋'}
onChange={(icon) => setNewCatIcon(icon)} />
<input
value={newCatName}
onChange={e => setNewCatName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCreateCategory()}
placeholder="category_name"
autoFocus
/>
<button className="btn-icon-xs" onClick={handleCreateCategory}></button>
<button className="btn-icon-xs" onClick={() => { setShowNewCat(false); setNewCatName(''); setNewCatIcon(''); }}></button>
</div>
)}
</div>
{/* ── Objectives ── */}
<div className="sidebar-section">
<div className="sidebar-section-header">
<span className="sidebar-section-title">Objectives</span>
</div>
<div className="sidebar-list sidebar-list-scrollable">
<button
className={`sidebar-item ${selectedObjective === null ? 'active' : ''}`}
onClick={() => setSelectedObjective(null)}>
<span className="sidebar-color-dot" style={{ background: 'var(--text-dim)' }} />
<span className="sidebar-item-label">All Objectives</span>
</button>
{objectives.map(obj => (
<button key={obj.objectiveId || obj.name}
className={`sidebar-item ${selectedObjective === obj.name ? 'active' : ''}`}
onClick={() => setSelectedObjective(selectedObjective === obj.name ? null : obj.name)}>
{editMode ? (
<ColorPicker value={obj.color || '#999'}
onChange={(color) => handleUpdateObjective(obj.objectiveId, { color })} />
) : (
<span className="sidebar-color-dot" style={{ background: obj.color || '#999' }} />
)}
<span className="sidebar-item-label">{fmt(obj.name)}</span>
</button>
))}
</div>
</div>
</aside>
{/* ═══ MAIN CONTENT ═══ */}
<div className="templates-content">
{/* Channel legend */}
<div className="channel-legend-bar">
{CHANNELS.map(ch => (
<span key={ch} className="channel-chip" style={{ borderColor: CHANNEL_META[ch].color }}>
<span className="channel-chip-dot" style={{ background: CHANNEL_META[ch].color }} />
{CHANNEL_META[ch].label}
</span>
))}
</div>
{/* Header */}
<div className="panel-header">
<div>
<h2>
{selectedCategory ? fmt(selectedCategory) : 'Templates'}
{selectedObjective && <span className="header-objective-tag"
style={{ background: objColorMap[selectedObjective] || '#999' }}>
{fmt(selectedObjective)}
</span>}
</h2>
<span className="filter-count">{filtered.length} template{filtered.length !== 1 ? 's' : ''}</span>
</div>
<button className="btn-action" onClick={() => { setShowForm(!showForm); setEditingId(null); }}>
{showForm ? 'Cancel' : '+ Add Template'}
</button>
</div>
{/* Create/Edit form */}
{showForm && (
<TemplateForm
defaultCategory={selectedCategory}
defaultObjective={selectedObjective}
onSuccess={() => { setShowForm(false); refresh(); }}
onCancel={() => setShowForm(false)}
/>
)}
{editingId && (
<TemplateForm
editData={templates.find(t => t.templateId === editingId)}
onSuccess={() => { setEditingId(null); refresh(); }}
onCancel={() => setEditingId(null)}
/>
)}
{/* Empty state */}
{Object.keys(grouped).length === 0 && !showForm && (
<div className="empty-state">
<div className="empty-state-icon">📊</div>
<p>No templates for this combination yet.</p>
<p className="text-dim">Click "+ Add Template" to create one{selectedObjective ? ', or deselect the objective filter.' : '.'}</p>
</div>
)}
{/* Grouped template cards */}
{Object.entries(grouped).map(([key, items]) => {
const [cat, obj] = key.split('|');
const totalPct = items.reduce((s, t) => s + (t.recommendedPct || 0), 0);
const isBalanced = Math.abs(totalPct - 100) < 0.01;
return (
<div key={key} className="template-group">
<div className="template-group-header">
<div className="template-group-info">
<DonutChart allocations={items} />
<div className="template-group-meta">
<div className="template-group-title">
<span className="cat-icon">{catIconMap[cat] || '📋'}</span>
{fmt(cat)}
<span className="objective-tag"
style={{ background: objColorMap[obj] || '#999' }}>
{fmt(obj)}
</span>
</div>
<div className="template-group-stats">
{items.length} channel{items.length !== 1 ? 's' : ''}
{' · '}
<span className={isBalanced ? 'text-success' : 'text-danger'}>
{totalPct}% allocated {isBalanced ? '✓' : '⚠'}
</span>
</div>
<div className="template-group-channel-bars">
{items.map(t => (
<div key={t.templateId} className="channel-bar">
<span className="channel-bar-color"
style={{ background: CHANNEL_META[t.channelType]?.color || '#999' }} />
<span className="channel-bar-label">{CHANNEL_META[t.channelType]?.label || t.channelType}</span>
<span className="channel-bar-pct">{t.recommendedPct}%</span>
<div className="channel-bar-track">
<div className="channel-bar-fill"
style={{
width: `${t.recommendedPct}%`,
background: CHANNEL_META[t.channelType]?.color || '#999'
}} />
</div>
</div>
))}
</div>
{/* Validate with API button */}
<button
className="btn-small btn-validate"
disabled={validating === key}
onClick={() => handleValidateWithApi(cat, obj, items)}
style={{ marginTop: 8 }}
>
{validating === key ? '⏳ Checking…' : '🔬 Validate with Forecast API'}
</button>
</div>
</div>
</div>
{/* API Validation Results */}
{validationResults[key] && (
<div className={`validation-results ${validationResults[key].ok ? '' : 'validation-error'}`}>
{validationResults[key].ok ? (
<>
<div className="validation-header">
<span>📊 API Forecast Comparison</span>
<span className="text-dim" style={{ fontSize: 11 }}>
at $1,500/mo benchmark
</span>
</div>
<div className="validation-comparison">
{items.map(t => {
const api = validationResults[key].apiAllocations[t.channelType];
const diff = api ? (api.pct - t.recommendedPct) : null;
return (
<div key={t.templateId} className="validation-row">
<span className="channel-bar-color" style={{ background: CHANNEL_META[t.channelType]?.color || '#999' }} />
<span className="validation-ch">{CHANNEL_META[t.channelType]?.label || t.channelType}</span>
<span className="validation-template">{t.recommendedPct}%</span>
<span className="validation-arrow"></span>
<span className={`validation-api ${diff && Math.abs(diff) > 5 ? 'validation-divergent' : ''}`}>
{api ? `${api.pct}%` : '—'}
</span>
{diff != null && Math.abs(diff) > 0 && (
<span className={`validation-diff ${diff > 0 ? 'text-success' : 'text-danger'}`}>
{diff > 0 ? '+' : ''}{diff}%
</span>
)}
</div>
);
})}
</div>
</>
) : (
<div className="validation-error-msg">
{validationResults[key].error}
</div>
)}
</div>
)}
<table className="data-table">
<thead>
<tr>
<th>Channel</th>
<th>Allocation %</th>
<th>Min Budget</th>
<th>Rationale</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{items.sort((a, b) => (b.recommendedPct || 0) - (a.recommendedPct || 0)).map(t => (
<tr key={t.templateId} className={!t.isActive ? 'row-inactive' : ''}>
<td><ChannelBadge channel={t.channelType} /></td>
<td className="number-cell">{t.recommendedPct}%</td>
<td className="number-cell">${t.minBudgetRequired}</td>
<td className="rationale-cell" title={t.rationale || ''}>
{truncate(t.rationale, 50)}
</td>
<td>{t.isActive ? '✓' : '—'}</td>
<td>
<button className="btn-small" onClick={() => setEditingId(t.templateId)}>Edit</button>
{' '}
<button className="btn-small btn-danger"
onClick={() => handleDeleteTemplate(t.templateId)}>Delete</button>
</td>
</tr>
))}
</tbody>
</table>
{/* Add Channel */}
{(() => {
const usedChannels = items.map(t => t.channelType);
const missing = CHANNELS.filter(c => !usedChannels.includes(c));
if (missing.length === 0) return null;
const isAdding = addingToGroup?.category === cat && addingToGroup?.objective === obj;
return isAdding ? (
<TemplateForm
defaultCategory={cat}
defaultObjective={obj}
availableChannels={missing}
onSuccess={() => { setAddingToGroup(null); refresh(); }}
onCancel={() => setAddingToGroup(null)}
/>
) : (
<button className="btn-small btn-add-channel"
onClick={() => setAddingToGroup({ category: cat, objective: obj })}>
+ Add Channel ({missing.map(c => CHANNEL_META[c]?.label || c).join(', ')})
</button>
);
})()}
</div>
);
})}
{/* Allocation Summary */}
{Object.keys(grouped).length > 0 && (
<div className="allocation-summary">
<h3>Allocation Summary</h3>
<div className="summary-grid">
{Object.entries(grouped).map(([key, items]) => {
const [cat, obj] = key.split('|');
const totalPct = items.reduce((s, t) => s + (t.recommendedPct || 0), 0);
const isBalanced = Math.abs(totalPct - 100) < 0.01;
return (
<div key={key} className={`summary-item ${isBalanced ? '' : 'summary-warning'}`}>
<span className="summary-label">
{catIconMap[cat] || '📋'} {fmt(cat)} / {fmt(obj)}
</span>
<span className={`summary-pct ${isBalanced ? 'summary-ok' : 'summary-bad'}`}>
{totalPct}%
</span>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,209 +0,0 @@
import React, { useState, useEffect } from 'react';
import { API_BASE_URL } from '../../auth/authConfig';
export default function UsersPanel({ data, sessionToken, onRefresh }) {
const [showForm, setShowForm] = useState(false);
const users = data.users || [];
return (
<div className="table-panel">
<div className="panel-header">
<h2>Users ({data.totalCount})</h2>
<button className="btn-action" onClick={() => setShowForm(!showForm)}>
{showForm ? 'Cancel' : '+ Add User'}
</button>
</div>
{showForm && (
<CreateUserForm
sessionToken={sessionToken}
onSuccess={() => { setShowForm(false); onRefresh(); }}
onCancel={() => setShowForm(false)}
/>
)}
<table className="data-table">
<thead>
<tr>
<th>Email</th>
<th>Name</th>
<th>Status</th>
<th>Clients</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map(user => (
<tr key={user.userId}>
<td>{user.email}</td>
<td>{user.displayName || '-'}</td>
<td><StatusBadge status={user.status} /></td>
<td>{user.clients?.map(c => `${c.clientName} (${c.role})`).join(', ') || '-'}</td>
<td>{new Date(user.createdAt).toLocaleDateString()}</td>
<td>
<button
className="btn-small btn-danger"
onClick={() => deleteUser(user.userId, sessionToken, onRefresh)}
>
Deactivate
</button>
</td>
</tr>
))}
{users.length === 0 && (
<tr><td colSpan="6" className="empty-row">No users found</td></tr>
)}
</tbody>
</table>
</div>
);
}
function CreateUserForm({ sessionToken, onSuccess, onCancel }) {
const [email, setEmail] = useState('');
const [displayName, setDisplayName] = useState('');
const [clientId, setClientId] = useState('');
const [role, setRole] = useState('User');
const [clients, setClients] = useState([]);
const [loading, setLoading] = useState(false);
const [loadingClients, setLoadingClients] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const loadClients = async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/admin/clients?pageSize=100`, {
headers: { 'X-Session-Token': sessionToken },
});
const data = await response.json();
if (data.ok) setClients(data.clients || []);
} catch (err) {
console.error('Failed to load clients:', err);
} finally {
setLoadingClients(false);
}
};
loadClients();
}, [sessionToken]);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const response = await fetch(`${API_BASE_URL}/api/admin/users`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Session-Token': sessionToken,
},
body: JSON.stringify({
email,
displayName: displayName || null,
clientId: clientId || null,
role
}),
});
const data = await response.json();
if (data.ok) {
onSuccess();
} else {
setError(data.error || 'Failed to create user');
}
} catch (err) {
setError('Network error: ' + err.message);
} finally {
setLoading(false);
}
};
return (
<div className="admin-form">
<h3>Create User</h3>
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Email *</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
disabled={loading}
required
autoFocus
/>
</div>
<div className="form-group">
<label>Display Name</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="John Smith"
disabled={loading}
/>
</div>
<div className="form-group">
<label>Assign to Client</label>
<select
value={clientId}
onChange={(e) => setClientId(e.target.value)}
disabled={loading || loadingClients}
>
<option value="">-- No client (invite later) --</option>
{clients.filter(c => c.status === 'Active').map(c => (
<option key={c.clientId} value={c.clientId}>{c.clientName}</option>
))}
</select>
</div>
{clientId && (
<div className="form-group">
<label>Role</label>
<select value={role} onChange={(e) => setRole(e.target.value)} disabled={loading}>
<option value="Admin">Admin</option>
<option value="User">User</option>
<option value="ReadOnly">Read Only</option>
</select>
</div>
)}
<div className="form-buttons">
<button type="button" className="btn-cancel" onClick={onCancel} disabled={loading}>Cancel</button>
<button type="submit" className="btn-primary" disabled={loading || !email.trim()}>
{loading ? 'Creating...' : 'Create User'}
</button>
</div>
</form>
</div>
);
}
async function deleteUser(userId, sessionToken, onRefresh) {
if (!confirm('Are you sure you want to deactivate this user?')) return;
try {
const response = await fetch(`${API_BASE_URL}/api/admin/users/${userId}`, {
method: 'DELETE',
headers: { 'X-Session-Token': sessionToken },
});
const data = await response.json();
if (data.ok) {
onRefresh();
} else {
alert(data.error || 'Failed to deactivate user');
}
} catch (err) {
alert('Network error: ' + err.message);
}
}
function StatusBadge({ status }) {
const statusClass = {
'Active': 'status-active',
'Inactive': 'status-inactive',
}[status] || 'status-default';
return <span className={`status-badge ${statusClass}`}>{status}</span>;
}

View File

@@ -0,0 +1,263 @@
import React, { createContext, useContext, useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { useAuth } from '../auth/AuthProvider';
import { MANAGEMENT_URL } from '../auth/authConfig';
// ─── Tab / Category Configuration ─────────────────────────────────────────────
export const CATEGORY_TABS = {
dashboard: [
{ id: 'overview', label: 'Overview' },
{ id: 'sessions', label: 'Sessions' },
],
clients: [
{ id: 'pending', label: 'Pending' },
{ id: 'allClients', label: 'All Clients' },
{ id: 'clientUsers', label: 'Client Users' },
{ id: 'clientActivity', label: 'Activity' },
{ id: 'clientDocuments', label: 'Documents' },
],
campaigns: [{ id: 'campaigns', label: 'All Campaigns' }],
intelligence: [
{ id: 'performance', label: 'Performance' },
{ id: 'insights', label: 'Insights' },
{ id: 'analysis', label: 'Analysis' },
],
config: [
{ id: 'objectives', label: 'Objective Mapping' },
{ id: 'templates', label: 'Allocation Templates' },
{ id: 'modifiers', label: 'Audience Modifiers' },
],
documents: [{ id: 'documents', label: 'Documents' }],
};
export const TAB_ENDPOINTS = {
overview: '/api/monitoring/health', // GET
sessions: '/api/admin/sessions/list', // POST {}
clientUsers: '/api/admin/client-users/list', // POST {}
templates: '/api/admin/templates/list', // POST {}
objectives: '/api/admin/objectives/list', // POST {}
campaigns: '/api/admin/campaigns/list', // POST {}
help: '/api/admin/help/list', // POST {}
// documents — self-managing panel, no auto-fetch
};
export const CATEGORY_LABELS = {
dashboard: 'Dashboard',
clients: 'Clients',
campaigns: 'Campaigns',
intelligence: 'Campaign Intelligence',
config: 'Configuration',
documents: 'Documents',
};
// ─── Cache config ─────────────────────────────────────────────────────────────
const CACHE_TTL_MS = 2 * 60 * 1000; // 2 minutes
// ─── Context ──────────────────────────────────────────────────────────────────
const AdminContext = createContext(null);
export function useAdmin() {
const ctx = useContext(AdminContext);
if (!ctx) throw new Error('useAdmin must be used within <AdminProvider>');
return ctx;
}
// ─── Provider ─────────────────────────────────────────────────────────────────
export function AdminProvider({ children }) {
const {
sessionUser: user,
signOut,
getIdToken,
isAuthenticated: isSignedIn,
userRole,
isAdmin,
isTech,
isStaff,
} = useAuth();
// Navigation
const [activeCategory, setActiveCategory] = useState('dashboard');
const [activeTab, setActiveTab] = useState('overview');
const [collapsed, setCollapsed] = useState(false);
// Active tab fetch state
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [refreshKey, setRefreshKey] = useState(0);
// ─── Response cache ───────────────────────────────────────────────────────
// Stored in a ref so cache reads/writes never trigger re-renders.
// Shape: Map<url, { data: any, timestamp: number }>
const cacheRef = useRef(new Map());
const isCacheValid = (entry) =>
entry != null && (Date.now() - entry.timestamp) < CACHE_TTL_MS;
const invalidateCache = useCallback((pathPrefix = null) => {
if (!pathPrefix) {
cacheRef.current.clear();
} else {
for (const key of cacheRef.current.keys()) {
if (key.includes(pathPrefix)) cacheRef.current.delete(key);
}
}
}, []);
// ─── Core API call — JWT Bearer, with GET caching ─────────────────────────
// Two calling conventions:
// apiCall(url, 'POST', body) — standard
// apiCall(url, { method, body, rawResponse }) — options-object for special cases
// rawResponse: true → returns raw fetch Response (for blob downloads)
// body: FormData → multipart upload; Content-Type not set (browser sets boundary)
const apiCall = useCallback(async (urlOrPath, methodOrOpts = 'GET', body = null) => {
const url = urlOrPath.startsWith('http') ? urlOrPath : `${MANAGEMENT_URL}${urlOrPath}`;
// Resolve calling convention
let method, reqBody, isFormData, rawResponse;
if (methodOrOpts !== null && typeof methodOrOpts === 'object') {
method = methodOrOpts.method || 'GET';
rawResponse = !!methodOrOpts.rawResponse;
const b = methodOrOpts.body ?? null;
isFormData = b instanceof FormData;
reqBody = isFormData ? b : (typeof b === 'string' ? b : (b ? JSON.stringify(b) : null));
} else {
method = methodOrOpts;
rawResponse = false;
isFormData = false;
reqBody = body ? JSON.stringify(body) : null;
}
// Cache hit for plain GETs only
if (method === 'GET' && !rawResponse) {
const cached = cacheRef.current.get(url);
if (isCacheValid(cached)) {
console.debug('[Admin] Cache hit:', urlOrPath);
return cached.data;
}
}
const token = await getIdToken();
if (!token) {
console.warn('[Admin] apiCall: no token');
return rawResponse ? null : { ok: false, error: 'Not authenticated' };
}
const headers = { 'Authorization': `Bearer ${token}` };
if (!isFormData) headers['Content-Type'] = 'application/json';
const fetchOpts = { method, headers };
if (reqBody) fetchOpts.body = reqBody;
try {
const res = await fetch(url, fetchOpts);
if (!res.ok) {
if (rawResponse) return null;
const text = await res.text();
if (res.status !== 404) {
console.error(`[Admin] HTTP ${res.status}:`, text.substring(0, 200));
}
return { ok: false, error: `HTTP ${res.status}: ${text.substring(0, 100)}` };
}
if (rawResponse) return res;
const result = await res.json();
if (method === 'GET') {
cacheRef.current.set(url, { data: result, timestamp: Date.now() });
} else if (result?.ok) {
const basePath = urlOrPath.replace(/\/[^/]+$/, '');
const baseUrl = `${MANAGEMENT_URL}${basePath}`;
if (cacheRef.current.has(baseUrl)) {
cacheRef.current.set(baseUrl, { data: result, timestamp: Date.now() });
}
}
return result;
} catch (err) {
console.error('[Admin] Network error:', err.message);
return rawResponse ? null : { ok: false, error: 'Network error: ' + err.message };
}
}, [getIdToken, invalidateCache]);
// ─── Navigation ───────────────────────────────────────────────────────────
const handleSelectCategory = useCallback((categoryId) => {
setActiveCategory(categoryId);
const tabs = CATEGORY_TABS[categoryId];
if (tabs?.length > 0) setActiveTab(tabs[0].id);
setData(null);
setError(null);
}, []);
const handleSelectTab = useCallback((tabId) => {
setActiveTab(tabId);
setData(null);
setError(null);
}, []);
// ─── Tab data fetch ───────────────────────────────────────────────────────
// /api/monitoring/health is a true GET.
// All list endpoints use POST with an empty body.
const fetchTabData = useCallback(async (endpoint) => {
if (!isSignedIn) return null;
setLoading(true);
setError(null);
try {
const isGet = endpoint === '/api/monitoring/health';
const result = isGet
? await apiCall(endpoint)
: await apiCall(endpoint, 'POST', {});
if (!result?.ok) throw new Error(result?.error || 'Request failed');
return result;
} catch (err) {
console.error('[Admin] Tab fetch error:', err);
setError(err.message);
return null;
} finally {
setLoading(false);
}
}, [isSignedIn, apiCall]);
// Force-refresh: bust the cache for the current tab endpoint, then re-fetch
const refresh = useCallback(() => {
const endpoint = TAB_ENDPOINTS[activeTab];
if (endpoint) invalidateCache(`${MANAGEMENT_URL}${endpoint}`);
setRefreshKey(k => k + 1);
}, [activeTab, invalidateCache]);
useEffect(() => {
const endpoint = TAB_ENDPOINTS[activeTab];
if (!endpoint) {
setData(null);
setLoading(false);
return;
}
fetchTabData(endpoint).then(setData);
}, [activeTab, fetchTabData, refreshKey]);
// ─── Context value ────────────────────────────────────────────────────────
const value = useMemo(() => ({
user, signOut,
userRole, isAdmin, isTech, isStaff,
activeCategory, activeTab, collapsed,
setActiveCategory: handleSelectCategory,
setActiveTab: handleSelectTab,
setCollapsed,
tabs: CATEGORY_TABS[activeCategory] || [],
data, loading, error, refresh,
apiCall,
invalidateCache,
}), [
user, signOut,
userRole, isAdmin, isTech, isStaff,
activeCategory, activeTab, collapsed,
handleSelectCategory, handleSelectTab,
data, loading, error, refresh,
apiCall, invalidateCache,
]);
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>;
}

View File

@@ -0,0 +1,273 @@
// ============================================================
// context/ObjectiveMappingsContext.jsx
// Provides channels, objectives, and mappings state for
// the ObjectiveMappingPanel. Follows TemplatesContext pattern.
// ============================================================
import React, { createContext, useContext, useState, useCallback, useEffect, useMemo } from 'react';
import { useAdmin } from './AdminContext';
// ── Provider-specific objective options ──
// These stay as client-side helpers for the mapping form.
// Could move to a tbProviderObjective table later.
export const PROVIDER_OBJECTIVES = {
google_ads: [
{ value: 'MAXIMIZE_CONVERSIONS', label: 'Maximize Conversions' },
{ value: 'MAXIMIZE_CONVERSION_VALUE', label: 'Maximize Conversion Value' },
{ value: 'TARGET_CPA', label: 'Target CPA' },
{ value: 'TARGET_ROAS', label: 'Target ROAS' },
{ value: 'MAXIMIZE_CLICKS', label: 'Maximize Clicks' },
{ value: 'TARGET_IMPRESSION_SHARE', label: 'Target Impression Share' },
{ value: 'MANUAL_CPC', label: 'Manual CPC' },
],
meta: [
{ value: 'OUTCOME_AWARENESS', label: 'Awareness' },
{ value: 'OUTCOME_TRAFFIC', label: 'Traffic' },
{ value: 'OUTCOME_ENGAGEMENT', label: 'Engagement' },
{ value: 'OUTCOME_LEADS', label: 'Leads' },
{ value: 'OUTCOME_SALES', label: 'Sales' },
{ value: 'CONVERSIONS', label: 'Conversions' },
],
tiktok: [
{ value: 'REACH', label: 'Reach' },
{ value: 'TRAFFIC', label: 'Traffic' },
{ value: 'VIDEO_VIEWS', label: 'Video Views' },
{ value: 'CONVERSION', label: 'Conversion' },
{ value: 'APP_INSTALL', label: 'App Install' },
],
};
const ObjectiveMappingsContext = createContext(null);
export function useObjectiveMappings() {
const ctx = useContext(ObjectiveMappingsContext);
if (!ctx) throw new Error('useObjectiveMappings must be used within <ObjectiveMappingsProvider>');
return ctx;
}
export function ObjectiveMappingsProvider({ children }) {
const { data, apiCall, refresh, loading: adminLoading } = useAdmin();
// ── Metadata state ──
const [channels, setChannels] = useState([]);
const [objectives, setObjectives] = useState([]);
const [metaLoading, setMetaLoading] = useState(true);
const [metaError, setMetaError] = useState(null);
// ── Track whether API endpoints returned data or need fallback ──
const [needsChannelFallback, setNeedsChannelFallback] = useState(false);
const [needsObjectiveFallback, setNeedsObjectiveFallback] = useState(false);
// ── Fetch channels + objectives once on mount ──
useEffect(() => {
let cancelled = false;
const fetchMeta = async () => {
setMetaLoading(true);
setMetaError(null);
try {
const [chRes, objRes] = await Promise.all([
apiCall('/api/admin/template-config/channels', 'GET'),
apiCall('/api/admin/template-config/objectives', 'GET'),
]);
if (!cancelled) {
// Channels
if (chRes?.ok && chRes.channels) {
setChannels(chRes.channels);
} else {
setNeedsChannelFallback(true);
}
// Objectives
if (objRes?.ok && objRes.objectives) {
setObjectives(objRes.objectives);
} else {
setNeedsObjectiveFallback(true);
}
}
} catch (err) {
if (!cancelled) setMetaError(err.message);
} finally {
if (!cancelled) setMetaLoading(false);
}
};
fetchMeta();
return () => { cancelled = true; };
}, [apiCall]); // apiCall is stable — runs once on mount
// ── Fallback: derive from mapping data once it arrives ──
useEffect(() => {
if (!data?.mappings) return;
if (needsChannelFallback && channels.length === 0) {
console.info('[ObjectiveMappings] Deriving channels from mapping data');
setChannels(deriveChannelsFromMappings(data));
}
if (needsObjectiveFallback && objectives.length === 0) {
console.info('[ObjectiveMappings] Deriving objectives from mapping data');
setObjectives(deriveObjectivesFromMappings(data));
}
}, [data, needsChannelFallback, needsObjectiveFallback, channels.length, objectives.length]);
// ── Mappings come from AdminContext `data` (the /api/admin/objectives response) ──
const mappings = useMemo(() => {
if (!data?.mappings) return [];
return data.mappings;
}, [data]);
// ── Lookup maps ──
const channelMap = useMemo(() => {
const map = {};
channels.forEach(ch => {
map[ch.code] = ch;
});
return map;
}, [channels]);
const objectiveColorMap = useMemo(() => {
const map = {};
objectives.forEach(obj => {
map[obj.name] = obj.color || '#6B7280';
});
return map;
}, [objectives]);
// ── Coverage matrix: { [objective]: { [channelCode]: mapping | null } } ──
const coverageMatrix = useMemo(() => {
const activeObjectives = objectives.filter(o => o.isActive);
const activeChannels = channels.filter(c => c.isActive);
const matrix = {};
activeObjectives.forEach(obj => {
matrix[obj.name] = {};
activeChannels.forEach(ch => {
const mapping = mappings.find(
m => m.platformObjective === obj.name && m.channelType === ch.code && m.isActive
);
matrix[obj.name][ch.code] = mapping || null;
});
});
return matrix;
}, [objectives, channels, mappings]);
// ── Coverage stats ──
const coverageStats = useMemo(() => {
const activeObjectives = objectives.filter(o => o.isActive);
const activeChannels = channels.filter(c => c.isActive);
const totalCells = activeObjectives.length * activeChannels.length;
let filledCells = 0;
activeObjectives.forEach(obj => {
activeChannels.forEach(ch => {
if (coverageMatrix[obj.name]?.[ch.code]) filledCells++;
});
});
return {
total: totalCells,
filled: filledCells,
gaps: totalCells - filledCells,
pct: totalCells > 0 ? Math.round((filledCells / totalCells) * 100) : 0,
};
}, [coverageMatrix, objectives, channels]);
// ── CRUD callbacks ──
const createMapping = useCallback(async (body) => {
const result = await apiCall('/api/admin/objectives', 'POST', body);
if (result?.ok) refresh();
return result;
}, [apiCall, refresh]);
const updateMapping = useCallback(async (mappingId, body) => {
const result = await apiCall(`/api/admin/objectives/${mappingId}`, 'PUT', body);
if (result?.ok) refresh();
return result;
}, [apiCall, refresh]);
const deleteMapping = useCallback(async (mappingId) => {
const result = await apiCall(`/api/admin/objectives/${mappingId}`, 'DELETE');
if (result?.ok) refresh();
return result;
}, [apiCall, refresh]);
// ── Provider objectives helper ──
const getProviderObjectives = useCallback((channelCode) => {
return PROVIDER_OBJECTIVES[channelCode] || [];
}, []);
const value = useMemo(() => ({
// Data
channels,
objectives,
mappings,
channelMap,
objectiveColorMap,
coverageMatrix,
coverageStats,
// Loading
loading: adminLoading || metaLoading,
metaLoading,
metaError,
// CRUD
createMapping,
updateMapping,
deleteMapping,
// Helpers
getProviderObjectives,
refresh,
}), [
channels, objectives, mappings, channelMap, objectiveColorMap,
coverageMatrix, coverageStats, adminLoading, metaLoading, metaError,
createMapping, updateMapping, deleteMapping, getProviderObjectives, refresh,
]);
return (
<ObjectiveMappingsContext.Provider value={value}>
{children}
</ObjectiveMappingsContext.Provider>
);
}
// ── Fallback derivation helpers ──
function deriveChannelsFromMappings(data) {
if (!data?.mappings) return [];
const codes = [...new Set(data.mappings.map(m => m.channelType))].sort();
const defaults = {
google_ads: { label: 'Google Ads', color: '#4285F4', icon: '🔵' },
meta: { label: 'Meta', color: '#8B5CF6', icon: '🟣' },
tiktok: { label: 'TikTok', color: '#FF004F', icon: '🔴' },
};
return codes.map((code, i) => ({
channelId: i + 1,
code,
label: defaults[code]?.label || code,
color: defaults[code]?.color || '#6B7280',
icon: defaults[code]?.icon || '📢',
sortOrder: i,
isActive: true,
}));
}
function deriveObjectivesFromMappings(data) {
if (!data?.mappings) return [];
const names = [...new Set(data.mappings.map(m => m.platformObjective))].sort();
const defaultColors = {
awareness: '#3B82F6',
traffic: '#F59E0B',
leads: '#8B5CF6',
conversions: '#10B981',
sales: '#EF4444',
};
return names.map((name, i) => ({
objectiveId: i + 1,
name,
color: defaultColors[name] || '#6B7280',
sortOrder: i,
isActive: true,
}));
}

View File

@@ -0,0 +1,244 @@
import React, { createContext, useContext, useState, useCallback, useEffect, useMemo } from 'react';
import { useAdmin } from './AdminContext';
import { API_BASE_URL } from '../auth/authConfig';
// ─── Channel Configuration ──────────────────────────────────
export const CHANNELS = ['google_ads', 'meta', 'tiktok'];
export const CHANNEL_META = {
google_ads: { color: '#4285F4', label: 'Google Ads' },
meta: { color: '#8B5CF6', label: 'Meta' },
tiktok: { color: '#FF004F', label: 'TikTok' },
};
// Default fallbacks when metadata tables haven't been seeded
const FALLBACK_ICONS = {
restaurant: '🍽️', retail: '🛍️', b2b_services: '💼',
local_services: '🏠', health_wellness: '🏥', general: '📋',
automotive: '🚗', education: '🎓', legal_services: '⚖️',
fitness: '🏋️', real_estate: '🏢', pet_services: '🐾', travel: '✈️',
};
const FALLBACK_OBJ_COLORS = {
awareness: '#7dca7d', traffic: '#4fc3f7', leads: '#ffb74d',
conversions: '#ce93d8', sales: '#ef5350',
};
// ─── Context ────────────────────────────────────────────────
const TemplatesContext = createContext(null);
export function useTemplates() {
const ctx = useContext(TemplatesContext);
if (!ctx) throw new Error('useTemplates must be used within <TemplatesProvider>');
return ctx;
}
// ─── Provider ───────────────────────────────────────────────
export function TemplatesProvider({ children }) {
const { data, apiCall, refresh } = useAdmin();
const templates = data?.templates || [];
// ─── Metadata state ─────────────────────────────────────
const [categories, setCategories] = useState([]);
const [objectives, setObjectives] = useState([]);
const [metaLoading, setMetaLoading] = useState(true);
// ─── Sidebar filter state ───────────────────────────────
const [selectedCategory, setSelectedCategory] = useState(null);
const [selectedObjective, setSelectedObjective] = useState(null);
const [editMode, setEditMode] = useState(false);
// ─── Form state ─────────────────────────────────────────
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState(null);
const [addingToGroup, setAddingToGroup] = useState(null);
// ─── New category state ─────────────────────────────────
const [showNewCat, setShowNewCat] = useState(false);
const [newCatName, setNewCatName] = useState('');
const [newCatIcon, setNewCatIcon] = useState('');
// ─── Fetch category & objective metadata ────────────────
const fetchMetadata = useCallback(async () => {
setMetaLoading(true);
try {
const [catData, objData] = await Promise.all([
apiCall('/api/admin/template-config/categories').catch(() => null),
apiCall('/api/admin/template-config/objectives').catch(() => null),
]);
if (catData?.ok && catData.categories) {
setCategories([...catData.categories].sort((a, b) => a.name.localeCompare(b.name)));
} else {
const catNames = [...new Set(templates.map(t => t.businessCategory))].sort();
setCategories(catNames.map((name, i) => ({
categoryId: i + 1, name,
icon: templates.find(t => t.businessCategory === name)?.categoryIcon || FALLBACK_ICONS[name] || '📋',
isActive: true,
templateCount: templates.filter(t => t.businessCategory === name).length,
})));
}
if (objData?.ok && objData.objectives) {
setObjectives([...objData.objectives].sort((a, b) => a.name.localeCompare(b.name)));
} else {
const objNames = [...new Set(templates.map(t => t.objective))].sort();
setObjectives(objNames.map((name, i) => ({
objectiveId: i + 1, name,
color: templates.find(t => t.objective === name)?.objectiveColor || FALLBACK_OBJ_COLORS[name] || '#999',
isActive: true,
templateCount: templates.filter(t => t.objective === name).length,
})));
}
} catch (err) {
console.warn('[Templates] Metadata fetch failed, using fallbacks:', err.message);
const catNames = [...new Set(templates.map(t => t.businessCategory))].sort();
setCategories(catNames.map((name, i) => ({
categoryId: i + 1, name,
icon: FALLBACK_ICONS[name] || '📋', isActive: true,
templateCount: templates.filter(t => t.businessCategory === name).length,
})));
const objNames = [...new Set(templates.map(t => t.objective))].sort();
setObjectives(objNames.map((name, i) => ({
objectiveId: i + 1, name,
color: FALLBACK_OBJ_COLORS[name] || '#999', isActive: true,
templateCount: templates.filter(t => t.objective === name).length,
})));
} finally {
setMetaLoading(false);
}
}, [apiCall, templates]);
useEffect(() => { fetchMetadata(); }, [fetchMetadata]);
// Auto-select first category
useEffect(() => {
if (!selectedCategory && categories.length > 0) {
setSelectedCategory(categories[0].name);
}
}, [categories, selectedCategory]);
// ─── Memoized lookups ───────────────────────────────────
const catIconMap = useMemo(() => {
const map = {};
categories.forEach(c => { map[c.name] = c.icon; });
return map;
}, [categories]);
const objColorMap = useMemo(() => {
const map = {};
objectives.forEach(o => { map[o.name] = o.color; });
return map;
}, [objectives]);
const filtered = useMemo(() =>
templates.filter(t =>
(!selectedCategory || t.businessCategory === selectedCategory) &&
(!selectedObjective || t.objective === selectedObjective)
),
[templates, selectedCategory, selectedObjective]
);
const grouped = useMemo(() => {
const g = {};
filtered.forEach(t => {
const key = `${t.businessCategory}|${t.objective}`;
if (!g[key]) g[key] = [];
g[key].push(t);
});
return g;
}, [filtered]);
const catCounts = useMemo(() => {
const counts = {};
templates.forEach(t => {
counts[t.businessCategory] = (counts[t.businessCategory] || 0) + 1;
});
return counts;
}, [templates]);
// ─── CRUD operations ────────────────────────────────────
const handleUpdateCategory = useCallback(async (categoryId, updates) => {
const result = await apiCall(`/api/admin/template-config/categories/${categoryId}`, 'PUT', updates);
if (result.ok) { fetchMetadata(); refresh(); }
else alert(result.error || 'Update failed');
}, [apiCall, fetchMetadata, refresh]);
const handleDeleteCategory = useCallback(async (categoryId, name) => {
if (!confirm(`Delete category "${name}"? This cannot be undone.`)) return;
const result = await apiCall(`/api/admin/template-config/categories/${categoryId}`, 'DELETE');
if (result.ok) {
if (selectedCategory === name) setSelectedCategory(null);
fetchMetadata(); refresh();
}
else alert(result.error || 'Delete failed');
}, [apiCall, fetchMetadata, refresh, selectedCategory]);
const handleCreateCategory = useCallback(async () => {
if (!newCatName.trim()) return;
const result = await apiCall('/api/admin/template-config/categories', 'POST', {
name: newCatName.trim().toLowerCase().replace(/\s+/g, '_'),
icon: newCatIcon || '📋',
});
if (result.ok) {
setNewCatName(''); setNewCatIcon(''); setShowNewCat(false);
fetchMetadata();
}
else alert(result.error || 'Create failed');
}, [apiCall, fetchMetadata, newCatName, newCatIcon]);
const handleUpdateObjective = useCallback(async (objectiveId, updates) => {
const result = await apiCall(`/api/admin/template-config/objectives/${objectiveId}`, 'PUT', updates);
if (result.ok) fetchMetadata();
else alert(result.error || 'Update failed');
}, [apiCall, fetchMetadata]);
const handleDeleteTemplate = useCallback(async (templateId) => {
if (!confirm('Delete this allocation template?')) return;
const result = await apiCall(`/api/admin/templates/${templateId}`, 'DELETE');
if (result.ok) refresh();
else alert(result.error || 'Delete failed');
}, [apiCall, refresh]);
// ─── Memoized context value ─────────────────────────────
const value = useMemo(() => ({
// Data
templates, categories, objectives, metaLoading,
catIconMap, objColorMap, catCounts,
filtered, grouped,
// Sidebar state
selectedCategory, setSelectedCategory,
selectedObjective, setSelectedObjective,
editMode, setEditMode,
// Form state
showForm, setShowForm,
editingId, setEditingId,
addingToGroup, setAddingToGroup,
// New category
showNewCat, setShowNewCat,
newCatName, setNewCatName,
newCatIcon, setNewCatIcon,
// Actions
fetchMetadata,
handleUpdateCategory, handleDeleteCategory, handleCreateCategory,
handleUpdateObjective, handleDeleteTemplate,
}), [
templates, categories, objectives, metaLoading,
catIconMap, objColorMap, catCounts,
filtered, grouped,
selectedCategory, selectedObjective, editMode,
showForm, editingId, addingToGroup,
showNewCat, newCatName, newCatIcon,
fetchMetadata,
handleUpdateCategory, handleDeleteCategory, handleCreateCategory,
handleUpdateObjective, handleDeleteTemplate,
]);
return <TemplatesContext.Provider value={value}>{children}</TemplatesContext.Provider>;
}

View File

@@ -0,0 +1,7 @@
export { AdminProvider, useAdmin, CATEGORY_TABS, TAB_ENDPOINTS, CATEGORY_LABELS } from './AdminContext';
export { TemplatesProvider, useTemplates, CHANNELS, CHANNEL_META } from './TemplatesContext';
export {
ObjectiveMappingsProvider,
useObjectiveMappings,
PROVIDER_OBJECTIVES,
} from './ObjectiveMappingsContext';

View File

@@ -3,6 +3,8 @@ import { createRoot } from 'react-dom/client';
import { AuthProvider } from './auth/AuthProvider';
import App from './app/App';
import './styles/app.css';
import './styles/templates-panel.css';
import './styles/modifiers-panel.css';
const root = createRoot(document.getElementById('root'));
root.render(

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,389 @@
/* ============================================================
Intelligence Panel Styles
============================================================ */
.intel-panel {
width: 100%;
}
.intel-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 60px 20px;
color: var(--text-dim);
}
/* ─── Emulated Data Banner ───────────────────────────────── */
.intel-emulated-banner {
padding: 10px 16px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
color: #1e40af;
font-size: 13px;
margin-bottom: 16px;
}
.intel-emulated-banner code {
background: rgba(0,0,0,0.06);
padding: 1px 5px;
border-radius: 3px;
font-size: 12px;
}
/* ─── KPI Grid ───────────────────────────────────────────── */
.intel-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
margin: 16px 0 24px;
}
.intel-kpi-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px 16px;
text-align: center;
}
.intel-kpi-value {
font-size: 26px;
font-weight: 700;
color: var(--accent);
letter-spacing: -0.5px;
margin-bottom: 4px;
}
.intel-kpi-label {
font-size: 11px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 550;
}
/* ─── Channel Breakdown ──────────────────────────────────── */
.intel-channel-breakdown {
margin-bottom: 24px;
}
.intel-channel-breakdown h3 {
font-size: 15px;
font-weight: 600;
margin-bottom: 12px;
color: var(--text);
}
.intel-channel-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
}
.intel-channel-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-top: 3px solid;
border-radius: 8px;
padding: 16px;
}
.intel-channel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.intel-channel-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.intel-channel-count {
font-size: 12px;
color: var(--text-dim);
}
.intel-channel-metrics {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.intel-channel-metrics > div {
display: flex;
flex-direction: column;
}
.metric-val {
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.metric-lbl {
font-size: 10px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.3px;
margin-top: 1px;
}
/* ─── Campaigns Performance Table ────────────────────────── */
.intel-campaigns-table {
margin-top: 4px;
}
.intel-campaigns-table h3 {
font-size: 15px;
font-weight: 600;
margin-bottom: 12px;
color: var(--text);
}
/* ─── Pacing Bar ─────────────────────────────────────────── */
.intel-pacing {
display: flex;
align-items: center;
gap: 6px;
min-width: 100px;
}
.intel-pacing-label {
font-size: 11px;
color: var(--text-dim);
white-space: nowrap;
}
.intel-pacing-track {
flex: 1;
height: 6px;
background: #e5e7eb;
border-radius: 3px;
overflow: hidden;
min-width: 50px;
}
.intel-pacing-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
}
.intel-pacing-pct {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
min-width: 32px;
text-align: right;
}
/* ─── Insights View ──────────────────────────────────────── */
.intel-insights-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.intel-insights-summary {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 14px;
color: var(--text-secondary);
}
.intel-insights-count {
font-size: 28px;
font-weight: 700;
color: var(--accent);
letter-spacing: -0.5px;
}
.intel-insights-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.intel-insight-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-left: 4px solid;
border-radius: 8px;
overflow: hidden;
}
.intel-insight-top {
padding: 14px 16px;
}
.intel-insight-top:hover {
background: rgba(0,0,0,0.01);
}
.intel-insight-meta {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.intel-insight-severity {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.intel-insight-type {
font-size: 11px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.intel-insight-title {
font-size: 14px;
font-weight: 600;
color: var(--text);
margin: 0 0 4px;
}
.intel-insight-context {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-dim);
}
.intel-insight-expand {
margin-left: auto;
font-size: 12px;
color: var(--text-dim);
}
.intel-insight-body {
padding: 0 16px 14px;
border-top: 1px solid var(--border-light);
padding-top: 12px;
margin-top: 4px;
}
.intel-insight-desc {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
margin: 0 0 10px;
}
.intel-insight-rec {
font-size: 13px;
color: var(--text);
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 6px;
padding: 10px 12px;
line-height: 1.5;
}
.intel-insight-rec strong {
color: #16a34a;
}
/* ─── Analysis View ──────────────────────────────────────── */
.intel-analysis-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.intel-analysis-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
.intel-analysis-top {
padding: 16px;
}
.intel-analysis-top:hover {
background: rgba(0,0,0,0.01);
}
.intel-analysis-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 14px;
}
.intel-analysis-header h4 {
font-size: 15px;
font-weight: 600;
color: var(--text);
margin: 0 0 2px;
}
.intel-analysis-expand {
font-size: 14px;
color: var(--text-dim);
padding: 4px;
}
.intel-analysis-kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.intel-analysis-kpi {
display: flex;
flex-direction: column;
}
.intel-analysis-budget {
margin-top: 4px;
}
.intel-analysis-channels {
padding: 0 16px 16px;
border-top: 1px solid var(--border-light);
padding-top: 14px;
}
.intel-analysis-channels h5 {
font-size: 13px;
font-weight: 600;
color: var(--text);
margin: 0 0 10px;
}
/* ─── Responsive ─────────────────────────────────────────── */
@media (max-width: 768px) {
.intel-kpi-grid { grid-template-columns: repeat(2, 1fr); }
.intel-channel-cards { grid-template-columns: 1fr; }
.intel-analysis-kpis { grid-template-columns: repeat(3, 1fr); }
}

View File

@@ -0,0 +1,465 @@
/* ── Modifiers Panel ────────────────────────────────────── */
.modifiers-panel {
max-width: 900px;
}
/* ── Toast ── */
.mod-toast {
position: fixed;
top: 16px;
right: 24px;
background: #059669;
color: #fff;
padding: 8px 20px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
animation: mod-toast-in 0.25s ease-out;
}
@keyframes mod-toast-in {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.mod-dismiss-btn {
margin-left: 12px;
cursor: pointer;
background: none;
border: none;
font-weight: 700;
font-size: 14px;
color: inherit;
}
/* ── Intro ── */
.mod-intro {
margin-bottom: 20px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.mod-intro p {
color: #64748b;
margin: 0;
font-size: 13px;
line-height: 1.5;
}
.mod-dirty-badge {
flex-shrink: 0;
background: #fef3c7;
color: #92400e;
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: 12px;
white-space: nowrap;
}
/* ── Factor Group Card ── */
.mod-group {
background: #fff;
border: 1px solid var(--border, #e2e8f0);
border-radius: 10px;
margin-bottom: 16px;
overflow: hidden;
}
.mod-group-header {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px 20px 12px;
border-bottom: 1px solid var(--border, #e2e8f0);
background: #fafbfc;
}
.mod-group-icon {
font-size: 20px;
flex-shrink: 0;
margin-top: 2px;
}
.mod-group-title {
margin: 0;
font-size: 15px;
font-weight: 700;
color: var(--text, #1e293b);
}
.mod-group-desc {
margin: 2px 0 0;
font-size: 12px;
color: var(--text-dim, #64748b);
}
/* ── Value Section (young, mature, local, etc.) ── */
.mod-value-section {
border-bottom: 1px solid #f1f5f9;
}
.mod-value-section:last-child {
border-bottom: none;
}
.mod-value-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px 4px;
}
.mod-value-icon {
font-size: 14px;
}
.mod-value-label {
font-size: 13px;
font-weight: 600;
color: var(--text, #1e293b);
flex: 1;
}
.mod-save-group-btn {
background: var(--accent, #4F46E5);
color: #fff;
border: none;
border-radius: 6px;
padding: 4px 12px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.mod-save-group-btn:hover { opacity: 0.85; }
.mod-save-group-btn:disabled { opacity: 0.5; cursor: default; }
/* ── Modifier Row ── */
.mod-slider-list {
padding: 2px 20px 14px;
}
.mod-row {
padding: 10px 0 6px;
border-bottom: 1px solid #f8fafc;
transition: opacity 0.2s;
}
.mod-row:last-child { border-bottom: none; }
.mod-row-disabled { opacity: 0.4; }
.mod-row-dirty {
background: #fffbeb;
margin: 0 -8px;
padding-left: 8px;
padding-right: 8px;
border-radius: 6px;
}
.mod-row-top {
display: flex;
align-items: center;
gap: 10px;
}
.mod-ch-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.mod-ch-name {
font-size: 13px;
font-weight: 500;
color: var(--text, #1e293b);
min-width: 90px;
}
.mod-empty {
padding: 12px 0;
color: #94a3b8;
font-size: 13px;
font-style: italic;
}
/* ── Slider ── */
.mod-slider-wrap {
flex: 1;
min-width: 180px;
display: flex;
align-items: center;
gap: 6px;
}
.mod-slider-tick {
font-size: 10px;
color: #94a3b8;
flex-shrink: 0;
width: 24px;
font-variant-numeric: tabular-nums;
}
.mod-slider-tick-neg { text-align: right; }
.mod-slider-tick-pos { text-align: left; }
.mod-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 3px;
background: linear-gradient(to right, #fecaca 0%, #f1f5f9 50%, #bbf7d0 100%);
outline: none;
cursor: pointer;
}
.mod-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--slider-color, #94a3b8);
border: 2px solid #fff;
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
cursor: grab;
transition: transform 0.1s;
}
.mod-slider::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
.mod-slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--slider-color, #94a3b8);
border: 2px solid #fff;
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
cursor: grab;
}
/* ── Percentage Badge ── */
.mod-pct-badge {
font-size: 13px;
font-weight: 700;
min-width: 48px;
text-align: center;
padding: 2px 6px;
border-radius: 4px;
font-variant-numeric: tabular-nums;
color: #64748b;
background: #f1f5f9;
}
.mod-pct-badge.pos { color: #059669; background: #ecfdf5; }
.mod-pct-badge.neg { color: #dc2626; background: #fef2f2; }
/* ── Toggle Switch ── */
.mod-toggle {
position: relative;
cursor: pointer;
flex-shrink: 0;
}
.mod-toggle input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.mod-toggle-track {
display: block;
width: 34px;
height: 18px;
border-radius: 9px;
background: #cbd5e1;
transition: background 0.2s;
position: relative;
}
.mod-toggle-track::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
transition: transform 0.2s;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.mod-toggle input:checked + .mod-toggle-track {
background: #4F46E5;
}
.mod-toggle input:checked + .mod-toggle-track::after {
transform: translateX(16px);
}
/* ── Save Button (per row) ── */
.mod-save-btn {
background: #4F46E5;
color: #fff;
border: none;
border-radius: 6px;
width: 28px;
height: 28px;
font-size: 14px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: opacity 0.15s;
}
.mod-save-btn:hover { opacity: 0.85; }
.mod-save-btn:disabled { opacity: 0.5; cursor: default; }
/* ── Rationale Row ── */
.mod-row-bottom {
margin-top: 4px;
padding-left: 20px;
}
.mod-rationale {
width: 100%;
border: 1px solid transparent;
border-radius: 4px;
padding: 4px 8px;
font-size: 11px;
color: #64748b;
background: transparent;
transition: border-color 0.15s, background 0.15s;
box-sizing: border-box;
}
.mod-rationale:hover {
background: #f8fafc;
}
.mod-rationale:focus {
outline: none;
border-color: #4F46E5;
background: #fff;
color: #1e293b;
}
.mod-rationale::placeholder {
color: #94a3b8;
}
/* ── Preview Section ── */
.mod-preview-controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 14px 20px;
align-items: flex-end;
}
.mod-preview-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.mod-preview-field label {
font-size: 11px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.mod-preview-field input,
.mod-preview-field select {
border: 1px solid var(--border, #e2e8f0);
border-radius: 6px;
padding: 6px 10px;
font-size: 13px;
min-width: 120px;
color: var(--text, #1e293b);
background: #fff;
}
.mod-preview-field input:focus,
.mod-preview-field select:focus {
outline: none;
border-color: #4F46E5;
}
.mod-preview-btn {
background: var(--text, #1e293b);
color: #fff;
border: none;
border-radius: 6px;
padding: 7px 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.mod-preview-btn:hover { opacity: 0.85; }
.mod-preview-btn:disabled { opacity: 0.5; cursor: default; }
/* ── Preview Result ── */
.mod-preview-result {
padding: 14px 20px;
border-top: 1px solid #f1f5f9;
}
.mod-preview-channels {
display: flex;
flex-direction: column;
gap: 2px;
}
.mod-preview-row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
font-size: 13px;
}
.mod-preview-header-row {
padding-bottom: 4px;
border-bottom: 1px solid #f1f5f9;
margin-bottom: 2px;
}
.mod-preview-ch-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.mod-preview-ch-dot-spacer {
width: 10px;
flex-shrink: 0;
}
.mod-preview-ch-name {
font-weight: 500;
min-width: 90px;
flex: 1;
}
.mod-preview-ch-val {
min-width: 40px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.mod-preview-ch-arrow {
color: #94a3b8;
font-size: 12px;
width: 16px;
text-align: center;
}
.mod-preview-ch-diff {
font-size: 11px;
font-weight: 600;
padding: 1px 8px;
border-radius: 4px;
min-width: 32px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.mod-preview-ch-diff.pos {
color: #059669;
background: #ecfdf5;
}
.mod-preview-ch-diff.neg {
color: #dc2626;
background: #fef2f2;
}
.mod-preview-ch-diff-spacer {
min-width: 32px;
}
/* ── Responsive ── */
@media (max-width: 768px) {
.mod-row-top {
flex-wrap: wrap;
}
.mod-slider-wrap {
min-width: 100%;
order: 10;
margin-top: 4px;
}
.mod-preview-controls {
flex-direction: column;
}
.mod-preview-field input,
.mod-preview-field select {
min-width: 100%;
}
}

View File

@@ -0,0 +1,632 @@
/* ============================================================
Templates Panel — Sidebar + Grouped Card Layout
Append to app.css or import separately
============================================================ */
/* ─── Layout ─────────────────────────────────────────────── */
.templates-layout {
display: flex;
gap: 0;
min-height: calc(100vh - var(--header-height) - 100px);
}
.templates-sidebar {
width: 240px;
min-width: 240px;
background: var(--surface);
border-right: 1px solid var(--border);
border-radius: 8px 0 0 8px;
padding: 0;
overflow-y: auto;
max-height: calc(100vh - var(--header-height) - 100px);
}
.templates-content {
flex: 1;
min-width: 0;
padding: 20px 24px;
}
/* ─── Sidebar Sections ───────────────────────────────────── */
.sidebar-section {
padding: 12px 0;
border-bottom: 1px solid var(--border-light);
}
.sidebar-section:last-child {
border-bottom: none;
}
.sidebar-section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 14px 6px;
}
.sidebar-section-title {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
}
/* ─── Sidebar Items ──────────────────────────────────────── */
.sidebar-list {
display: flex;
flex-direction: column;
gap: 1px;
}
.sidebar-list-scrollable {
max-height: 280px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.sidebar-list-scrollable::-webkit-scrollbar {
width: 4px;
}
.sidebar-list-scrollable::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
.sidebar-list-scrollable::-webkit-scrollbar-track {
background: transparent;
}
/* Add button pinned below the scrollable list */
.sidebar-section > .sidebar-add-btn,
.sidebar-section > .sidebar-new-item {
margin: 4px 6px 0;
}
.sidebar-item-wrapper {
display: flex;
align-items: center;
position: relative;
}
.sidebar-item-wrapper .btn-icon-xs {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
opacity: 0;
transition: opacity 0.15s;
}
.sidebar-item-wrapper:hover .btn-icon-xs {
opacity: 1;
}
.sidebar-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 14px;
border: none;
background: none;
cursor: pointer;
font-size: 13px;
color: var(--text);
text-align: left;
font-family: inherit;
transition: background 0.12s;
border-radius: 0;
}
.sidebar-item:hover {
background: var(--sidebar-hover);
}
.sidebar-item.active {
background: var(--sidebar-active);
color: var(--accent);
font-weight: 600;
}
.sidebar-item-static {
cursor: default;
}
.sidebar-item-static:hover {
background: none;
}
.sidebar-item-icon {
font-size: 16px;
width: 22px;
text-align: center;
flex-shrink: 0;
}
.sidebar-item-label {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-item-count {
font-size: 11px;
color: var(--text-dim);
background: var(--bg);
padding: 1px 7px;
border-radius: 10px;
font-weight: 500;
flex-shrink: 0;
}
.sidebar-color-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
margin-left: 6px;
}
.sidebar-add-btn {
color: var(--accent);
font-style: italic;
}
.sidebar-new-item {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 14px;
}
.sidebar-new-item input {
flex: 1;
font-size: 12px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 4px;
font-family: inherit;
background: var(--bg);
}
/* ─── Icon & Color Pickers ───────────────────────────────── */
.icon-picker-btn {
font-size: 16px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 5px;
cursor: pointer;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition: border-color 0.15s;
}
.icon-picker-btn:hover {
border-color: var(--accent);
}
.icon-picker-popover {
position: absolute;
left: 0;
z-index: 999;
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0,0,0,0.15), 0 1px 4px rgba(0,0,0,0.08);
overflow: hidden;
animation: iconPickerIn 0.12s ease-out;
}
.icon-picker-popover em-emoji-picker {
--em-rgb-background: var(--surface-rgb, 255, 255, 255);
--em-rgb-input: 241, 245, 249;
border: none !important;
}
@keyframes iconPickerIn {
from { opacity: 0; transform: translateY(-3px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.color-picker-btn {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid var(--border);
cursor: pointer;
padding: 0;
transition: border-color 0.15s;
margin-left: 6px;
flex-shrink: 0;
}
.color-picker-btn:hover {
border-color: var(--accent);
}
.color-picker-grid {
position: absolute;
left: 0;
top: 24px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 6px;
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
z-index: 99;
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
}
.color-picker-option {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
padding: 0;
transition: border-color 0.15s, transform 0.1s;
}
.color-picker-option:hover {
transform: scale(1.15);
}
.color-picker-option.active {
border-color: var(--text);
}
/* ─── Small Button Variants ──────────────────────────────── */
.btn-icon-sm {
width: 24px;
height: 24px;
border: 1px solid var(--border);
background: var(--bg);
border-radius: 4px;
cursor: pointer;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
transition: all 0.12s;
padding: 0;
font-family: inherit;
}
.btn-icon-sm:hover {
background: var(--accent-light);
color: var(--accent);
border-color: var(--accent);
}
.btn-icon-xs {
width: 20px;
height: 20px;
border: none;
background: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
transition: all 0.12s;
padding: 0;
font-family: inherit;
}
.btn-icon-xs:hover {
background: var(--danger-bg);
color: var(--danger);
}
.btn-icon-xs:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.btn-danger-icon:hover {
background: var(--danger-bg);
color: var(--danger);
}
/* ─── Template Groups ────────────────────────────────────── */
.template-group {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 16px;
overflow: hidden;
}
.template-group-header {
padding: 16px 20px;
background: var(--bg);
border-bottom: 1px solid var(--border-light);
}
.template-group-info {
display: flex;
align-items: flex-start;
gap: 20px;
}
.template-group-meta {
flex: 1;
min-width: 0;
}
.template-group-title {
font-size: 15px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.cat-icon {
font-size: 18px;
}
.objective-tag,
.header-objective-tag {
display: inline-block;
font-size: 11px;
font-weight: 600;
color: #fff;
padding: 2px 10px;
border-radius: 10px;
margin-left: 6px;
}
.template-group-stats {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 10px;
}
.text-success { color: var(--success); font-weight: 600; }
.text-danger { color: var(--danger); font-weight: 600; }
.text-dim { color: var(--text-dim); }
/* ─── Channel Bars (mini horizontal bars) ────────────────── */
.template-group-channel-bars {
display: flex;
flex-direction: column;
gap: 4px;
}
.channel-bar {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.channel-bar-color {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.channel-bar-label {
width: 80px;
flex-shrink: 0;
color: var(--text-secondary);
}
.channel-bar-pct {
width: 36px;
text-align: right;
font-weight: 600;
flex-shrink: 0;
}
.channel-bar-track {
flex: 1;
height: 6px;
background: var(--border-light);
border-radius: 3px;
overflow: hidden;
max-width: 200px;
}
.channel-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
}
/* ─── Template Group Table ───────────────────────────────── */
.template-group .data-table {
margin: 0;
border: none;
border-radius: 0;
}
.template-group .data-table thead th {
background: var(--surface);
}
/* ─── Channel Legend Bar (top of content) ───────────────── */
.channel-legend-bar {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.channel-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 20px;
border-left-width: 3px;
}
.channel-chip-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
/* ─── Add Channel Button (per group) ────────────────────── */
.btn-add-channel {
display: block;
margin: 8px 0 4px;
padding: 6px 12px;
font-size: 12px;
color: var(--accent);
background: var(--surface);
border: 1px dashed var(--border);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
}
.btn-add-channel:hover {
background: var(--accent-light, #f0f7ff);
border-color: var(--accent);
}
/* ─── Empty State ────────────────────────────────────────── */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.empty-state-icon {
font-size: 48px;
margin-bottom: 12px;
}
/* ─── Responsive ─────────────────────────────────────────── */
@media (max-width: 1024px) {
.templates-sidebar {
width: 200px;
min-width: 200px;
}
}
@media (max-width: 768px) {
.templates-layout {
flex-direction: column;
}
.templates-sidebar {
width: 100%;
min-width: unset;
max-height: 260px;
border-right: none;
border-bottom: 1px solid var(--border);
border-radius: 8px 8px 0 0;
}
.template-group-info {
flex-direction: column;
align-items: center;
}
}
/* ── Forecast Validation ── */
.btn-validate {
background: none;
border: 1px solid var(--border, #d1d5db);
color: var(--text-primary, #374151);
padding: 4px 12px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.btn-validate:hover:not(:disabled) {
background: var(--bg-hover, #f3f4f6);
border-color: #8B5CF6;
color: #8B5CF6;
}
.btn-validate:disabled {
opacity: 0.6;
cursor: wait;
}
.validation-results {
background: #fafafa;
border: 1px solid var(--border, #e5e7eb);
border-radius: 8px;
padding: 12px 16px;
margin: 8px 0 12px;
font-size: 13px;
}
.validation-results.validation-error {
border-color: #fca5a5;
background: #fef2f2;
}
.validation-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
font-weight: 600;
font-size: 13px;
}
.validation-comparison {
display: flex;
flex-direction: column;
gap: 6px;
}
.validation-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.validation-ch {
min-width: 80px;
font-size: 12px;
}
.validation-template {
min-width: 36px;
text-align: right;
font-weight: 500;
color: var(--text-dim, #6b7280);
}
.validation-arrow {
color: var(--text-dim, #9ca3af);
font-size: 11px;
}
.validation-api {
min-width: 36px;
text-align: right;
font-weight: 600;
}
.validation-api.validation-divergent {
color: #d97706;
}
.validation-diff {
font-size: 11px;
font-weight: 600;
min-width: 40px;
}
.validation-error-msg {
color: #dc2626;
font-size: 13px;
}