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
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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"presets": [
|
||||
"@babel/preset-env",
|
||||
["@babel/preset-react", { "runtime": "automatic" }]
|
||||
]
|
||||
}
|
||||
"presets": [
|
||||
"@babel/preset-env",
|
||||
["@babel/preset-react", { "runtime": "automatic" }]
|
||||
]
|
||||
}
|
||||
2
Client-Admin/dist/bundle.js
vendored
2
Client-Admin/dist/bundle.js
vendored
File diff suppressed because one or more lines are too long
2
Client-Admin/dist/index.html
vendored
2
Client-Admin/dist/index.html
vendored
@@ -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>
|
||||
7302
Client-Admin/package-lock.json
generated
7302
Client-Admin/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
143
Client-Admin/src/components/HelpIcon.jsx
Normal file
143
Client-Admin/src/components/HelpIcon.jsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
170
Client-Admin/src/components/Sidebar.jsx
Normal file
170
Client-Admin/src/components/Sidebar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
389
Client-Admin/src/components/admin/CampaignsPanel.jsx
Normal file
389
Client-Admin/src/components/admin/CampaignsPanel.jsx
Normal 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;
|
||||
}
|
||||
341
Client-Admin/src/components/admin/ClientActivityPanel.jsx
Normal file
341
Client-Admin/src/components/admin/ClientActivityPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
396
Client-Admin/src/components/admin/ClientDocumentsPanel.jsx
Normal file
396
Client-Admin/src/components/admin/ClientDocumentsPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
762
Client-Admin/src/components/admin/ClientManagementPanel.jsx
Normal file
762
Client-Admin/src/components/admin/ClientManagementPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
159
Client-Admin/src/components/admin/ClientUsersPanel.jsx
Normal file
159
Client-Admin/src/components/admin/ClientUsersPanel.jsx
Normal 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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
425
Client-Admin/src/components/admin/DocumentsPanel.jsx
Normal file
425
Client-Admin/src/components/admin/DocumentsPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
345
Client-Admin/src/components/admin/HelpPanel.jsx
Normal file
345
Client-Admin/src/components/admin/HelpPanel.jsx
Normal 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">
|
||||
<p> <ul> <li> <strong> <h4> 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>
|
||||
);
|
||||
}
|
||||
702
Client-Admin/src/components/admin/IntelligencePanel.jsx
Normal file
702
Client-Admin/src/components/admin/IntelligencePanel.jsx
Normal 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 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
382
Client-Admin/src/components/admin/ModifiersPanel.jsx
Normal file
382
Client-Admin/src/components/admin/ModifiersPanel.jsx
Normal 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 (18–34)', 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>
|
||||
);
|
||||
});
|
||||
931
Client-Admin/src/components/admin/ObjectiveMappingPanel.jsx
Normal file
931
Client-Admin/src/components/admin/ObjectiveMappingPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
658
Client-Admin/src/components/admin/TemplatesPanel.jsx
Normal file
658
Client-Admin/src/components/admin/TemplatesPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
263
Client-Admin/src/context/AdminContext.jsx
Normal file
263
Client-Admin/src/context/AdminContext.jsx
Normal 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>;
|
||||
}
|
||||
273
Client-Admin/src/context/ObjectiveMappingsContext.jsx
Normal file
273
Client-Admin/src/context/ObjectiveMappingsContext.jsx
Normal 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,
|
||||
}));
|
||||
}
|
||||
244
Client-Admin/src/context/TemplatesContext.jsx
Normal file
244
Client-Admin/src/context/TemplatesContext.jsx
Normal 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>;
|
||||
}
|
||||
7
Client-Admin/src/context/index.js
Normal file
7
Client-Admin/src/context/index.js
Normal 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';
|
||||
@@ -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
389
Client-Admin/src/styles/intelligence-panel.css
Normal file
389
Client-Admin/src/styles/intelligence-panel.css
Normal 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); }
|
||||
}
|
||||
465
Client-Admin/src/styles/modifiers-panel.css
Normal file
465
Client-Admin/src/styles/modifiers-panel.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
632
Client-Admin/src/styles/templates-panel.css
Normal file
632
Client-Admin/src/styles/templates-panel.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user