Standardize admin tables and polish shell UX

This commit is contained in:
Ashwin Kumar 2026-03-30 04:48:09 +02:00
parent d7bf5a6d56
commit 8ed523d80c
44 changed files with 5671 additions and 2346 deletions

View file

@ -5,6 +5,11 @@
"dev": "vinxi dev",
"build": "vinxi build",
"start": "vinxi start",
"start:3000": "HOST=0.0.0.0 PORT=3000 node .output/server/index.mjs",
"admin:restart:3000": "bash ./scripts/admin-3000-service.sh restart",
"admin:stop:3000": "bash ./scripts/admin-3000-service.sh stop",
"admin:status:3000": "bash ./scripts/admin-3000-service.sh status",
"admin:start:3000": "bash ./scripts/admin-3000-service.sh start",
"test": "node --test --experimental-strip-types src/lib/**/*.test.ts",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",

24
scripts/admin-3000-daemon.sh Executable file
View file

@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -u
ROOT_DIR="/Users/ashwin/workspace/nxtgauge-admin-solid"
APP_LOG="/tmp/nxtgauge-admin-3000.log"
cd "$ROOT_DIR" || exit 1
echo "[$(date '+%Y-%m-%d %H:%M:%S')] admin-3000 daemon started" >> "$APP_LOG"
while true; do
if [[ ! -f ".output/server/index.mjs" ]]; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] build output missing, running build..." >> "$APP_LOG"
npm run build >> "$APP_LOG" 2>&1
fi
echo "[$(date '+%Y-%m-%d %H:%M:%S')] launching admin on 0.0.0.0:3000" >> "$APP_LOG"
HOST=0.0.0.0 PORT=3000 node .output/server/index.mjs >> "$APP_LOG" 2>&1
code=$?
echo "[$(date '+%Y-%m-%d %H:%M:%S')] admin exited with code ${code}, restarting in 2s" >> "$APP_LOG"
sleep 2
done

95
scripts/admin-3000-service.sh Executable file
View file

@ -0,0 +1,95 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="/Users/ashwin/workspace/nxtgauge-admin-solid"
PID_FILE="/tmp/nxtgauge-admin-3000.pid"
APP_LOG="/tmp/nxtgauge-admin-3000.log"
APP_URL="http://127.0.0.1:3000/admin/external-dashboard-management"
kill_listeners() {
lsof -tiTCP:3000 -sTCP:LISTEN | xargs -r kill -9 || true
}
start() {
if [[ -f "$PID_FILE" ]]; then
old_pid="$(cat "$PID_FILE" || true)"
if [[ -n "${old_pid:-}" ]] && kill -0 "$old_pid" 2>/dev/null; then
if curl -fsS -m 3 "$APP_URL" >/dev/null 2>&1; then
echo "admin-3000 already running (pid $old_pid)"
echo "url: $APP_URL"
return 0
fi
kill -9 "$old_pid" || true
fi
rm -f "$PID_FILE"
fi
pkill -f "admin-3000-daemon.sh" || true
pkill -f ".output/server/index.mjs" || true
kill_listeners
nohup /bin/zsh -lc "cd '$ROOT_DIR' && HOST=0.0.0.0 PORT=3000 node .output/server/index.mjs" >>"$APP_LOG" 2>&1 &
new_pid=$!
echo "$new_pid" > "$PID_FILE"
for _ in {1..30}; do
if curl -fsS -m 3 "$APP_URL" >/dev/null 2>&1; then
echo "admin-3000: running (pid $new_pid)"
echo "url: $APP_URL"
return 0
fi
sleep 1
done
echo "admin-3000 failed to become healthy"
tail -n 80 /tmp/nxtgauge-admin-3000.log || true
exit 1
}
stop() {
if [[ -f "$PID_FILE" ]]; then
pid="$(cat "$PID_FILE" || true)"
if [[ -n "${pid:-}" ]] && kill -0 "$pid" 2>/dev/null; then
kill -9 "$pid" || true
fi
rm -f "$PID_FILE"
fi
pkill -f "admin-3000-daemon.sh|.output/server/index.mjs" || true
kill_listeners
echo "admin-3000: stopped"
}
status() {
app_state="stopped"
app_pid=""
if [[ -f "$PID_FILE" ]]; then
app_pid="$(cat "$PID_FILE" || true)"
if [[ -n "${app_pid:-}" ]] && kill -0 "$app_pid" 2>/dev/null; then
app_state="running"
fi
fi
if lsof -nP -iTCP:3000 -sTCP:LISTEN >/dev/null 2>&1; then
echo "admin-3000: running"
echo "process: $app_state${app_pid:+ (pid $app_pid)}"
lsof -nP -iTCP:3000 -sTCP:LISTEN
exit 0
fi
echo "admin-3000: stopped"
echo "process: $app_state${app_pid:+ (pid $app_pid)}"
exit 1
}
action="${1:-restart}"
case "$action" in
start) start ;;
stop) stop ;;
restart) stop; start ;;
status) status ;;
*)
echo "Usage: $0 [start|stop|restart|status]"
exit 2
;;
esac

75
scripts/restart-admin-3000.sh Executable file
View file

@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="/Users/ashwin/workspace/nxtgauge-admin-solid"
PID_FILE="/tmp/nxtgauge-admin-3000.pid"
LOG_FILE="/tmp/nxtgauge-admin-3000.log"
APP_URL="http://127.0.0.1:3000/admin/external-dashboard-management"
stop_server() {
if [[ -f "$PID_FILE" ]]; then
pid="$(cat "$PID_FILE" || true)"
if [[ -n "${pid:-}" ]] && kill -0 "$pid" 2>/dev/null; then
kill -9 "$pid" || true
fi
rm -f "$PID_FILE"
fi
lsof -tiTCP:3000 -sTCP:LISTEN | xargs -r kill -9 || true
}
status_server() {
if lsof -nP -iTCP:3000 -sTCP:LISTEN >/dev/null 2>&1; then
echo "admin-3000: running"
lsof -nP -iTCP:3000 -sTCP:LISTEN
exit 0
fi
echo "admin-3000: stopped"
exit 1
}
start_server() {
cd "$ROOT_DIR"
if [[ ! -f ".output/server/index.mjs" ]]; then
echo "Build output missing. Running build..."
npm run build
fi
nohup env HOST=127.0.0.1 PORT=3000 node .output/server/index.mjs >"$LOG_FILE" 2>&1 &
new_pid=$!
echo "$new_pid" > "$PID_FILE"
for _ in {1..20}; do
if curl -fsS -m 3 "$APP_URL" >/dev/null 2>&1; then
echo "admin-3000: running (pid $new_pid)"
echo "url: $APP_URL"
exit 0
fi
sleep 1
done
echo "admin-3000: failed to start"
echo "Last log lines:"
tail -n 40 "$LOG_FILE" || true
exit 1
}
action="${1:-restart}"
case "$action" in
restart)
stop_server
start_server
;;
stop)
stop_server
echo "admin-3000: stopped"
;;
status)
status_server
;;
*)
echo "Usage: $0 [restart|stop|status]"
exit 2
;;
esac

View file

@ -466,3 +466,128 @@ body {
padding: 10px 11px;
}
}
/* ===== Dark Theme Coverage ===== */
html[data-theme='dark'] body {
background: #0b1220;
color: #e5e7eb;
}
html[data-theme='dark'] .table-card {
background: #0f172a;
border-color: #243041;
box-shadow: 0 1px 8px rgba(0, 0, 0, 0.35);
}
html[data-theme='dark'] .data-table thead th {
background: #111827;
color: #e5e7eb;
}
html[data-theme='dark'] .data-table tbody td {
color: #d1d5db;
border-bottom-color: #243041;
}
html[data-theme='dark'] .data-table tbody tr:hover td {
background: #111a2f;
}
html[data-theme='dark'] .data-table-empty {
color: #94a3b8;
}
html[data-theme='dark'] .admin-main table.w-full thead th {
background: #111827;
color: #e5e7eb;
}
html[data-theme='dark'] .admin-main table.w-full tbody td {
color: #d1d5db;
border-bottom-color: #243041;
}
html[data-theme='dark'] .admin-main table.w-full tbody tr:hover td {
background: #111a2f;
}
html[data-theme='dark'] .tab-bar {
border-bottom-color: #243041;
}
html[data-theme='dark'] .tab-link {
color: #9ca3af;
}
html[data-theme='dark'] .tab-link:hover {
color: #e5e7eb;
}
html[data-theme='dark'] .tab-link[aria-current='page'] {
color: #ff5e13;
border-bottom-color: #ff5e13;
}
html[data-theme='dark'] .admin-link-tabs {
border-color: #243041;
background: #0f172a;
}
html[data-theme='dark'] .admin-link-tabs a {
color: #9ca3af;
}
html[data-theme='dark'] .admin-link-tabs a[aria-current='page'] {
background: rgba(255, 94, 19, 0.16);
color: #ff8a52;
}
html[data-theme='dark'] .preview-tabs button {
border-color: #243041;
background: #0f172a;
color: #cbd5e1;
}
html[data-theme='dark'] .admin-main input[type='text'],
html[data-theme='dark'] .admin-main input[type='search'],
html[data-theme='dark'] .admin-main input[type='number'],
html[data-theme='dark'] .admin-main input[type='email'],
html[data-theme='dark'] .admin-main input[type='url'],
html[data-theme='dark'] .admin-main input[type='password'],
html[data-theme='dark'] .admin-main select,
html[data-theme='dark'] .admin-main textarea {
background: #0f172a;
color: #e5e7eb;
border-color: #334155;
}
html[data-theme='dark'] .admin-main input::placeholder,
html[data-theme='dark'] .admin-main textarea::placeholder {
color: #94a3b8;
}
html[data-theme='dark'] .btn-secondary,
html[data-theme='dark'] .action-btn {
background: #111827;
border-color: #334155;
color: #d1d5db;
}
html[data-theme='dark'] .btn-secondary:hover,
html[data-theme='dark'] .action-btn:hover {
background: #1f2937;
border-color: #475569;
}
html[data-theme='dark'] .admin-main div[style*='border-bottom:1px solid #E5E7EB'] {
border-bottom-color: #243041 !important;
}
html[data-theme='dark'] .admin-main button[style*='padding-bottom:12px'][style*='font-size:14px'] {
color: #94a3b8 !important;
}
html[data-theme='dark'] .admin-main button[style*='padding-bottom:12px'][style*='border-bottom:2px solid #FF5E13'] {
color: #ff8a52 !important;
border-bottom-color: #ff5e13 !important;
}

View file

@ -3,10 +3,11 @@ import {
For, Show, createEffect, createMemo, createSignal,
onCleanup, onMount, type JSX,
} from 'solid-js';
import { Bell, Search, Settings, User } from 'lucide-solid';
import { Bell, Moon, Search, Settings, Sun, User } from 'lucide-solid';
import AdminSidebar from './AdminSidebar';
import { isExternalIdentity } from '~/lib/admin-auth';
import { clearAdminSession, hasAdminSession, setAdminSession } from '~/lib/admin-session';
import { normalizeAllowedModules } from '~/lib/admin/module-access';
type Tab = { href: string; label: string; exact?: boolean };
type SearchResult = { id: string; title: string; subtitle: string; href: string };
@ -27,6 +28,8 @@ const PAGE_TITLES: Array<{ prefix: string; label: string; exact?: boolean }> = [
{ prefix: '/admin/verification', label: 'Verification Management' },
{ prefix: '/admin/verification-status', label: 'Verification Management' },
{ prefix: '/admin/approval', label: 'Approval Management' },
{ prefix: '/admin/approvals', label: 'Approval Management' },
{ prefix: '/admin/approval-management', label: 'Approval Management' },
{ prefix: '/admin/users', label: 'Users Management' },
{ prefix: '/admin/company', label: 'Company Management' },
{ prefix: '/admin/candidate', label: 'Candidate Management' },
@ -42,6 +45,8 @@ const PAGE_TITLES: Array<{ prefix: string; label: string; exact?: boolean }> = [
{ prefix: '/admin/social-media-managers', label: 'Social Media Manager Management' },
{ prefix: '/admin/jobs', label: 'Jobs Management' },
{ prefix: '/admin/leads', label: 'Leads Management' },
{ prefix: '/admin/applications', label: 'Applications Management' },
{ prefix: '/admin/responses', label: 'Responses Management' },
{ prefix: '/admin/pricing', label: 'Pricing Management' },
{ prefix: '/admin/credit', label: 'Credit Management' },
{ prefix: '/admin/coupon', label: 'Coupon Management' },
@ -49,6 +54,8 @@ const PAGE_TITLES: Array<{ prefix: string; label: string; exact?: boolean }> = [
{ prefix: '/admin/tax', label: 'Tax Management' },
{ prefix: '/admin/order', label: 'Order Management' },
{ prefix: '/admin/invoice', label: 'Invoice Management' },
{ prefix: '/admin/kb', label: 'Knowledge Base Management' },
{ prefix: '/admin/notifications', label: 'Notifications' },
{ prefix: '/admin/review', label: 'Review Management' },
{ prefix: '/admin/support', label: 'Support Management' },
{ prefix: '/admin/report', label: 'Report Management' },
@ -57,19 +64,24 @@ const PAGE_TITLES: Array<{ prefix: string; label: string; exact?: boolean }> = [
const TAB_SETS: Array<{ prefixes: string[]; tabs: Tab[] }> = [];
const ROUTE_MODULE_KEYS: Array<{ prefix: string; keys: string[] }> = [
{ prefix: '/admin', keys: ['ADMIN_DASHBOARD', 'DASHBOARD'] },
{ prefix: '/admin/department', keys: ['DEPARTMENT_MANAGEMENT', 'DEPARTMENTS'] },
{ prefix: '/admin/department-management', keys: ['DEPARTMENT_MANAGEMENT', 'DEPARTMENTS'] },
{ prefix: '/admin/designation', keys: ['DESIGNATION_MANAGEMENT', 'DESIGNATIONS'] },
{ prefix: '/admin/designation-management', keys: ['DESIGNATION_MANAGEMENT', 'DESIGNATIONS'] },
{ prefix: '/admin/roles', keys: ['INTERNAL_ROLE_MANAGEMENT', 'ROLES'] },
{ prefix: '/admin/employees', keys: ['EMPLOYEE_MANAGEMENT', 'EMPLOYEES'] },
{ prefix: '/admin/external-roles', keys: ['EXTERNAL_ROLE_MANAGEMENT', 'EXTERNAL_ROLES'] },
{ prefix: '/admin/onboarding-management', keys: ['ONBOARDING_MANAGEMENT', 'ONBOARDING_SCHEMAS', 'ONBOARDING'] },
{ prefix: '/admin/onboarding-schemas', keys: ['ONBOARDING_MANAGEMENT', 'ONBOARDING_SCHEMAS', 'ONBOARDING'] },
{ prefix: '/admin/internal-dashboard-management', keys: ['INTERNAL_DASHBOARD_MANAGEMENT', 'INTERNAL_DASHBOARDS'] },
{ prefix: '/admin/external-dashboard-management', keys: ['DASHBOARD_CONFIG_MANAGEMENT', 'EXTERNAL_DASHBOARD_MANAGEMENT', 'EXTERNAL_DASHBOARDS', 'RUNTIME_ROLES'] },
{ prefix: '/admin/role-ui-configs', keys: ['DASHBOARD_CONFIG_MANAGEMENT', 'EXTERNAL_DASHBOARD_MANAGEMENT', 'EXTERNAL_DASHBOARDS', 'RUNTIME_ROLES'] },
{ prefix: '/admin/onboarding-management', keys: ['EXTERNAL_ONBOARDING_MANAGEMENT', 'ONBOARDING_MANAGEMENT', 'ONBOARDING_SCHEMAS', 'ONBOARDING'] },
{ prefix: '/admin/onboarding-schemas', keys: ['EXTERNAL_ONBOARDING_MANAGEMENT', 'ONBOARDING_MANAGEMENT', 'ONBOARDING_SCHEMAS', 'ONBOARDING'] },
{ prefix: '/admin/internal-dashboard-management', keys: ['INTERNAL_DASHBOARD_MANAGEMENT', 'INTERNAL_DASHBOARDS', 'INTERNAL_DASHBOARD_CONFIG'] },
{ prefix: '/admin/external-dashboard-management', keys: ['DASHBOARD_CONFIG_MANAGEMENT', 'EXTERNAL_DASHBOARD_MANAGEMENT', 'EXTERNAL_DASHBOARDS', 'EXTERNAL_DASHBOARD_CONFIG', 'RUNTIME_ROLES'] },
{ prefix: '/admin/role-ui-configs', keys: ['DASHBOARD_CONFIG_MANAGEMENT', 'EXTERNAL_DASHBOARD_MANAGEMENT', 'EXTERNAL_DASHBOARDS', 'EXTERNAL_DASHBOARD_CONFIG', 'RUNTIME_ROLES'] },
{ prefix: '/admin/verification', keys: ['VERIFICATION_MANAGEMENT', 'VERIFICATIONS'] },
{ prefix: '/admin/verification-status', keys: ['VERIFICATION_MANAGEMENT', 'VERIFICATIONS'] },
{ prefix: '/admin/approval', keys: ['APPROVAL_MANAGEMENT', 'APPROVALS'] },
{ prefix: '/admin/approvals', keys: ['APPROVAL_MANAGEMENT', 'APPROVALS'] },
{ prefix: '/admin/approval-management', keys: ['APPROVAL_MANAGEMENT', 'APPROVALS'] },
{ prefix: '/admin/users', keys: ['USER_MANAGEMENT', 'USERS'] },
{ prefix: '/admin/company', keys: ['COMPANY_MANAGEMENT', 'COMPANIES'] },
{ prefix: '/admin/candidate', keys: ['CANDIDATE_MANAGEMENT', 'CANDIDATES'] },
@ -85,6 +97,8 @@ const ROUTE_MODULE_KEYS: Array<{ prefix: string; keys: string[] }> = [
{ prefix: '/admin/social-media-managers', keys: ['SOCIAL_MEDIA_MANAGEMENT', 'SOCIAL_MEDIA_MANAGER_MANAGEMENT', 'SOCIAL_MEDIA_MANAGERS'] },
{ prefix: '/admin/jobs', keys: ['JOBS_MANAGEMENT', 'JOBS'] },
{ prefix: '/admin/leads', keys: ['LEADS_MANAGEMENT', 'LEADS', 'REQUIREMENTS_MANAGEMENT', 'REQUIREMENTS'] },
{ prefix: '/admin/applications', keys: ['APPLICATIONS_MANAGEMENT', 'APPLICATIONS'] },
{ prefix: '/admin/responses', keys: ['RESPONSES_MANAGEMENT', 'RESPONSES'] },
{ prefix: '/admin/pricing', keys: ['PRICING_MANAGEMENT', 'PRICING'] },
{ prefix: '/admin/credit', keys: ['CREDIT_MANAGEMENT', 'CREDITS'] },
{ prefix: '/admin/coupon', keys: ['COUPON_MANAGEMENT', 'COUPONS'] },
@ -92,6 +106,8 @@ const ROUTE_MODULE_KEYS: Array<{ prefix: string; keys: string[] }> = [
{ prefix: '/admin/tax', keys: ['TAX_MANAGEMENT', 'TAXES'] },
{ prefix: '/admin/order', keys: ['ORDER_MANAGEMENT', 'ORDERS'] },
{ prefix: '/admin/invoice', keys: ['INVOICE_MANAGEMENT', 'INVOICES'] },
{ prefix: '/admin/kb', keys: ['KNOWLEDGE_BASE_MANAGEMENT', 'KNOWLEDGE_BASE', 'KB'] },
{ prefix: '/admin/notifications', keys: ['NOTIFICATIONS_MANAGEMENT', 'NOTIFICATIONS'] },
{ prefix: '/admin/review', keys: ['REVIEW_MANAGEMENT', 'REVIEWS'] },
{ prefix: '/admin/support', keys: ['SUPPORT_MANAGEMENT', 'SUPPORT'] },
{ prefix: '/admin/report', keys: ['REPORT_MANAGEMENT', 'REPORTS'] },
@ -303,10 +319,37 @@ export default function AdminShell(props: { children: JSX.Element }) {
const [sidebarOpen, setSidebarOpen] = createSignal(false);
const [sidebarCollapsed, setSidebarCollapsed] = createSignal(false);
const [notifCount] = createSignal(0);
const [theme, setTheme] = createSignal<'light' | 'dark'>('light');
const [routeTransitioning, setRouteTransitioning] = createSignal(false);
const [tabsTrackEl, setTabsTrackEl] = createSignal<HTMLDivElement>();
const [tabRefs, setTabRefs] = createSignal<Record<string, HTMLAnchorElement>>({});
const [tabIndicator, setTabIndicator] = createSignal({ left: 0, width: 0, ready: false });
let contentScrollRef: HTMLDivElement | undefined;
const logout = async () => {
try {
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
await fetch('/api/gateway/auth/logout', {
method: 'POST',
headers: {
Accept: 'application/json',
'x-portal-target': 'admin',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
}).catch(() => null);
} finally {
if (typeof sessionStorage !== 'undefined') {
sessionStorage.removeItem('nxtgauge_admin_access_token');
sessionStorage.removeItem('nxtgauge_admin_preview');
}
clearAdminSession();
navigate('/login', { replace: true });
}
};
const tabs = createMemo<Tab[]>(() => {
const path = location.pathname;
@ -336,16 +379,40 @@ export default function AdminShell(props: { children: JSX.Element }) {
requestAnimationFrame(refreshTabIndicator);
});
createEffect(() => {
location.pathname;
setRouteTransitioning(true);
requestAnimationFrame(() => {
requestAnimationFrame(() => setRouteTransitioning(false));
});
if (!contentScrollRef) return;
const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false;
contentScrollRef.scrollTo({
top: 0,
behavior: prefersReducedMotion ? 'auto' : 'smooth',
});
});
onMount(() => {
const savedTheme = (typeof localStorage !== 'undefined'
? localStorage.getItem('nxtgauge_admin_theme')
: null) as 'light' | 'dark' | null;
const nextTheme = savedTheme === 'dark' ? 'dark' : 'light';
setTheme(nextTheme);
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', nextTheme);
}
window.addEventListener('resize', refreshTabIndicator);
onCleanup(() => window.removeEventListener('resize', refreshTabIndicator));
const isLocalDev = typeof window !== 'undefined' &&
(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
const isPreview = searchParams._preview === '1' ||
(typeof sessionStorage !== 'undefined' && sessionStorage.getItem('nxtgauge_admin_preview') === '1');
if (isPreview || isLocalDev) {
if (isPreview) {
if (typeof sessionStorage !== 'undefined') sessionStorage.setItem('nxtgauge_admin_preview', '1');
setAdminSession();
setCheckedSession(true);
@ -397,19 +464,7 @@ export default function AdminShell(props: { children: JSX.Element }) {
});
const runtime = await res.json().catch(() => ({}));
if (res.ok) {
const mods = (
runtime?.enabled_modules
|| runtime?.enabledModules
|| runtime?.modules
|| runtime?.config_json?.modules
|| runtime?.configJson?.modules
|| []
) as unknown;
if (Array.isArray(mods)) {
setAllowedModules(mods.map((m) => String(m || '').trim().toUpperCase()).filter(Boolean));
} else {
setAllowedModules(null);
}
setAllowedModules(normalizeAllowedModules(runtime));
const activeRole = String(runtime?.active_role || runtime?.user?.active_role || roleKey || '').toUpperCase();
if (activeRole) setIsSuperAdmin(activeRole === 'SUPER_ADMIN');
} else {
@ -447,6 +502,15 @@ export default function AdminShell(props: { children: JSX.Element }) {
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
});
createEffect(() => {
const t = theme();
if (typeof localStorage !== 'undefined') localStorage.setItem('nxtgauge_admin_theme', t);
if (typeof document !== 'undefined') document.documentElement.setAttribute('data-theme', t);
});
const toggleTheme = () => setTheme((v) => (v === 'dark' ? 'light' : 'dark'));
const isDark = () => theme() === 'dark';
createEffect(() => {
if (!checkedSession()) return;
if (isSuperAdmin()) return;
@ -457,7 +521,10 @@ export default function AdminShell(props: { children: JSX.Element }) {
const path = location.pathname;
if (path === '/admin') return;
const guard = ROUTE_MODULE_KEYS.find((entry) => path === entry.prefix || path.startsWith(`${entry.prefix}/`));
const matches = ROUTE_MODULE_KEYS.filter(
(entry) => path === entry.prefix || path.startsWith(`${entry.prefix}/`),
);
const guard = matches.sort((a, b) => b.prefix.length - a.prefix.length)[0];
if (!guard) return;
const allowed = new Set(modules.map((m) => String(m || '').trim().toUpperCase()).filter(Boolean));
@ -468,7 +535,7 @@ export default function AdminShell(props: { children: JSX.Element }) {
});
return (
<div class="min-h-screen bg-[#F9FAFB] text-[#0D0D2A]">
<div class="min-h-screen" style={{ background: isDark() ? '#0B1220' : '#F9FAFB', color: isDark() ? '#E5E7EB' : '#0D0D2A' }}>
<Show
when={checkedSession()}
fallback={<div class="flex min-h-screen items-center justify-center text-[14px] text-[rgba(13,13,42,0.55)]">Checking session</div>}
@ -483,25 +550,31 @@ export default function AdminShell(props: { children: JSX.Element }) {
onNavigate={() => setSidebarOpen(false)}
adminName={adminName()}
adminInitials={adminInitials()}
theme={theme()}
allowedModules={allowedModules()}
isSuperAdmin={isSuperAdmin()}
/>
</div>
<div class="flex min-w-0 flex-1 flex-col">
<header style="height:64px;border-bottom:1px solid #E5E7EB;background:white;flex-shrink:0">
<header style={`height:64px;border-bottom:1px solid ${isDark() ? '#1F2937' : '#E5E7EB'};background:${isDark() ? '#111827' : 'white'};flex-shrink:0`}>
<div style="display:flex;height:100%;width:100%;align-items:center;justify-content:flex-end;padding:0 32px">
<div style="display:flex;align-items:center;gap:4px">
<button type="button" style="position:relative;display:inline-flex;width:36px;height:36px;align-items:center;justify-content:center;border-radius:8px;color:#6B7280;background:none;border:none;cursor:pointer" aria-label="Notifications">
<Bell size={18} />
<Show when={notifCount() > 0}>
<span style="position:absolute;right:8px;top:8px;width:7px;height:7px;border-radius:50%;border:2px solid white;background:#FF5E13" />
<button type="button" onClick={toggleTheme} style={`display:inline-flex;width:36px;height:36px;align-items:center;justify-content:center;border-radius:8px;color:${isDark() ? '#CBD5E1' : '#6B7280'};background:none;border:none;cursor:pointer`} aria-label="Toggle theme">
<Show when={isDark()} fallback={<Moon size={18} />}>
<Sun size={18} />
</Show>
</button>
<button type="button" style="display:inline-flex;width:36px;height:36px;align-items:center;justify-content:center;border-radius:8px;color:#6B7280;background:none;border:none;cursor:pointer" aria-label="Settings">
<button type="button" style={`position:relative;display:inline-flex;width:36px;height:36px;align-items:center;justify-content:center;border-radius:8px;color:${isDark() ? '#CBD5E1' : '#6B7280'};background:none;border:none;cursor:pointer`} aria-label="Notifications">
<Bell size={18} />
<Show when={notifCount() > 0}>
<span style={`position:absolute;right:8px;top:8px;width:7px;height:7px;border-radius:50%;border:2px solid ${isDark() ? '#111827' : 'white'};background:#FF5E13`} />
</Show>
</button>
<button type="button" style={`display:inline-flex;width:36px;height:36px;align-items:center;justify-content:center;border-radius:8px;color:${isDark() ? '#CBD5E1' : '#6B7280'};background:none;border:none;cursor:pointer`} aria-label="Settings">
<Settings size={18} />
</button>
<div style="width:1px;height:24px;background:#E5E7EB;margin:0 8px" />
<div style={`width:1px;height:24px;background:${isDark() ? '#1F2937' : '#E5E7EB'};margin:0 8px`} />
<button
type="button"
style="display:inline-flex;align-items:center;gap:8px;border-radius:8px;padding:4px 8px 4px 4px;background:none;border:none;cursor:pointer"
@ -511,16 +584,37 @@ export default function AdminShell(props: { children: JSX.Element }) {
{adminInitials()}
</div>
<div style="text-align:left">
<p style="font-size:13px;font-weight:600;color:#111827;line-height:1.3">{adminName()}</p>
<p style="font-size:11px;color:#6B7280;line-height:1.3">Super Admin</p>
<p style={`font-size:13px;font-weight:600;color:${isDark() ? '#E5E7EB' : '#111827'};line-height:1.3`}>{adminName()}</p>
<p style={`font-size:11px;color:${isDark() ? '#94A3B8' : '#6B7280'};line-height:1.3`}>Super Admin</p>
</div>
</button>
<button
type="button"
onClick={() => void logout()}
style={`height:32px;border-radius:8px;border:1px solid ${isDark() ? '#374151' : '#E5E7EB'};background:${isDark() ? '#1F2937' : 'white'};padding:0 12px;font-size:12px;font-weight:600;color:${isDark() ? '#E5E7EB' : '#374151'};cursor:pointer`}
>
Logout
</button>
</div>
</div>
</header>
<div class="min-h-0 flex-1 overflow-y-auto bg-[#F9FAFB]">
<main style="width:100%;padding:28px 24px 36px 24px">
<div
ref={(el) => { contentScrollRef = el; }}
class="min-h-0 flex-1 overflow-y-scroll"
style={{ background: isDark() ? '#0B1220' : '#F9FAFB', 'scrollbar-gutter': 'stable' }}
>
<main
class="admin-main"
style={{
width: '100%',
padding: '28px 24px 36px 24px',
filter: isDark() ? 'brightness(0.96)' : 'none',
transition: 'opacity 180ms ease, transform 180ms ease',
opacity: routeTransitioning() ? '0.94' : '1',
transform: routeTransitioning() ? 'translateY(4px)' : 'translateY(0)',
}}
>
{props.children}
</main>
</div>

View file

@ -7,7 +7,7 @@ import {
WalletCards, CreditCard, Tag, Percent, Receipt, ShoppingCart,
FileCheck, Star, HeadphonesIcon, BarChart3,
ChevronLeft, BadgeCheck, Activity, Film, Utensils, PenTool,
Megaphone,
Megaphone, Bell,
} from 'lucide-solid';
type NavItem = {
@ -20,7 +20,7 @@ type NavItem = {
const GROUPS: NavItem[][] = [
[
{ href: '/admin', label: 'Dashboard', icon: LayoutGrid, moduleKeys: ['ADMIN_DASHBOARD'] },
{ href: '/admin', label: 'Dashboard', icon: LayoutGrid, moduleKeys: ['ADMIN_DASHBOARD', 'DASHBOARD'] },
],
[
{ href: '/admin/department', label: 'Department Management', icon: Building2, moduleKeys: ['DEPARTMENT_MANAGEMENT', 'DEPARTMENTS'] },
@ -30,9 +30,9 @@ const GROUPS: NavItem[][] = [
],
[
{ href: '/admin/external-roles', label: 'External Role Management', icon: ShieldCheck, moduleKeys: ['EXTERNAL_ROLE_MANAGEMENT', 'EXTERNAL_ROLES'] },
{ href: '/admin/onboarding-management', label: 'External Onboarding Management', icon: FileText, aliasPrefix: '/admin/onboarding-schemas', moduleKeys: ['ONBOARDING_MANAGEMENT', 'ONBOARDING_SCHEMAS', 'ONBOARDING'] },
{ href: '/admin/internal-dashboard-management', label: 'Internal Dashboard Management', icon: LayoutDashboard, moduleKeys: ['INTERNAL_DASHBOARD_MANAGEMENT', 'INTERNAL_DASHBOARDS'] },
{ href: '/admin/external-dashboard-management', label: 'External Dashboard Management', icon: LayoutDashboard, aliasPrefix: '/admin/role-ui-configs', moduleKeys: ['DASHBOARD_CONFIG_MANAGEMENT', 'EXTERNAL_DASHBOARD_MANAGEMENT', 'EXTERNAL_DASHBOARDS', 'RUNTIME_ROLES'] },
{ href: '/admin/onboarding-management', label: 'External Onboarding Management', icon: FileText, aliasPrefix: '/admin/onboarding-schemas', moduleKeys: ['EXTERNAL_ONBOARDING_MANAGEMENT', 'ONBOARDING_MANAGEMENT', 'ONBOARDING_SCHEMAS', 'ONBOARDING'] },
{ href: '/admin/internal-dashboard-management', label: 'Internal Dashboard Management', icon: LayoutDashboard, moduleKeys: ['INTERNAL_DASHBOARD_MANAGEMENT', 'INTERNAL_DASHBOARDS', 'INTERNAL_DASHBOARD_CONFIG'] },
{ href: '/admin/external-dashboard-management', label: 'External Dashboard Management', icon: LayoutDashboard, aliasPrefix: '/admin/role-ui-configs', moduleKeys: ['DASHBOARD_CONFIG_MANAGEMENT', 'EXTERNAL_DASHBOARD_MANAGEMENT', 'EXTERNAL_DASHBOARDS', 'EXTERNAL_DASHBOARD_CONFIG', 'RUNTIME_ROLES'] },
],
[
{ href: '/admin/verification', label: 'Verification Management', icon: BadgeCheck, moduleKeys: ['VERIFICATION_MANAGEMENT', 'VERIFICATIONS'] },
@ -58,6 +58,8 @@ const GROUPS: NavItem[][] = [
[
{ href: '/admin/jobs', label: 'Jobs Management', icon: BriefcaseBusiness, moduleKeys: ['JOBS_MANAGEMENT', 'JOBS'] },
{ href: '/admin/leads', label: 'Leads Management', icon: HandHelping, moduleKeys: ['LEADS_MANAGEMENT', 'LEADS', 'REQUIREMENTS_MANAGEMENT', 'REQUIREMENTS'] },
{ href: '/admin/applications', label: 'Applications Management', icon: FileText, moduleKeys: ['APPLICATIONS_MANAGEMENT', 'APPLICATIONS'] },
{ href: '/admin/responses', label: 'Responses Management', icon: FileText, moduleKeys: ['RESPONSES_MANAGEMENT', 'RESPONSES'] },
],
[
{ href: '/admin/pricing', label: 'Pricing Management', icon: WalletCards, moduleKeys: ['PRICING_MANAGEMENT', 'PRICING'] },
@ -69,6 +71,8 @@ const GROUPS: NavItem[][] = [
{ href: '/admin/invoice', label: 'Invoice Management', icon: FileCheck, moduleKeys: ['INVOICE_MANAGEMENT', 'INVOICES'] },
],
[
{ href: '/admin/kb', label: 'Knowledge Base Management', icon: BookOpen, moduleKeys: ['KNOWLEDGE_BASE_MANAGEMENT', 'KNOWLEDGE_BASE', 'KB'] },
{ href: '/admin/notifications', label: 'Notifications', icon: Bell, moduleKeys: ['NOTIFICATIONS_MANAGEMENT', 'NOTIFICATIONS'] },
{ href: '/admin/review', label: 'Review Management', icon: Star, moduleKeys: ['REVIEW_MANAGEMENT', 'REVIEWS'] },
{ href: '/admin/support', label: 'Support Management', icon: HeadphonesIcon, moduleKeys: ['SUPPORT_MANAGEMENT', 'SUPPORT'] },
{ href: '/admin/report', label: 'Report Management', icon: BarChart3, moduleKeys: ['REPORT_MANAGEMENT', 'REPORTS'] },
@ -82,6 +86,7 @@ export default function AdminSidebar(props: {
onNavigate?: () => void;
adminName: string;
adminInitials: string;
theme?: 'light' | 'dark';
allowedModules?: string[] | null;
isSuperAdmin?: boolean;
}) {
@ -106,6 +111,8 @@ export default function AdminSidebar(props: {
return location.pathname === item.href || location.pathname.startsWith(`${item.href}/`);
};
const isDark = () => props.theme === 'dark';
return (
<aside
style={{
@ -113,15 +120,15 @@ export default function AdminSidebar(props: {
display: 'flex',
'flex-direction': 'column',
height: '100%',
background: 'white',
'border-right': '1px solid #E5E7EB',
background: isDark() ? '#0F172A' : 'white',
'border-right': `1px solid ${isDark() ? '#1F2937' : '#E5E7EB'}`,
transition: 'width 0.3s',
'flex-shrink': 0,
width: props.collapsed ? '64px' : '220px'
}}
>
{/* Logo area */}
<div style="position:relative;height:64px;display:flex;align-items:center;border-bottom:1px solid #E5E7EB;flex-shrink:0;padding:0 14px">
<div style={`position:relative;height:64px;display:flex;align-items:center;border-bottom:1px solid ${isDark() ? '#1F2937' : '#E5E7EB'};flex-shrink:0;padding:0 14px`}>
<A href="/admin" onClick={props.onNavigate} style="display:flex;align-items:center;gap:10px;text-decoration:none;overflow:hidden">
<Show
when={!props.collapsed}
@ -135,7 +142,7 @@ export default function AdminSidebar(props: {
<button
type="button"
onClick={props.onToggle}
style="position:absolute;right:-10px;top:50%;transform:translateY(-50%);width:20px;height:20px;border-radius:50%;border:1px solid #E5E7EB;background:white;box-shadow:0 1px 4px rgba(0,0,0,0.1);display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:10;color:#6B7280"
style={`position:absolute;right:-10px;top:50%;transform:translateY(-50%);width:20px;height:20px;border-radius:50%;border:1px solid ${isDark() ? '#1F2937' : '#E5E7EB'};background:${isDark() ? '#111827' : 'white'};box-shadow:0 1px 4px rgba(0,0,0,0.1);display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:10;color:${isDark() ? '#CBD5E1' : '#6B7280'}`}
aria-label={props.collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
<ChevronLeft size={11} style={`transition:transform 0.3s;${props.collapsed ? 'transform:rotate(180deg)' : ''}`} />
@ -148,7 +155,7 @@ export default function AdminSidebar(props: {
{(group, gi) => (
<>
<Show when={gi() > 0}>
<div style="height:1px;background:#F3F4F6;margin:6px 4px" />
<div style={`height:1px;background:${isDark() ? '#1F2937' : '#F3F4F6'};margin:6px 4px`} />
</Show>
<div style="display:flex;flex-direction:column;gap:1px">
<For each={group}>
@ -160,12 +167,12 @@ export default function AdminSidebar(props: {
href={item.href}
onClick={props.onNavigate}
title={props.collapsed ? item.label : undefined}
style={`display:flex;align-items:center;height:36px;border-radius:8px;text-decoration:none;padding:0 ${props.collapsed ? '0' : '10px'};${props.collapsed ? 'justify-content:center;' : ''}${active() ? 'background:#FFF3EE;color:#FF5E13;' : 'color:#6B7280;'}`}
style={`display:flex;align-items:center;height:36px;border-radius:8px;text-decoration:none;padding:0 ${props.collapsed ? '0' : '10px'};transition:background 140ms ease,color 140ms ease;${props.collapsed ? 'justify-content:center;' : ''}${active() ? 'background:#FFF3EE;color:#FF5E13;' : `color:${isDark() ? '#CBD5E1' : '#6B7280'};`}`}
aria-current={active() ? 'page' : undefined}
>
<Icon
size={16}
style={`flex-shrink:0;${active() ? 'color:#FF5E13' : 'color:#9CA3AF'}`}
style={`flex-shrink:0;${active() ? 'color:#FF5E13' : `color:${isDark() ? '#94A3B8' : '#9CA3AF'}`}`}
strokeWidth={active() ? 2.5 : 2}
/>
<Show when={!props.collapsed}>

File diff suppressed because it is too large Load diff

View file

@ -40,6 +40,7 @@ type Props = {
error?: string;
livePreviewUrl?: string;
livePreviewHint?: string;
roleOptions?: { value: string; label: string }[];
primaryLabel: string;
onChange: (next: {
title?: string;
@ -69,6 +70,13 @@ const ROLE_OPTIONS = [
'developer',
];
function fallbackRoleOptions(): { value: string; label: string }[] {
return ROLE_OPTIONS.map((option) => ({
value: option,
label: option.replace(/_/g, ' '),
}));
}
const DEFAULT_LIBRARY: OnboardingQuestion[] = [
{ key: 'full_name', label: 'Full Name', type: 'text', required: true, category: 'profile' },
{ key: 'email', label: 'Email', type: 'email', required: true, category: 'contact' },
@ -396,6 +404,10 @@ export default function OnboardingFlowBuilder(props: Props) {
const selectedKeySet = createMemo(() => new Set(props.selectedFields.map((field) => field.id)));
const previewSteps = createMemo(() => buildStepsFromFields(props.selectedFields, props.stepCount));
const roleOptions = createMemo(() => {
const incoming = Array.isArray(props.roleOptions) ? props.roleOptions : [];
return incoming.length > 0 ? incoming : fallbackRoleOptions();
});
const toggleQuestion = (question: OnboardingQuestion) => {
const checked = selectedKeySet().has(question.key);
@ -467,7 +479,7 @@ export default function OnboardingFlowBuilder(props: Props) {
<label>Who is this form for?</label>
<select value={props.roleKey} onChange={(event) => props.onChange({ roleKey: event.currentTarget.value })}>
<option value="">Select a role</option>
<For each={ROLE_OPTIONS}>{(option) => <option value={option}>{option.replace(/_/g, ' ')}</option>}</For>
<For each={roleOptions()}>{(option) => <option value={option.value}>{option.label}</option>}</For>
</select>
</div>
<div class="field">

View file

@ -0,0 +1,223 @@
import { A } from '@solidjs/router';
import { createMemo, createResource, createSignal, For, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
type SortMode = 'newest' | 'oldest' | 'name_asc' | 'name_desc';
async function fetchUsers(role: string): Promise<any[]> {
try {
const res = await fetch(`${API}/api/admin/users?role=${encodeURIComponent(role)}`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.users || []);
} catch {
return [];
}
}
function statusBadge(status?: string) {
const normalized = (status || '').toUpperCase();
if (normalized === 'ACTIVE') return 'inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-700';
if (normalized === 'PENDING') return 'inline-flex items-center rounded-full bg-orange-50 px-2.5 py-0.5 text-xs font-medium text-orange-700';
return 'inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600';
}
export default function RoleUserManagementTablePage(props: {
role: string;
title: string;
subtitle: string;
emptyLabel: string;
viewHref: (id: string) => string;
}) {
const [users] = createResource(() => props.role, fetchUsers);
const [search, setSearch] = createSignal('');
const [statusFilter, setStatusFilter] = createSignal<'all' | 'ACTIVE' | 'INACTIVE' | 'PENDING'>('all');
const [sortBy, setSortBy] = createSignal<SortMode>('newest');
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
const filtered = createMemo(() => {
const list = users() ?? [];
const q = search().toLowerCase().trim();
const f = statusFilter();
const sorted = list.filter((u) => {
const fullName = String(u.name || u.full_name || '').toLowerCase();
const email = String(u.email || '').toLowerCase();
const st = String(u.status || '').toUpperCase();
const matchesSearch = !q || fullName.includes(q) || email.includes(q);
const matchesStatus = f === 'all' || st === f;
return matchesSearch && matchesStatus;
});
sorted.sort((a, b) => {
const aDate = new Date(a.created_at || 0).getTime();
const bDate = new Date(b.created_at || 0).getTime();
if (sortBy() === 'oldest') return aDate - bDate;
if (sortBy() === 'name_asc') return String(a.name || a.full_name || '').localeCompare(String(b.name || b.full_name || ''));
if (sortBy() === 'name_desc') return String(b.name || b.full_name || '').localeCompare(String(a.name || a.full_name || ''));
return bDate - aDate;
});
return sorted;
});
const exportCsv = () => {
const headers = ['Name', 'Email', 'Status', 'Registered'];
const rows = filtered().map((item) => [
String(item.name || item.full_name || ''),
String(item.email || ''),
String(item.status || ''),
item.created_at ? new Date(item.created_at).toLocaleDateString() : '',
]);
const csv = [headers, ...rows]
.map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${props.role}-users.csv`;
link.click();
URL.revokeObjectURL(url);
};
return (
<AdminShell>
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
<div class="bg-white border-b border-gray-200 px-6 py-4">
<h1 class="text-xl font-semibold text-gray-900">{props.title}</h1>
<p class="text-sm text-gray-500 mt-0.5">{props.subtitle}</p>
</div>
<div class="flex-1 p-6">
<div class="mb-4 flex flex-wrap items-center gap-2" style="position:relative;z-index:20;">
<input
type="text"
placeholder="Search by name or email..."
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
class="rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#FF5E13] w-72"
/>
<div style="position:relative;">
<button
type="button"
onClick={() => { setSortMenuOpen((v) => !v); setFilterMenuOpen(false); }}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
>
Sort
</button>
<Show when={sortMenuOpen()}>
<div style="position:absolute;right:0;top:38px;z-index:40;min-width:180px;border:1px solid #E5E7EB;border-radius:12px;background:#fff;box-shadow:0 12px 24px rgba(2,6,23,0.12);padding:8px">
<For each={[
{ key: 'newest', label: 'Newest First' },
{ key: 'oldest', label: 'Oldest First' },
{ key: 'name_asc', label: 'Name A-Z' },
{ key: 'name_desc', label: 'Name Z-A' },
] as { key: SortMode; label: string }[]}>
{(item) => (
<button
type="button"
onClick={() => { setSortBy(item.key); setSortMenuOpen(false); }}
style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === item.key ? '#FF5E13' : '#374151'};background:${sortBy() === item.key ? '#FFF1EB' : 'transparent'}`}
>
{item.label}
</button>
)}
</For>
</div>
</Show>
</div>
<div style="position:relative;">
<button
type="button"
onClick={() => { setFilterMenuOpen((v) => !v); setSortMenuOpen(false); }}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
>
Filters
</button>
<Show when={filterMenuOpen()}>
<div style="position:absolute;right:0;top:38px;z-index:40;min-width:180px;border:1px solid #E5E7EB;border-radius:12px;background:#fff;box-shadow:0 12px 24px rgba(2,6,23,0.12);padding:8px">
<For each={[
{ key: 'all', label: 'All Status' },
{ key: 'ACTIVE', label: 'Active' },
{ key: 'INACTIVE', label: 'Inactive' },
{ key: 'PENDING', label: 'Pending' },
] as { key: 'all' | 'ACTIVE' | 'INACTIVE' | 'PENDING'; label: string }[]}>
{(item) => (
<button
type="button"
onClick={() => { setStatusFilter(item.key); setFilterMenuOpen(false); }}
style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === item.key ? '#FF5E13' : '#374151'};background:${statusFilter() === item.key ? '#FFF1EB' : 'transparent'}`}
>
{item.label}
</button>
)}
</For>
</div>
</Show>
</div>
<button
type="button"
onClick={exportCsv}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #D1D5DB;background:#fff;padding:0 12px;font-size:12px;font-weight:600;color:#0f172a;cursor:pointer"
>
Export
</button>
</div>
<div class="table-card">
<div class="overflow-x-auto">
<table class="data-table w-full text-sm">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Registered</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={users.loading}>
<tr><td colspan="5" class="text-center py-8 text-slate-500">Loading...</td></tr>
</Show>
<Show when={!users.loading && users.error}>
<tr><td colspan="5" class="text-center py-8 text-red-700">Failed to load. Is the backend running?</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length === 0}>
<tr><td colspan="5" class="text-center py-8 text-slate-400">{props.emptyLabel}</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length > 0}>
<For each={filtered()}>
{(item) => (
<tr class="hover:bg-slate-50">
<td class="font-semibold text-slate-900">{item.name || item.full_name || '—'}</td>
<td class="text-slate-500">{item.email}</td>
<td>
<span class={statusBadge(item.status)}>{item.status?.toUpperCase() || '—'}</span>
</td>
<td class="text-slate-500">{item.created_at ? new Date(item.created_at).toLocaleDateString() : '—'}</td>
<td>
<div class="flex items-center justify-end gap-1">
<A class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm hover:bg-gray-50" href={props.viewHref(String(item.id))}>View</A>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</div>
</div>
</div>
</AdminShell>
);
}

View file

@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { normalizeAllowedModules } from './module-access.ts';
test('normalizeAllowedModules reads explicit module arrays', () => {
const modules = normalizeAllowedModules({
enabled_modules: ['employee_management', 'approval_management'],
});
assert.deepEqual(modules, ['EMPLOYEE_MANAGEMENT', 'APPROVAL_MANAGEMENT']);
});
test('normalizeAllowedModules derives module keys from permissions object', () => {
const modules = normalizeAllowedModules({
permissions: {
'departments.view': true,
'external_dashboard_management.update': true,
},
});
assert.deepEqual(modules, ['DEPARTMENTS', 'EXTERNAL_DASHBOARD_MANAGEMENT']);
});
test('normalizeAllowedModules derives module keys from permission keys list', () => {
const modules = normalizeAllowedModules({
permission_keys: ['INTERNAL_DASHBOARD_CONFIG:View', 'VERIFICATIONS_VIEW'],
});
assert.deepEqual(modules, ['INTERNAL_DASHBOARD_CONFIG', 'VERIFICATIONS']);
});

View file

@ -0,0 +1,56 @@
export function normalizeAllowedModules(runtime: any): string[] | null {
const directModules = [
...(toArray(runtime?.enabled_modules)),
...(toArray(runtime?.enabledModules)),
...(toArray(runtime?.modules)),
...(toArray(runtime?.config_json?.modules)),
...(toArray(runtime?.configJson?.modules)),
];
const permissionCandidates = [
...(toArray(runtime?.permission_keys)),
...(toArray(runtime?.permissionKeys)),
...permissionObjectKeys(runtime?.permissions),
...permissionObjectKeys(runtime?.config_json?.permissions),
...permissionObjectKeys(runtime?.configJson?.permissions),
];
const derivedModules = permissionCandidates
.map((value) => parsePermissionToModule(String(value || '')))
.filter(Boolean) as string[];
const all = [...directModules, ...derivedModules]
.map((value) => String(value || '').trim().toUpperCase())
.filter(Boolean);
if (!all.length) return null;
return Array.from(new Set(all));
}
function toArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((item) => String(item || ''));
}
function permissionObjectKeys(value: unknown): string[] {
if (!value || typeof value !== 'object' || Array.isArray(value)) return [];
return Object.keys(value as Record<string, unknown>);
}
function parsePermissionToModule(permission: string): string | null {
const normalized = permission.trim().toUpperCase();
if (!normalized) return null;
// Example: DEPARTMENTS_VIEW
const suffixMatch = normalized.match(/^([A-Z0-9_]+)_(VIEW|CREATE|UPDATE|DELETE)$/);
if (suffixMatch) return suffixMatch[1];
// Example: DEPARTMENT_MANAGEMENT:VIEW
if (normalized.includes(':')) return normalized.split(':')[0] || null;
// Example: departments.view
if (normalized.includes('.')) return normalized.split('.')[0] || null;
// Already a module key
return normalized;
}

View file

@ -6,7 +6,7 @@ const API = '/api/gateway';
type ApprovalRecord = CrudRecord & {
applicantName?: string;
approvalType: 'PROFILE' | 'BUSINESS' | 'JOB' | 'ORDER' | 'INVOICE' | 'COUPON' | 'DISCOUNT' | 'TAX' | 'ROLE';
approvalType: 'PROFILE' | 'BUSINESS' | 'JOB' | 'ORDER' | 'INVOICE' | 'COUPON' | 'DISCOUNT' | 'TAX' | 'ROLE' | 'REQUIREMENT';
userType: 'CUSTOMER' | 'PROFESSIONAL' | 'COMPANY' | 'JOBSEEKER';
submittedDate?: string;
verificationStatus: 'PENDING' | 'VERIFIED' | 'FLAGGED';
@ -15,14 +15,6 @@ type ApprovalRecord = CrudRecord & {
status: 'PENDING' | 'IN_REVIEW' | 'APPROVED' | 'REJECTED' | 'ON_HOLD' | 'ESCALATED';
};
const FALLBACK_APPROVALS: ApprovalRecord[] = [
{ id: 'a1', name: 'Profile Approval - Arun Kumar', applicantName: 'Arun Kumar', approvalType: 'PROFILE', userType: 'PROFESSIONAL', submittedDate: '2026-03-25', verificationStatus: 'VERIFIED', assignedApprover: 'Suresh Menon', priority: 'HIGH', status: 'PENDING', updatedAt: '2026-03-25' },
{ id: 'a2', name: 'Business Approval - Tech Solutions', applicantName: 'Tech Solutions', approvalType: 'BUSINESS', userType: 'COMPANY', submittedDate: '2026-03-24', verificationStatus: 'VERIFIED', assignedApprover: 'Rekha Nair', priority: 'MEDIUM', status: 'IN_REVIEW', updatedAt: '2026-03-26' },
{ id: 'a3', name: 'Order Approval - ORD-12345', applicantName: 'Rahul Verma', approvalType: 'ORDER', userType: 'CUSTOMER', submittedDate: '2026-03-26', verificationStatus: 'VERIFIED', assignedApprover: 'Unassigned', priority: 'LOW', status: 'PENDING', updatedAt: '2026-03-26' },
{ id: 'a4', name: 'Job Approval - Senior Dev', applicantName: 'Deepak Verma', approvalType: 'JOB', userType: 'COMPANY', submittedDate: '2026-03-23', verificationStatus: 'FLAGGED', assignedApprover: 'Anita Pillai', priority: 'CRITICAL', status: 'ESCALATED', updatedAt: '2026-03-25' },
{ id: 'a5', name: 'Invoice Approval - INV-987', applicantName: 'Manoj Iyer', approvalType: 'INVOICE', userType: 'PROFESSIONAL', submittedDate: '2026-03-22', verificationStatus: 'VERIFIED', assignedApprover: 'Arun Kumar', priority: 'MEDIUM', status: 'APPROVED', updatedAt: '2026-03-24' },
];
function StatusBadge(props: { status: string }) {
const getColors = () => {
switch (props.status) {
@ -85,6 +77,8 @@ export default function ApprovalManagementPage() {
const [sortBy, setSortBy] = createSignal<'submitted_desc' | 'submitted_asc' | 'priority_desc' | 'priority_asc'>('submitted_desc');
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
const [error, setError] = createSignal('');
const [isActing, setIsActing] = createSignal(false);
type ApprovalRule = {
id: string;
@ -106,7 +100,56 @@ export default function ApprovalManagementPage() {
const [formError, setFormError] = createSignal('');
const load = async () => {
setRows(FALLBACK_APPROVALS);
setError('');
try {
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
const res = await fetch(`${API}/api/admin/approvals?page=1&limit=100`, {
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
});
if (!res.ok) throw new Error(`Request failed (${res.status})`);
const payload = await res.json().catch(() => ({} as any));
const jobs = Array.isArray(payload?.jobs) ? payload.jobs : [];
const requirements = Array.isArray(payload?.requirements) ? payload.requirements : [];
const mappedJobs: ApprovalRecord[] = jobs.map((job: any) => ({
id: String(job.id),
name: `Job Approval - ${String(job.title || 'Untitled Job')}`,
applicantName: String(job.title || 'Untitled Job'),
approvalType: 'JOB',
userType: 'COMPANY',
submittedDate: String(job.created_at || ''),
verificationStatus: 'VERIFIED',
assignedApprover: 'Unassigned',
priority: 'HIGH',
status: 'PENDING',
updatedAt: String(job.updated_at || job.created_at || ''),
}));
const mappedReqs: ApprovalRecord[] = requirements.map((req: any) => ({
id: String(req.id),
name: `Requirement Approval - ${String(req.title || 'Untitled Requirement')}`,
applicantName: String(req.title || 'Untitled Requirement'),
approvalType: 'REQUIREMENT',
userType: 'CUSTOMER',
submittedDate: String(req.created_at || ''),
verificationStatus: 'VERIFIED',
assignedApprover: 'Unassigned',
priority: 'MEDIUM',
status: 'PENDING',
updatedAt: String(req.updated_at || req.created_at || ''),
}));
setRows([...mappedJobs, ...mappedReqs]);
} catch (e: any) {
setRows([]);
setError(e?.message || 'Could not reach approvals API.');
}
};
onMount(() => void load());
@ -146,6 +189,31 @@ export default function ApprovalManagementPage() {
return sorted;
});
const exportCsv = () => {
const headers = ['Approval ID', 'Applicant', 'Type', 'Verification', 'Priority', 'Status', 'Submitted Date'];
const rowsData = filteredRows().map((row) => [
row.id || '',
row.applicantName || '',
row.approvalType || '',
row.verificationStatus || '',
row.priority || '',
row.status || '',
formatDate(row.submittedDate || row.updatedAt),
]);
const csv = [headers, ...rowsData]
.map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `approval-management-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const openView = (row: ApprovalRecord) => {
setViewingCase(row);
setDetailTab('overview');
@ -182,6 +250,47 @@ export default function ApprovalManagementPage() {
resetRuleForm();
};
const runApprovalAction = async (row: ApprovalRecord, action: 'approve' | 'reject') => {
const type = row.approvalType;
if (type !== 'JOB' && type !== 'REQUIREMENT') {
setError(`Action is not supported for approval type "${type}".`);
return;
}
setIsActing(true);
setError('');
try {
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
const endpoint = type === 'JOB'
? `${API}/api/admin/approvals/jobs/${row.id}/${action}`
: `${API}/api/admin/approvals/requirements/${row.id}/${action}`;
const res = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
body: action === 'reject'
? JSON.stringify({ reason: 'Rejected by admin from approval management' })
: JSON.stringify({}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error((data as any).message || `Request failed (${res.status})`);
}
await load();
setViewingCase(null);
setListTab('all');
} catch (e: any) {
setError(e?.message || 'Approval action failed.');
} finally {
setIsActing(false);
}
};
return (
<AdminShell>
<div class="w-full space-y-6 pb-8">
@ -191,6 +300,11 @@ export default function ApprovalManagementPage() {
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Approval Management</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">Manage final approval decisions for all platform entities and requests</p>
</div>
<Show when={error()}>
<div style="margin-bottom:10px;border-radius:10px;border:1px solid #FECACA;background:#FEF2F2;padding:12px 16px;font-size:13px;color:#B91C1C">
{error()}
</div>
</Show>
{/* ── LIST VIEW ── */}
<Show when={view() === 'list'}>
@ -229,8 +343,8 @@ export default function ApprovalManagementPage() {
<p style="margin-top:2px;font-size:13px;color:#6B7280">ID: {viewingCase()!.id} {viewingCase()!.approvalType} Submitted: {formatDate(viewingCase()!.submittedDate)}</p>
</div>
<div style="display:flex;gap:10px">
<button type="button" style="height:36px;border-radius:8px;background:#0D0D2A;padding:0 16px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer">Approve</button>
<button type="button" style="height:36px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 16px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Reject</button>
<button type="button" onClick={() => void runApprovalAction(viewingCase()!, 'approve')} disabled={isActing()} style={`height:36px;border-radius:8px;background:#0D0D2A;padding:0 16px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer;opacity:${isActing() ? 0.7 : 1}`}>Approve</button>
<button type="button" onClick={() => void runApprovalAction(viewingCase()!, 'reject')} disabled={isActing()} style={`height:36px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 16px;font-size:13px;font-weight:600;color:#374151;cursor:pointer;opacity:${isActing() ? 0.7 : 1}`}>Reject</button>
</div>
</div>
@ -398,7 +512,7 @@ export default function ApprovalManagementPage() {
</div>
</Show>
</div>
<button type="button" style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:600;color:white;border:none;cursor:pointer">
<button type="button" onClick={exportCsv} style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:600;color:white;border:none;cursor:pointer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Export
</button>
@ -439,8 +553,8 @@ export default function ApprovalManagementPage() {
<Show when={openMenuId() === row.id}>
<div style="position:absolute;right:20px;top:44px;z-index:20;width:190px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 20px rgba(0,0,0,0.12)">
<button type="button" onClick={() => openView(row)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">View Approval</button>
<button type="button" style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">Approve</button>
<button type="button" style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#DC2626;background:none;border:none;cursor:pointer;text-align:left">Reject</button>
<button type="button" onClick={() => void runApprovalAction(row, 'approve')} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">Approve</button>
<button type="button" onClick={() => void runApprovalAction(row, 'reject')} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#DC2626;background:none;border:none;cursor:pointer;text-align:left">Reject</button>
</div>
</Show>
</td>

View file

@ -85,6 +85,28 @@ export default function CandidateManagementPage() {
return sorted;
});
const exportCsv = () => {
const headers = ['Candidate Name', 'Email', 'Location', 'Registered', 'Status'];
const body = filteredRows().map((row) => [
row.name || '',
row.email || '',
row.location || '',
row.registeredDate || '',
row.status || '',
]);
const csv = [headers, ...body]
.map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'candidates.csv';
link.click();
URL.revokeObjectURL(url);
};
const openDetail = (row: CandidateRecord) => { setSelectedCandidate(row); setListTab('view'); setOpenMenuId(null); };
return (
@ -226,6 +248,13 @@ export default function CandidateManagementPage() {
</div>
</Show>
</div>
<button
type="button"
onClick={exportCsv}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #D1D5DB;background:#fff;padding:0 12px;font-size:12px;font-weight:600;color:#0f172a;cursor:pointer"
>
Export
</button>
</div>
<div class="overflow-x-auto">

View file

@ -1,125 +1,13 @@
import { A } from '@solidjs/router';
import { createMemo, createResource, createSignal, For, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
async function fetchUsers(): Promise<any[]> {
try {
const res = await fetch(`${API}/api/admin/users?role=catering_services`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.users || []);
} catch {
return [];
}
}
import RoleUserManagementTablePage from '~/components/admin/RoleUserManagementTablePage';
export default function CateringServicesPage() {
const [users] = createResource(fetchUsers);
const [search, setSearch] = createSignal('');
const [statusFilter, setStatusFilter] = createSignal('');
const filtered = createMemo(() => {
const list = users() ?? [];
const q = search().toLowerCase();
const s = statusFilter();
return list.filter((u) => {
const matchSearch =
!q ||
(u.name || u.full_name || '').toLowerCase().includes(q) ||
(u.email || '').toLowerCase().includes(q);
const matchStatus = !s || (u.status || '').toUpperCase() === s;
return matchSearch && matchStatus;
});
});
return (
<AdminShell>
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
<div class="bg-white border-b border-gray-200 px-6 py-4">
<h1 class="text-xl font-semibold text-gray-900">Catering Services Management</h1>
<p class="text-sm text-gray-500 mt-0.5">Manage all catering services accounts on the platform.</p>
</div>
<div class="flex-1 p-6">
<div class="table-card">
<div style="display:flex;gap:12px;padding:16px;border-bottom:1px solid #e2e8f0;flex-wrap:wrap;">
<input
type="text"
placeholder="Search by name or email..."
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
style="border:1px solid #cbd5e1;border-radius:6px;padding:8px 12px;font-size:14px;width:260px;"
/>
<select
value={statusFilter()}
onChange={(e) => setStatusFilter(e.currentTarget.value)}
style="border:1px solid #cbd5e1;border-radius:6px;padding:8px 12px;font-size:14px;"
>
<option value="">All Status</option>
<option value="ACTIVE">ACTIVE</option>
<option value="INACTIVE">INACTIVE</option>
<option value="PENDING">PENDING</option>
</select>
</div>
<div class="overflow-x-auto">
<table data-table class="w-full text-sm">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Registered</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={users.loading}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!users.loading && users.error}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length === 0}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#94a3b8">No catering services users found.</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length > 0}>
<For each={filtered()}>
{(item) => (
<tr class="hover:bg-slate-50">
<td class="font-semibold text-slate-900">{item.name || item.full_name || '—'}</td>
<td class="text-slate-500">{item.email}</td>
<td>
{item.status?.toUpperCase() === 'ACTIVE' && (
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-700">ACTIVE</span>
)}
{item.status?.toUpperCase() === 'INACTIVE' && (
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">INACTIVE</span>
)}
{item.status?.toUpperCase() === 'PENDING' && (
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600" style="background:#fff7ed;color:#c2410c;border-color:#fed7aa;">PENDING</span>
)}
{!item.status && <span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600"></span>}
</td>
<td class="text-slate-500">
{item.created_at ? new Date(item.created_at).toLocaleDateString() : '—'}
</td>
<td>
<div class="flex items-center justify-end gap-1">
<A class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href={`/admin/users/${item.id}`}>View</A>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</div>
</div>
</div>
</AdminShell>
<RoleUserManagementTablePage
role="catering_services"
title="Catering Services Management"
subtitle="Manage all catering service accounts on the platform."
emptyLabel="No catering services users found."
viewHref={(id) => `/admin/users/${id}`}
/>
);
}

View file

@ -83,6 +83,28 @@ export default function CompanyManagementPage() {
return sorted;
});
const exportCsv = () => {
const headers = ['Company Name', 'Industry', 'City', 'Email', 'Status'];
const body = filteredRows().map((row) => [
row.name || '',
row.industry || '',
row.city || '',
row.email || '',
row.status || '',
]);
const csv = [headers, ...body]
.map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'companies.csv';
link.click();
URL.revokeObjectURL(url);
};
const openDetail = (row: CompanyRecord) => { setSelectedCompany(row); setListTab('view'); setOpenMenuId(null); };
return (
@ -223,6 +245,13 @@ export default function CompanyManagementPage() {
</div>
</Show>
</div>
<button
type="button"
onClick={exportCsv}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #D1D5DB;background:#fff;padding:0 12px;font-size:12px;font-weight:600;color:#0f172a;cursor:pointer"
>
Export
</button>
</div>
<div class="overflow-x-auto">

View file

@ -85,6 +85,28 @@ export default function CustomerManagementPage() {
return sorted;
});
const exportCsv = () => {
const headers = ['Customer Name', 'Email', 'Location', 'Orders', 'Status'];
const body = filteredRows().map((row) => [
row.name || '',
row.email || '',
row.location || '',
String(row.totalOrders || 0),
row.status || '',
]);
const csv = [headers, ...body]
.map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'customers.csv';
link.click();
URL.revokeObjectURL(url);
};
const openDetail = (row: CustomerRecord) => { setSelectedCustomer(row); setListTab('view'); setOpenMenuId(null); };
return (
@ -226,6 +248,13 @@ export default function CustomerManagementPage() {
</div>
</Show>
</div>
<button
type="button"
onClick={exportCsv}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #D1D5DB;background:#fff;padding:0 12px;font-size:12px;font-weight:600;color:#0f172a;cursor:pointer"
>
Export
</button>
</div>
<div class="overflow-x-auto">

View file

@ -94,8 +94,11 @@ export default function DepartmentManagementPage() {
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
const [rows, setRows] = createSignal<DepartmentRecord[]>([]);
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
const [openMenuPos, setOpenMenuPos] = createSignal({ x: 0, y: 0 });
const [editingId, setEditingId] = createSignal<string | null>(null);
const [viewingDept, setViewingDept] = createSignal<DepartmentRecord | null>(null);
const [deleteTarget, setDeleteTarget] = createSignal<DepartmentRecord | null>(null);
const [isDeleting, setIsDeleting] = createSignal(false);
const [name, setName] = createSignal('');
const [code, setCode] = createSignal('');
@ -347,7 +350,7 @@ export default function DepartmentManagementPage() {
{/* Table card */}
<div style={{ display: listTab() === 'view' ? 'none' : 'block' }}>
<div style="margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:hidden;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
<div style="position:relative;margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:visible;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
{/* Filter bar */}
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6">
@ -402,7 +405,7 @@ export default function DepartmentManagementPage() {
</div>
{/* Table */}
<div class="overflow-x-auto">
<div class="overflow-x-auto overflow-y-visible">
<table class="min-w-full">
<thead>
<tr style="background:#0D0D2A;text-align:left">
@ -448,14 +451,22 @@ export default function DepartmentManagementPage() {
<td style="padding:12px 20px;position:relative">
<button
type="button"
onClick={() => setOpenMenuId(openMenuId() === row.id ? null : row.id)}
onClick={(e) => {
if (openMenuId() === row.id) {
setOpenMenuId(null);
return;
}
const rect = e.currentTarget.getBoundingClientRect();
setOpenMenuPos({ x: rect.right, y: rect.top - 8 });
setOpenMenuId(row.id);
}}
class="inline-flex h-8 w-8 items-center justify-center rounded-lg text-[#9CA3AF] hover:bg-[#F3F4F6] hover:text-[#374151] transition-colors"
aria-label="More actions"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
</button>
<Show when={openMenuId() === row.id}>
<div style="position:absolute;right:20px;top:44px;z-index:20;width:210px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 20px rgba(0,0,0,0.12)">
<div style={`position:fixed;left:${openMenuPos().x}px;top:${openMenuPos().y}px;transform:translate(-100%, -100%);z-index:9999;width:210px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 20px rgba(0,0,0,0.12)`}>
<button type="button" onClick={() => { setViewingDept(row); setOpenMenuId(null); setListTab('view'); }} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:10px 12px;font-size:13px;font-weight:500;color:#374151;background:none;border:none;cursor:pointer;text-align:left">
<svg style="width:16px;height:16px;color:#FF5E13;flex-shrink:0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
View Department
@ -482,12 +493,8 @@ export default function DepartmentManagementPage() {
<button
type="button"
onClick={async () => {
if (!window.confirm(`Delete department "${row.name}"?`)) return;
try {
const res = await fetch(`${API}/api/admin/departments/${row.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`Request failed (${res.status})`);
} catch (err: any) { setError(err?.message || 'Failed to delete department.'); }
finally { setOpenMenuId(null); await load(); }
setOpenMenuId(null);
setDeleteTarget(row);
}}
style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:10px 12px;font-size:13px;font-weight:500;color:#DC2626;background:none;border:none;cursor:pointer;text-align:left"
>
@ -525,6 +532,53 @@ export default function DepartmentManagementPage() {
</div>
</Show>
<Show when={deleteTarget()}>
<div style="position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;background:rgba(17,24,39,0.45);padding:16px">
<div style="width:min(92vw,480px);border-radius:16px;border:1px solid #E5E7EB;background:white;box-shadow:0 24px 48px rgba(0,0,0,0.2)">
<div style="padding:20px 22px;border-bottom:1px solid #F3F4F6">
<h3 style="font-size:18px;font-weight:700;color:#111827">Delete Department?</h3>
<p style="margin-top:8px;font-size:13px;line-height:1.5;color:#4B5563">
You are about to permanently delete
<strong style="color:#111827"> {deleteTarget()?.name}</strong>.
This action cannot be undone.
</p>
</div>
<div style="display:flex;justify-content:flex-end;gap:10px;padding:14px 22px">
<button
type="button"
onClick={() => setDeleteTarget(null)}
disabled={isDeleting()}
style="height:38px;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 14px;font-size:13px;font-weight:600;color:#374151;cursor:pointer"
>
Cancel
</button>
<button
type="button"
disabled={isDeleting()}
onClick={async () => {
const target = deleteTarget();
if (!target) return;
setIsDeleting(true);
try {
const res = await fetch(`${API}/api/admin/departments/${target.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`Request failed (${res.status})`);
setDeleteTarget(null);
await load();
} catch (err: any) {
setError(err?.message || 'Failed to delete department.');
} finally {
setIsDeleting(false);
}
}}
style="height:38px;border-radius:10px;border:none;background:#0D0D2A;padding:0 14px;font-size:13px;font-weight:700;color:white;cursor:pointer"
>
{isDeleting() ? 'Deleting...' : 'Delete Department'}
</button>
</div>
</div>
</div>
</Show>
{/* ── FORM VIEW (Create / Edit) ── */}
<Show when={view() === 'form'}>
{/* Top tabs */}

View file

@ -117,8 +117,11 @@ export default function DesignationManagementPage() {
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
const [rows, setRows] = createSignal<DesignationRecord[]>([]);
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
const [openMenuPos, setOpenMenuPos] = createSignal({ x: 0, y: 0 });
const [editingId, setEditingId] = createSignal<string | null>(null);
const [viewingRecord, setViewingRecord] = createSignal<DesignationRecord | null>(null);
const [deleteTarget, setDeleteTarget] = createSignal<DesignationRecord | null>(null);
const [isDeleting, setIsDeleting] = createSignal(false);
const [name, setName] = createSignal('');
const [code, setCode] = createSignal('');
@ -208,7 +211,14 @@ export default function DesignationManagementPage() {
const filteredRows = createMemo(() => {
let r = rows();
if (statusFilter() !== 'all') r = r.filter((d) => d.status === statusFilter().toUpperCase());
if (deptFilter() !== 'all') r = r.filter((d) => d.department === deptFilter());
if (deptFilter() !== 'all') {
const selected = String(deptFilter()).trim().toLowerCase();
r = r.filter((d) => {
const byId = String(d.departmentId || '').trim().toLowerCase();
const byName = String(d.department || '').trim().toLowerCase();
return byId === selected || byName === selected;
});
}
const q = search().toLowerCase();
if (q) {
r = r.filter((d) =>
@ -229,6 +239,34 @@ export default function DesignationManagementPage() {
return r;
});
const exportCsv = () => {
const headers = ['Designation Name', 'Code', 'Department', 'Level', 'Employees', 'Status'];
const rowsData = filteredRows().map((row) => [
row.name || '',
row.code || '',
row.department || '',
row.level || '',
String(row.totalEmployees ?? 0),
row.status || '',
]);
const csv = [
headers,
...rowsData,
]
.map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `designation-management-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const resetForm = () => {
setEditingId(null); setName(''); setCode(''); setDepartmentId('');
setLevel(''); setDescription(''); setStatus('ACTIVE');
@ -248,6 +286,7 @@ export default function DesignationManagementPage() {
};
const save = async () => {
if (isSaving()) return;
if (!name().trim() || !code().trim()) {
setError('Designation name and code are required.');
setFormTab('general');
@ -271,7 +310,7 @@ export default function DesignationManagementPage() {
try {
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const endpoint = editingId()
? `${API}/api/admin/designations/${editingId()}`
@ -287,15 +326,23 @@ export default function DesignationManagementPage() {
credentials: 'include',
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error((body as any).message || `Request failed (${res.status})`);
const raw = await res.text();
let message = '';
if (raw) {
try {
const parsed = JSON.parse(raw) as { message?: string; error?: string };
message = parsed?.message || parsed?.error || '';
} catch {
message = raw;
}
}
if (!res.ok) throw new Error(message || `Request failed (${res.status})`);
setView('list');
resetForm();
await load();
} catch (err: any) {
setError(err?.message || 'Failed to save designation.');
const msg = String(err?.message || '').trim();
setError(msg || 'Failed to save designation.');
} finally {
setIsSaving(false);
}
@ -400,7 +447,7 @@ export default function DesignationManagementPage() {
{/* Table card */}
<div style={{ display: listTab() === 'view' ? 'none' : 'block' }}>
<div style="margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:hidden;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
<div style="position:relative;margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:visible;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
{/* Filter bar */}
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6">
@ -448,14 +495,14 @@ export default function DesignationManagementPage() {
</div>
</Show>
</div>
<button type="button" style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:600;color:white;border:none;cursor:pointer">
<button type="button" onClick={exportCsv} style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:600;color:white;border:none;cursor:pointer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Export
</button>
</div>
{/* Table */}
<div class="overflow-x-auto">
<div class="overflow-x-auto overflow-y-visible">
<table class="min-w-full">
<thead>
<tr style="background:#0D0D2A;text-align:left">
@ -501,14 +548,22 @@ export default function DesignationManagementPage() {
<td style="padding:12px 20px;position:relative">
<button
type="button"
onClick={() => setOpenMenuId(openMenuId() === row.id ? null : row.id)}
onClick={(e) => {
if (openMenuId() === row.id) {
setOpenMenuId(null);
return;
}
const rect = e.currentTarget.getBoundingClientRect();
setOpenMenuPos({ x: rect.right, y: rect.top - 8 });
setOpenMenuId(row.id);
}}
class="inline-flex h-8 w-8 items-center justify-center rounded-lg text-[#9CA3AF] hover:bg-[#F3F4F6] hover:text-[#374151] transition-colors"
aria-label="More actions"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
</button>
<Show when={openMenuId() === row.id}>
<div style="position:absolute;right:20px;top:44px;z-index:20;width:210px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 20px rgba(0,0,0,0.12)">
<div style={`position:fixed;left:${openMenuPos().x}px;top:${openMenuPos().y}px;transform:translate(-100%, -100%);z-index:9999;width:210px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 20px rgba(0,0,0,0.12)`}>
<button type="button" onClick={() => { setViewingRecord(row); setOpenMenuId(null); setListTab('view'); }} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:10px 12px;font-size:13px;font-weight:500;color:#374151;background:none;border:none;cursor:pointer;text-align:left">
<svg style="width:16px;height:16px;color:#FF5E13;flex-shrink:0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
View Details
@ -535,12 +590,8 @@ export default function DesignationManagementPage() {
<button
type="button"
onClick={async () => {
if (!window.confirm(`Delete designation "${row.name}"?`)) return;
try {
const res = await fetch(`${API}/api/admin/designations/${row.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`Request failed (${res.status})`);
} catch (err: any) { setError(err?.message || 'Failed to delete designation.'); }
finally { setOpenMenuId(null); await load(); }
setOpenMenuId(null);
setDeleteTarget(row);
}}
style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:10px 12px;font-size:13px;font-weight:500;color:#DC2626;background:none;border:none;cursor:pointer;text-align:left"
>
@ -577,6 +628,53 @@ export default function DesignationManagementPage() {
</div>
</Show>
<Show when={deleteTarget()}>
<div style="position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;background:rgba(17,24,39,0.45);padding:16px">
<div style="width:min(92vw,480px);border-radius:16px;border:1px solid #E5E7EB;background:white;box-shadow:0 24px 48px rgba(0,0,0,0.2)">
<div style="padding:20px 22px;border-bottom:1px solid #F3F4F6">
<h3 style="font-size:18px;font-weight:700;color:#111827">Delete Designation?</h3>
<p style="margin-top:8px;font-size:13px;line-height:1.5;color:#4B5563">
You are about to permanently delete
<strong style="color:#111827"> {deleteTarget()?.name}</strong>.
This action cannot be undone.
</p>
</div>
<div style="display:flex;justify-content:flex-end;gap:10px;padding:14px 22px">
<button
type="button"
onClick={() => setDeleteTarget(null)}
disabled={isDeleting()}
style="height:38px;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 14px;font-size:13px;font-weight:600;color:#374151;cursor:pointer"
>
Cancel
</button>
<button
type="button"
disabled={isDeleting()}
onClick={async () => {
const target = deleteTarget();
if (!target) return;
setIsDeleting(true);
try {
const res = await fetch(`${API}/api/admin/designations/${target.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`Request failed (${res.status})`);
setDeleteTarget(null);
await load();
} catch (err: any) {
setError(err?.message || 'Failed to delete designation.');
} finally {
setIsDeleting(false);
}
}}
style="height:38px;border-radius:10px;border:none;background:#0D0D2A;padding:0 14px;font-size:13px;font-weight:700;color:white;cursor:pointer"
>
{isDeleting() ? 'Deleting...' : 'Delete Designation'}
</button>
</div>
</div>
</div>
</Show>
{/* ── FORM VIEW (Create / Edit) ── */}
<Show when={view() === 'form'}>
{/* Top tabs */}

View file

@ -1,129 +1,13 @@
import { A } from '@solidjs/router';
import { createMemo, createResource, createSignal, For, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
async function fetchUsers(): Promise<any[]> {
try {
const res = await fetch(`${API}/api/admin/users?role=developer`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.users || []);
} catch {
return [];
}
}
import RoleUserManagementTablePage from '~/components/admin/RoleUserManagementTablePage';
export default function DevelopersPage() {
const [users] = createResource(fetchUsers);
const [search, setSearch] = createSignal('');
const [statusFilter, setStatusFilter] = createSignal('');
const filtered = createMemo(() => {
const list = users() ?? [];
const q = search().toLowerCase();
const s = statusFilter();
return list.filter((u) => {
const matchSearch =
!q ||
(u.name || u.full_name || '').toLowerCase().includes(q) ||
(u.email || '').toLowerCase().includes(q);
const matchStatus = !s || (u.status || '').toUpperCase() === s;
return matchSearch && matchStatus;
});
});
return (
<AdminShell>
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
{/* White page header */}
<div class="bg-white border-b border-gray-200 px-6 py-4">
<h1 class="text-xl font-semibold text-gray-900">Developers Management</h1>
<p class="text-sm text-gray-500 mt-0.5">Manage all developer accounts on the platform.</p>
</div>
{/* Content */}
<div class="flex-1 p-6">
{/* Search / Filters */}
<div class="mb-4 flex flex-wrap items-center gap-3">
<input
type="text"
placeholder="Search by name or email..."
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
class="rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] w-64"
/>
<select
value={statusFilter()}
onChange={(e) => setStatusFilter(e.currentTarget.value)}
class="rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37]"
>
<option value="">All Status</option>
<option value="ACTIVE">ACTIVE</option>
<option value="INACTIVE">INACTIVE</option>
<option value="PENDING">PENDING</option>
</select>
</div>
<div class="table-card">
<div class="overflow-x-auto">
<table class="data-table w-full text-sm">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Registered</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={users.loading}>
<tr><td colspan="5" class="text-center py-8 text-slate-500">Loading...</td></tr>
</Show>
<Show when={!users.loading && users.error}>
<tr><td colspan="5" class="text-center py-8 text-red-700">Failed to load. Is the backend running?</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length === 0}>
<tr><td colspan="5" class="text-center py-8 text-slate-400">No developer users found.</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length > 0}>
<For each={filtered()}>
{(item) => (
<tr class="hover:bg-slate-50">
<td class="font-semibold text-slate-900">{item.name || item.full_name || '—'}</td>
<td class="text-slate-500">{item.email}</td>
<td>
{item.status?.toUpperCase() === 'ACTIVE' && (
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-700">ACTIVE</span>
)}
{item.status?.toUpperCase() === 'INACTIVE' && (
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">INACTIVE</span>
)}
{item.status?.toUpperCase() === 'PENDING' && (
<span class="inline-flex items-center rounded-full bg-orange-50 px-2.5 py-0.5 text-xs font-medium text-orange-700 border border-orange-200">PENDING</span>
)}
{!item.status && <span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600"></span>}
</td>
<td class="text-slate-500">
{item.created_at ? new Date(item.created_at).toLocaleDateString() : '—'}
</td>
<td>
<div class="flex items-center justify-end gap-1">
<A class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href={`/admin/users/${item.id}`}>View</A>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</div>
</div>
</div>
</AdminShell>
<RoleUserManagementTablePage
role="developer"
title="Developers Management"
subtitle="Manage all developer accounts on the platform."
emptyLabel="No developer users found."
viewHref={(id) => `/admin/users/${id}`}
/>
);
}

View file

@ -5,11 +5,20 @@ import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
type Role = { id: string; name: string };
type Employee = { id: string; name?: string; full_name?: string; email: string; role_id?: string; role?: { id?: string } };
type Employee = { id: string; name?: string; full_name?: string; email: string; role_id?: string; role_name?: string; role?: { id?: string } };
async function fetchEmployee(id: string): Promise<Employee | null> {
try {
const res = await fetch(`${API}/api/admin/employees/${id}`);
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const res = await fetch(`${API}/api/admin/employees/${id}`, {
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
});
if (!res.ok) return null;
return res.json();
} catch {
@ -19,7 +28,16 @@ async function fetchEmployee(id: string): Promise<Employee | null> {
async function fetchRoles(): Promise<Role[]> {
try {
const res = await fetch(`${API}/api/admin/roles?audience=INTERNAL`);
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const res = await fetch(`${API}/api/admin/roles?audience=INTERNAL&per_page=100`, {
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
});
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : (data.roles || []);
@ -46,22 +64,34 @@ export default function EditEmployeePage() {
setName(e.name || e.full_name || '');
setEmail(e.email || '');
setRoleId(e.role_id || e.role?.id || '');
if (!(e.role_id || e.role?.id) && e.role_name) {
const byName = (roles() ?? []).find((r) => r.name.trim().toLowerCase() === String(e.role_name).trim().toLowerCase());
if (byName?.id) setRoleId(byName.id);
}
return null;
});
const submit = async (e: Event) => {
e.preventDefault();
if (!name().trim() || !email().trim() || !roleId()) {
setError('Name, email, and role are required.');
if (!roleId()) {
setError('Role is required.');
return;
}
try {
setSaving(true);
setError('');
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const res = await fetch(`${API}/api/admin/employees/${params.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name().trim(), email: email().trim(), role_id: roleId() }),
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
body: JSON.stringify({ role_id: roleId() }),
});
if (!res.ok) {
const payload = await res.json().catch(() => ({}));

View file

@ -1,5 +1,5 @@
import { A, useNavigate } from '@solidjs/router';
import { createResource, createSignal, For } from 'solid-js';
import { createResource, createSignal, For, onMount, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
@ -8,9 +8,32 @@ type Role = { id: string; name: string };
type Dept = { id: string; name: string };
type Desig = { id: string; name: string };
function parseEmployeeCodeNumber(code: string): number | null {
const normalized = String(code || '').trim().toUpperCase();
if (!normalized) return null;
const explicit = normalized.match(/^EMP[-_]?0*(\d+)$/);
if (explicit) return Number(explicit[1]);
const trailing = normalized.match(/(\d+)$/);
if (trailing) return Number(trailing[1]);
return null;
}
function formatEmployeeCode(value: number): string {
return `EMP-${String(Math.max(1, value)).padStart(4, '0')}`;
}
async function fetchRoles(): Promise<Role[]> {
try {
const res = await fetch(`${API}/api/admin/roles?audience=INTERNAL`);
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const res = await fetch(`${API}/api/admin/roles?audience=INTERNAL&per_page=100`, {
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
});
if (!res.ok) throw new Error();
const data = await res.json();
return Array.isArray(data) ? data : (data.roles ?? []);
@ -18,7 +41,16 @@ async function fetchRoles(): Promise<Role[]> {
}
async function fetchDepts(): Promise<Dept[]> {
try {
const res = await fetch(`${API}/api/admin/departments`);
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const res = await fetch(`${API}/api/admin/departments?per_page=100`, {
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
});
if (!res.ok) throw new Error();
const data = await res.json();
return Array.isArray(data) ? data : (data.departments ?? []);
@ -26,7 +58,16 @@ async function fetchDepts(): Promise<Dept[]> {
}
async function fetchDesigs(): Promise<Desig[]> {
try {
const res = await fetch(`${API}/api/admin/designations`);
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const res = await fetch(`${API}/api/admin/designations?per_page=100`, {
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
});
if (!res.ok) throw new Error();
const data = await res.json();
return Array.isArray(data) ? data : (data.designations ?? []);
@ -41,12 +82,65 @@ export default function CreateEmployeePage() {
const [fullName, setFullName] = createSignal('');
const [email, setEmail] = createSignal('');
const [employeeCode, setEmployeeCode] = createSignal('');
const [createLoginCreds, setCreateLoginCreds] = createSignal(true);
const [loginPassword, setLoginPassword] = createSignal('');
const [confirmLoginPassword, setConfirmLoginPassword] = createSignal('');
const [roleId, setRoleId] = createSignal('');
const [deptId, setDeptId] = createSignal('');
const [desigId, setDesigId] = createSignal('');
const [saving, setSaving] = createSignal(false);
const [generatingCode, setGeneratingCode] = createSignal(false);
const [error, setError] = createSignal('');
const fetchNextEmployeeCode = async (): Promise<string> => {
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
let page = 1;
let maxNum = 0;
while (page <= 100) {
const res = await fetch(`${API}/api/admin/employees?page=${page}&per_page=100&sort=joined_desc`, {
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
}).catch(() => null);
if (!res?.ok) break;
const payload = await res.json().catch(() => null);
const list: any[] = Array.isArray(payload)
? payload
: Array.isArray(payload?.employees)
? payload.employees
: Array.isArray(payload?.items)
? payload.items
: [];
if (!Array.isArray(list) || list.length === 0) break;
for (const item of list) {
const raw = String(item?.employee_id ?? item?.employeeId ?? item?.employee_code ?? '');
const parsed = parseEmployeeCodeNumber(raw);
if (parsed && parsed > maxNum) maxNum = parsed;
}
if (list.length < 100) break;
page += 1;
}
return formatEmployeeCode(maxNum + 1);
};
onMount(() => {
void (async () => {
setGeneratingCode(true);
try {
setEmployeeCode(await fetchNextEmployeeCode());
} catch {
setEmployeeCode('');
} finally {
setGeneratingCode(false);
}
})();
});
const handleSave = async (e: Event) => {
e.preventDefault();
if (!fullName().trim()) { setError('Full name is required'); return; }
@ -54,17 +148,90 @@ export default function CreateEmployeePage() {
if (!roleId()) { setError('Internal role is required'); return; }
if (!deptId()) { setError('Department is required'); return; }
if (!desigId()) { setError('Designation is required'); return; }
if (createLoginCreds()) {
if (loginPassword().trim().length < 8) { setError('Password must be at least 8 characters'); return; }
if (loginPassword().trim() !== confirmLoginPassword().trim()) { setError('Password and confirm password do not match'); return; }
}
setError(''); setSaving(true);
try {
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const getUsersByEmail = async () => {
const userRes = await fetch(`${API}/api/admin/users?q=${encodeURIComponent(email().trim())}&per_page=100`, {
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
}).catch(() => null);
const userPayload = await userRes?.json().catch(() => null);
const users = Array.isArray(userPayload)
? userPayload
: Array.isArray(userPayload?.users)
? userPayload.users
: Array.isArray(userPayload?.items)
? userPayload.items
: [];
const exact = users.find((u: any) => String(u?.email || '').trim().toLowerCase() === email().trim().toLowerCase());
const resolvedId =
userPayload?.user?.id
|| userPayload?.data?.user?.id
|| userPayload?.data?.id
|| exact?.id
|| userPayload?.id
|| users?.[0]?.id;
return resolvedId;
};
let userId = await getUsersByEmail();
if (!userId && createLoginCreds()) {
const regRes = await fetch(`${API}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
body: JSON.stringify({
full_name: fullName().trim() || email().trim(),
email: email().trim().toLowerCase(),
password: loginPassword().trim(),
}),
});
const regRaw = await regRes.text();
let regPayload: any = {};
if (regRaw) {
try { regPayload = JSON.parse(regRaw); } catch { regPayload = { message: regRaw }; }
}
if (!regRes.ok && (regPayload?.code || '').toString().toUpperCase() !== 'EMAIL_EXISTS') {
throw new Error(regPayload?.error || regPayload?.message || `Failed to create login credentials (${regRes.status})`);
}
userId =
regPayload?.user_id
|| regPayload?.id
|| regPayload?.user?.id
|| regPayload?.data?.user?.id
|| regPayload?.data?.id;
if (!userId) {
userId = await getUsersByEmail();
}
}
if (!userId) throw new Error('Unable to resolve or create login user for this employee.');
const res = await fetch(`${API}/api/admin/employees`, {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
full_name: fullName().trim(),
email: email().trim(),
user_id: userId,
role_id: roleId(),
department_id: deptId(),
designation_id: desigId(),
employee_code: employeeCode() || undefined,
}),
});
if (!res.ok) {
@ -139,6 +306,58 @@ export default function CreateEmployeePage() {
/>
</div>
{/* Employee ID */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Employee ID <span class="text-red-500">*</span>
</label>
<input
type="text"
readOnly
value={employeeCode()}
placeholder={generatingCode() ? 'Generating...' : 'Auto generated'}
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg bg-[#F9FAFB] text-[#111827] placeholder-[#9CA3AF]"
/>
</div>
{/* Login Credential Controls */}
<div class="col-span-2 rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] px-4 py-3">
<label class="flex items-center gap-2 text-[13px] font-medium text-[#111827]">
<input type="checkbox" checked={createLoginCreds()} onChange={e => setCreateLoginCreds(e.currentTarget.checked)} class="h-4 w-4 accent-[#FF5E13]" />
Create login credentials if this email does not exist
</label>
<p class="mt-1 text-[12px] text-[#6B7280]">When enabled, a user account is created with the password below and then linked as employee.</p>
</div>
<Show when={createLoginCreds()}>
<>
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Login Password <span class="text-red-500">*</span>
</label>
<input
type="password"
value={loginPassword()}
onInput={e => setLoginPassword(e.currentTarget.value)}
placeholder="Minimum 8 characters"
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
/>
</div>
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Confirm Password <span class="text-red-500">*</span>
</label>
<input
type="password"
value={confirmLoginPassword()}
onInput={e => setConfirmLoginPassword(e.currentTarget.value)}
placeholder="Repeat password"
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
/>
</div>
</>
</Show>
{/* Internal Role */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">

View file

@ -26,14 +26,9 @@ type EmployeeRecord = CrudRecord & {
shiftType?: string;
status: 'ACTIVE' | 'INACTIVE' | 'ON_LEAVE' | 'PROBATION' | 'SUSPENDED';
};
const FALLBACK_EMPLOYEES: EmployeeRecord[] = [
{ id: 'e1', name: 'Arjun Sharma', employeeId: 'EMP-001', email: 'arjun.s@nxtgauge.com', phone: '+91 98765 43210', department: 'Engineering', designation: 'Senior Software Engineer', internalRole: 'Admin', joiningDate: '2026-01-15', employmentType: 'FULL_TIME', status: 'ACTIVE', updatedAt: '2026-03-01' },
{ id: 'e2', name: 'Priya Nair', employeeId: 'EMP-002', email: 'priya.n@nxtgauge.com', phone: '+91 98765 43211', department: 'Marketing', designation: 'Marketing Manager', internalRole: 'Manager', joiningDate: '2026-01-20', employmentType: 'FULL_TIME', status: 'ACTIVE', updatedAt: '2026-03-01' },
{ id: 'e3', name: 'Rahul Verma', employeeId: 'EMP-003', email: 'rahul.v@nxtgauge.com', phone: '+91 98765 43212', department: 'Sales', designation: 'Sales Executive', internalRole: 'Staff', joiningDate: '2026-02-01', employmentType: 'FULL_TIME', status: 'PROBATION', updatedAt: '2026-03-01' },
{ id: 'e4', name: 'Sneha Rao', employeeId: 'EMP-004', email: 'sneha.r@nxtgauge.com', phone: '+91 98765 43213', department: 'Human Resources', designation: 'HR Specialist', internalRole: 'Staff', joiningDate: '2026-02-10', employmentType: 'CONTRACT', status: 'ACTIVE', updatedAt: '2026-03-01' },
{ id: 'e5', name: 'Vikram Singh', employeeId: 'EMP-005', email: 'vikram.s@nxtgauge.com', phone: '+91 98765 43214', department: 'Finance', designation: 'Financial Analyst', internalRole: 'Staff', joiningDate: '2026-02-15', employmentType: 'FULL_TIME', status: 'ON_LEAVE', updatedAt: '2026-03-01' },
];
type RoleOption = { id: string; name: string; key?: string };
type DepartmentOption = { id: string; name: string };
type DesignationOption = { id: string; name: string };
function normalizeEmployee(item: any, idx: number): EmployeeRecord {
const status = String(item.status ?? '').toUpperCase();
@ -53,6 +48,20 @@ function normalizeEmployee(item: any, idx: number): EmployeeRecord {
};
}
function parseEmployeeCodeNumber(code: string): number | null {
const normalized = String(code || '').trim().toUpperCase();
if (!normalized) return null;
const explicit = normalized.match(/^EMP[-_]?0*(\d+)$/);
if (explicit) return Number(explicit[1]);
const trailing = normalized.match(/(\d+)$/);
if (trailing) return Number(trailing[1]);
return null;
}
function formatEmployeeCode(value: number): string {
return `EMP-${String(Math.max(1, value)).padStart(4, '0')}`;
}
function StatusBadge(props: { status: string }) {
const getColors = () => {
switch (props.status) {
@ -119,13 +128,21 @@ export default function EmployeeManagementPage() {
const [status, setStatus] = createSignal<EmployeeRecord['status']>('ACTIVE');
const [employmentType, setEmploymentType] = createSignal('FULL_TIME');
const [joiningDate, setJoiningDate] = createSignal('');
const [createLoginCreds, setCreateLoginCreds] = createSignal(true);
const [loginPassword, setLoginPassword] = createSignal('');
const [confirmLoginPassword, setConfirmLoginPassword] = createSignal('');
const [isLoading, setIsLoading] = createSignal(false);
const [isSaving, setIsSaving] = createSignal(false);
const [isGeneratingEmployeeCode, setIsGeneratingEmployeeCode] = createSignal(false);
const [error, setError] = createSignal('');
const [rolesOptions, setRolesOptions] = createSignal<RoleOption[]>([]);
const [departmentOptions, setDepartmentOptions] = createSignal<DepartmentOption[]>([]);
const [designationOptions, setDesignationOptions] = createSignal<DesignationOption[]>([]);
const load = async () => {
setIsLoading(true);
setError('');
try {
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
@ -147,16 +164,82 @@ export default function EmployeeManagementPage() {
if (!res.ok) throw new Error();
const data = await res.json();
const list = Array.isArray(data) ? data : (data.employees ?? data.users ?? []);
if (list.length === 0) setRows(FALLBACK_EMPLOYEES);
else setRows(list.map(normalizeEmployee));
} catch {
setRows(FALLBACK_EMPLOYEES);
setRows((Array.isArray(list) ? list : []).map(normalizeEmployee));
} catch (e: any) {
setRows([]);
setError(e?.message || 'Could not reach employees API.');
} finally {
setIsLoading(false);
}
};
onMount(() => void load());
const loadMeta = async () => {
try {
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const common = {
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include' as const,
};
const [rolesRes, deptRes, desigRes] = await Promise.all([
fetch(`${API}/api/admin/roles?audience=INTERNAL&per_page=100`, common).catch(() => null),
fetch(`${API}/api/admin/departments?per_page=100`, common).catch(() => null),
fetch(`${API}/api/admin/designations?per_page=100`, common).catch(() => null),
]);
const rolesJson = await rolesRes?.json().catch(() => null);
const deptJson = await deptRes?.json().catch(() => null);
const desigJson = await desigRes?.json().catch(() => null);
const roles = Array.isArray(rolesJson) ? rolesJson : (rolesJson?.roles ?? []);
const depts = Array.isArray(deptJson) ? deptJson : (deptJson?.departments ?? []);
const desigs = Array.isArray(desigJson) ? desigJson : (desigJson?.designations ?? []);
setRolesOptions((Array.isArray(roles) ? roles : []).map((r: any) => ({ id: String(r.id || ''), name: String(r.name || ''), key: r.key ? String(r.key) : undefined })).filter((r: RoleOption) => r.id && r.name));
setDepartmentOptions((Array.isArray(depts) ? depts : []).map((d: any) => ({ id: String(d.id || ''), name: String(d.name || '') })).filter((d: DepartmentOption) => d.id && d.name));
setDesignationOptions((Array.isArray(desigs) ? desigs : []).map((d: any) => ({ id: String(d.id || ''), name: String(d.name || '') })).filter((d: DesignationOption) => d.id && d.name));
} catch {
// keep dropdowns empty; user can still view list
}
};
onMount(() => { void load(); void loadMeta(); });
const fetchNextEmployeeCode = async (): Promise<string> => {
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
let page = 1;
let maxNum = 0;
while (page <= 100) {
const res = await fetch(`${API}/api/admin/employees?page=${page}&per_page=100&sort=joined_desc`, {
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
}).catch(() => null);
if (!res?.ok) break;
const payload = await res.json().catch(() => null);
const list: any[] = Array.isArray(payload)
? payload
: Array.isArray(payload?.employees)
? payload.employees
: Array.isArray(payload?.items)
? payload.items
: [];
if (!Array.isArray(list) || list.length === 0) break;
for (const item of list) {
const raw = String(item?.employee_id ?? item?.employeeId ?? item?.employee_code ?? '');
const parsed = parseEmployeeCodeNumber(raw);
if (parsed && parsed > maxNum) maxNum = parsed;
}
if (list.length < 100) break;
page += 1;
}
return formatEmployeeCode(maxNum + 1);
};
const filteredRows = createMemo(() => {
let list = rows();
@ -188,15 +271,39 @@ export default function EmployeeManagementPage() {
setEditingId(null); setName(''); setEmpId(''); setEmail(''); setPhone('');
setDept(''); setDesig(''); setRole(''); setStatus('ACTIVE');
setEmploymentType('FULL_TIME'); setJoiningDate(''); setFormTab('basic'); setError('');
setCreateLoginCreds(true); setLoginPassword(''); setConfirmLoginPassword('');
};
const openCreate = () => { resetForm(); setView('form'); };
const openCreate = async () => {
resetForm();
setView('form');
setIsGeneratingEmployeeCode(true);
try {
setEmpId(await fetchNextEmployeeCode());
} catch {
setEmpId('');
} finally {
setIsGeneratingEmployeeCode(false);
}
};
const backToEmployeesList = async () => {
setView('list');
setListTab('all');
setOpenMenuId(null);
setSortMenuOpen(false);
setFilterMenuOpen(false);
await load();
};
const openEdit = (row: EmployeeRecord) => {
const roleMatch = rolesOptions().find((r) => r.name.trim().toLowerCase() === String(row.internalRole || '').trim().toLowerCase());
const deptMatch = departmentOptions().find((d) => d.name.trim().toLowerCase() === String(row.department || '').trim().toLowerCase());
const desigMatch = designationOptions().find((d) => d.name.trim().toLowerCase() === String(row.designation || '').trim().toLowerCase());
setEditingId(row.id);
setName(row.name); setEmpId(row.employeeId || ''); setEmail(row.email);
setPhone(row.phone || ''); setDept(row.department || ''); setDesig(row.designation || '');
setRole(row.internalRole || ''); setStatus(row.status);
setPhone(row.phone || ''); setDept(deptMatch?.id || ''); setDesig(desigMatch?.id || '');
setRole(roleMatch?.id || ''); setStatus(row.status);
setEmploymentType(row.employmentType || 'FULL_TIME'); setJoiningDate(row.joiningDate || '');
setFormTab('basic'); setView('form'); setOpenMenuId(null);
};
@ -210,19 +317,24 @@ export default function EmployeeManagementPage() {
};
const save = async () => {
if (!name().trim() || !email().trim()) {
if (!editingId() && (!name().trim() || !email().trim())) {
setError('Name and email are required.');
return;
}
setIsSaving(true);
try {
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const isEdit = Boolean(editingId());
let employeeCodeForCreate = empId().trim();
if (!isEdit && !employeeCodeForCreate) {
employeeCodeForCreate = await fetchNextEmployeeCode();
setEmpId(employeeCodeForCreate);
}
const body: Record<string, unknown> = {};
if (empId().trim()) body.employee_code = empId().trim();
// These fields expect IDs; leave blank if not provided
if (isEdit && empId().trim()) body.employee_code = empId().trim();
if (!isEdit && employeeCodeForCreate) body.employee_code = employeeCodeForCreate;
if (dept().trim()) body.department_id = dept().trim();
if (desig().trim()) body.designation_id = desig().trim();
if (role().trim()) body.role_id = role().trim();
@ -230,10 +342,9 @@ export default function EmployeeManagementPage() {
? `${API}/api/admin/employees/${editingId()}`
: `${API}/api/admin/employees`;
if (!isEdit) {
if (!role().trim()) throw new Error('Role ID is required to create employee');
if (!role().trim()) throw new Error('Internal role is required');
if (!email().trim()) throw new Error('Email required to resolve user');
// For initial version, require an existing user_id; resolve by email
const resUser = await fetch(`${API}/api/admin/users?email=${encodeURIComponent(email().trim())}`, {
const resUser = await fetch(`${API}/api/admin/users?q=${encodeURIComponent(email().trim())}&per_page=100`, {
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
@ -241,8 +352,68 @@ export default function EmployeeManagementPage() {
credentials: 'include',
}).catch(() => null);
const payload = await resUser?.json().catch(() => null);
const userId = payload?.user?.id || payload?.id || payload?.users?.[0]?.id;
if (!userId) throw new Error('User not found for the provided email');
const users = Array.isArray(payload)
? payload
: Array.isArray(payload?.users)
? payload.users
: Array.isArray(payload?.items)
? payload.items
: [];
const exact = users.find((u: any) => String(u?.email || '').trim().toLowerCase() === email().trim().toLowerCase());
let userId = payload?.user?.id || exact?.id || payload?.id || users?.[0]?.id;
if (!userId) {
if (!createLoginCreds()) {
throw new Error('User not found. Enable login credential creation or use an existing user email.');
}
if (loginPassword().trim().length < 8) {
throw new Error('Password must be at least 8 characters.');
}
if (loginPassword().trim() !== confirmLoginPassword().trim()) {
throw new Error('Password and confirm password do not match.');
}
const registerBody = {
full_name: name().trim() || email().trim(),
email: email().trim().toLowerCase(),
password: loginPassword().trim(),
};
const regRes = await fetch(`${API}/auth/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
credentials: 'include',
body: JSON.stringify(registerBody),
});
const regRaw = await regRes.text();
let regPayload: any = {};
if (regRaw) {
try { regPayload = JSON.parse(regRaw); } catch { regPayload = { message: regRaw }; }
}
if (!regRes.ok) {
if ((regPayload?.code || '').toString().toUpperCase() !== 'EMAIL_EXISTS') {
throw new Error(regPayload?.error || regPayload?.message || `Failed to create login credentials (${regRes.status})`);
}
}
userId = regPayload?.user_id || regPayload?.id || regPayload?.user?.id;
if (!userId) {
const retryUserRes = await fetch(`${API}/api/admin/users?q=${encodeURIComponent(email().trim())}&per_page=100`, {
headers: { Accept: 'application/json', ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}) },
credentials: 'include',
}).catch(() => null);
const retryPayload = await retryUserRes?.json().catch(() => null);
const retryUsers = Array.isArray(retryPayload)
? retryPayload
: Array.isArray(retryPayload?.users)
? retryPayload.users
: Array.isArray(retryPayload?.items)
? retryPayload.items
: [];
const retryExact = retryUsers.find((u: any) => String(u?.email || '').trim().toLowerCase() === email().trim().toLowerCase());
userId = retryPayload?.user?.id || retryExact?.id || retryPayload?.id || retryUsers?.[0]?.id;
}
}
if (!userId) throw new Error('Unable to resolve or create login user for this employee.');
body.user_id = userId;
}
const res = await fetch(endpoint, {
@ -255,7 +426,17 @@ export default function EmployeeManagementPage() {
credentials: 'include',
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Request failed (${res.status})`);
const raw = await res.text();
let message = '';
if (raw) {
try {
const parsed = JSON.parse(raw) as { message?: string; error?: string };
message = parsed?.message || parsed?.error || '';
} catch {
message = raw;
}
}
if (!res.ok) throw new Error(message || `Request failed (${res.status})`);
setView('list');
await load();
} catch (e: any) {
@ -280,8 +461,8 @@ export default function EmployeeManagementPage() {
{/* Tabs */}
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
{([
{ key: 'all', label: 'All Employees', action: () => { setListTab('all'); void load(); } },
{ key: 'create', label: 'Create Employee', action: () => { setListTab('create'); openCreate(); } },
{ key: 'all', label: 'All Employees', action: () => { void backToEmployeesList(); } },
{ key: 'create', label: 'Create Employee', action: () => { setListTab('create'); void openCreate(); } },
{ key: 'view', label: 'View Employee', action: () => { setListTab('view'); if (viewingEmp()) setView('detail'); } },
] as const).map((tab) => (
<button
@ -394,7 +575,7 @@ export default function EmployeeManagementPage() {
{/* ── FORM VIEW ── */}
<Show when={view() === 'form'}>
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
<button type="button" onClick={() => setView('list')} style="padding-bottom:12px;font-size:14px;font-weight:500;color:#6B7280;background:none;border:none;cursor:pointer">All Employees</button>
<button type="button" onClick={() => { void backToEmployeesList(); }} style="padding-bottom:12px;font-size:14px;font-weight:500;color:#6B7280;background:none;border:none;cursor:pointer">All Employees</button>
<button type="button" style="padding-bottom:12px;font-size:14px;font-weight:500;color:#FF5E13;border-bottom:2px solid #FF5E13;background:none;cursor:pointer;margin-bottom:-1px">{editingId() ? 'Edit Employee' : 'Create Employee'}</button>
</div>
@ -416,17 +597,61 @@ export default function EmployeeManagementPage() {
<Show when={formTab() === 'basic'}>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
<FormInput label="Full Name" required value={name()} onInput={setName} />
<FormInput label="Employee ID" required value={empId()} onInput={setEmpId} />
<label style="display:block">
<span style="font-size:13px;font-weight:600;color:#374151">Employee ID <span style="color:#FF5E13">*</span></span>
<input
type="text"
value={empId()}
readOnly
placeholder={isGeneratingEmployeeCode() ? 'Generating...' : 'Auto generated'}
style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;background:#F9FAFB;padding:0 14px;font-size:13px;color:#111827;outline:none;box-sizing:border-box"
/>
</label>
<FormInput label="Email Address" required value={email()} onInput={setEmail} type="email" />
<FormInput label="Phone Number" value={phone()} onInput={setPhone} />
<Show when={!editingId()}>
<>
<label style="display:block;grid-column:1 / span 2">
<span style="font-size:13px;font-weight:600;color:#374151">Create Login Credentials</span>
<div style="margin-top:8px;display:flex;align-items:center;gap:10px">
<input type="checkbox" checked={createLoginCreds()} onChange={(e) => setCreateLoginCreds(e.currentTarget.checked)} style="width:16px;height:16px;accent-color:#FF5E13;cursor:pointer" />
<span style="font-size:12px;color:#6B7280">Create a new login account if this email does not exist.</span>
</div>
</label>
<Show when={createLoginCreds()}>
<>
<FormInput label="Login Password" required value={loginPassword()} onInput={setLoginPassword} type="password" />
<FormInput label="Confirm Password" required value={confirmLoginPassword()} onInput={setConfirmLoginPassword} type="password" />
</>
</Show>
</>
</Show>
</div>
</Show>
<Show when={formTab() === 'work'}>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
<FormInput label="Department" value={dept()} onInput={setDept} />
<FormInput label="Designation" value={desig()} onInput={setDesig} />
<FormInput label="Internal Role" value={role()} onInput={setRole} />
<label style="display:block">
<span style="font-size:13px;font-weight:600;color:#374151">Department</span>
<select value={dept()} onChange={(e) => setDept(e.currentTarget.value)} style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 14px;font-size:13px;color:#111827;outline:none;box-sizing:border-box">
<option value="">Select department</option>
<For each={departmentOptions()}>{(d) => <option value={d.id}>{d.name}</option>}</For>
</select>
</label>
<label style="display:block">
<span style="font-size:13px;font-weight:600;color:#374151">Designation</span>
<select value={desig()} onChange={(e) => setDesig(e.currentTarget.value)} style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 14px;font-size:13px;color:#111827;outline:none;box-sizing:border-box">
<option value="">Select designation</option>
<For each={designationOptions()}>{(d) => <option value={d.id}>{d.name}</option>}</For>
</select>
</label>
<label style="display:block">
<span style="font-size:13px;font-weight:600;color:#374151">Internal Role <span style="color:#FF5E13">*</span></span>
<select value={role()} onChange={(e) => setRole(e.currentTarget.value)} style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 14px;font-size:13px;color:#111827;outline:none;box-sizing:border-box">
<option value="">Select role</option>
<For each={rolesOptions()}>{(r) => <option value={r.id}>{r.name}</option>}</For>
</select>
</label>
<FormInput label="Joining Date" value={joiningDate()} onInput={setJoiningDate} type="date" />
</div>
</Show>
@ -453,7 +678,7 @@ export default function EmployeeManagementPage() {
</div>
<div style="display:flex;align-items:center;justify-content:flex-end;gap:10px;border-top:1px solid #E5E7EB;padding:14px 24px">
<button type="button" onClick={() => setView('list')} style="height:38px;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 20px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Cancel</button>
<button type="button" onClick={() => { void backToEmployeesList(); }} style="height:38px;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 20px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Cancel</button>
<button type="button" onClick={save} style="height:38px;border-radius:10px;background:#0D0D2A;padding:0 24px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer">
{isSaving() ? 'Saving...' : editingId() ? 'Update Employee' : 'Create Employee'}
</button>

View file

@ -1,377 +1,694 @@
import { For, Show, createMemo, createSignal, onMount } from 'solid-js';
import { For, Show, createEffect, createMemo, createSignal, onMount } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import type { CrudRecord } from '~/lib/admin/types';
import DashboardDesignPreview from '~/components/admin/DashboardDesignPreview';
const API = '/api/gateway';
type DashboardTemplate = {
type RoleOption = { id: string; key: string; name: string };
type ExternalDashboard = {
id: string;
roleId: string;
roleKey: string;
name: string;
code: string;
userType: 'CUSTOMER' | 'PROFESSIONAL' | 'COMPANY' | 'JOBSEEKER';
role?: string;
widgetsCount: number;
widgets: string[];
tabs: string[];
sidebarItems: string[];
fields: string[];
previewPath: string;
status: 'ACTIVE' | 'INACTIVE' | 'DRAFT';
updatedAt: string;
lastUpdated?: string;
};
const FALLBACK_TEMPLATES: DashboardTemplate[] = [
{ id: 'dt1', name: 'Standard Professional Dashboard', code: 'DASH-PRO-01', userType: 'PROFESSIONAL', role: 'Photographer', widgetsCount: 12, status: 'ACTIVE', lastUpdated: '2026-03-25', updatedAt: '2026-03-27' },
{ id: 'dt2', name: 'Corporate Company View', code: 'DASH-COMP-01', userType: 'COMPANY', widgetsCount: 15, status: 'ACTIVE', lastUpdated: '2026-03-24', updatedAt: '2026-03-27' },
{ id: 'dt3', name: 'Jobseeker Profile Dashboard', code: 'DASH-JOB-01', userType: 'JOBSEEKER', widgetsCount: 8, status: 'DRAFT', lastUpdated: '2026-03-26', updatedAt: '2026-03-27' },
{ id: 'dt4', name: 'Basic Customer Portal', code: 'DASH-CUST-01', userType: 'CUSTOMER', widgetsCount: 6, status: 'ACTIVE', lastUpdated: '2026-03-20', updatedAt: '2026-03-27' },
const AVAILABLE_WIDGETS = ['kpi_summary', 'pending_approvals', 'user_growth', 'active_sessions', 'system_health', 'recent_activity', 'quick_actions', 'team_performance'];
const AVAILABLE_TABS = ['overview', 'approvals', 'users', 'reports', 'audit_logs', 'settings'];
const ROLE_BASED_SIDEBAR: Record<'PROFESSIONAL' | 'COMPANY' | 'JOB_SEEKER' | 'CUSTOMER', string[]> = {
PROFESSIONAL: [
'My Dashboard',
'My Profile',
'Leads',
'My Responses',
'Credits',
'Explore Nxtgauge',
'Verification',
'Help Center',
'Settings',
'Switch Services',
'Logout',
],
COMPANY: [
'My Dashboard',
'My Profile',
'Jobs',
'Applications',
'Shortlisted Candidates',
'Credits',
'Explore Nxtgauge',
'Verification',
'Help Center',
'Settings',
'Switch Services',
'Logout',
],
JOB_SEEKER: [
'My Dashboard',
'My Profile',
'Jobs',
'My Applications',
'Saved Jobs',
'Credits',
'Explore Nxtgauge',
'Verification',
'Help Center',
'Settings',
'Switch Services',
'Logout',
],
CUSTOMER: [
'My Dashboard',
'My Profile',
'My Requirements',
'Received Responses',
'Shortlisted Responses',
'Credits',
'Explore Nxtgauge',
'Verification',
'Help Center',
'Switch Services',
'Logout',
],
};
const AVAILABLE_SIDEBAR_ITEMS = Array.from(new Set(Object.values(ROLE_BASED_SIDEBAR).flat()));
const AVAILABLE_FIELDS = [
'full_name',
'email',
'phone',
'city',
'role_status',
'profile_completion',
'verification_status',
'kyc_status',
'approval_status',
'documents_submitted',
];
const AVAILABLE_WIDGETS = [
'Profile Completion Card', 'Verification Status Card', 'Credits Balance', 'Recent Orders',
'Application Status', 'Recent Activity', 'Quick Actions', 'Recommended Jobs'
];
function normalizeToken(value: string): string {
return String(value || '').trim().toLowerCase();
}
function humanizeLabel(value: string): string {
return String(value || '')
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}
function rolePreviewPath(roleKey: string): string {
const key = String(roleKey || '').toUpperCase();
if (key.includes('COMPANY')) return '/employers/dashboard';
if (key.includes('CUSTOMER')) return '/users/customer/dashboard';
if (key.includes('JOB_SEEKER') || key.includes('JOBSEEKER')) return '/users/candidate/dashboard';
if (key.includes('PHOTOGRAPHER')) return '/users/photographer/dashboard';
if (key.includes('MAKEUP')) return '/users/makeup/dashboard';
if (key.includes('TUTOR')) return '/users/tutors/dashboard';
if (key.includes('DEVELOPER')) return '/users/developers/dashboard';
if (key.includes('VIDEO')) return '/users/video-editors/dashboard';
if (key.includes('FITNESS')) return '/users/fitness-trainers/dashboard';
if (key.includes('GRAPHIC')) return '/users/graphic-designers/dashboard';
if (key.includes('SOCIAL')) return '/users/social-media-managers/dashboard';
if (key.includes('CATER')) return '/users/catering-services/dashboard';
return '/users/choose-role';
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((item) => String(item || '').trim()).filter(Boolean);
}
function normalizeDashboard(item: any): ExternalDashboard {
const cfg = item?.config_json ?? {};
const widgets = asStringArray(Array.isArray(cfg?.widgets) ? cfg.widgets.map((w: any) => (typeof w === 'string' ? w : (w?.key || w?.id))) : []);
const tabs = asStringArray(cfg?.tabs);
const sidebarItems = asStringArray(cfg?.sidebar_items ?? cfg?.sidebarItems);
const fields = asStringArray(cfg?.fields);
const previewPath = String(cfg?.preview_path || cfg?.previewPath || '').trim();
const isInactive = item?.is_active === false || String(item?.status || '').toUpperCase() === 'INACTIVE';
const isDraft = String(item?.status || '').toUpperCase() === 'DRAFT';
return {
id: String(item?.id || ''),
roleId: String(item?.role_id || ''),
roleKey: String(cfg?.role_key || cfg?.roleKey || item?.role_key || ''),
name: String(cfg?.name || item?.name || 'External Dashboard'),
code: String(cfg?.code || item?.code || ''),
widgets,
tabs,
sidebarItems,
fields,
previewPath,
status: isInactive ? 'INACTIVE' : (isDraft ? 'DRAFT' : 'ACTIVE'),
updatedAt: String(item?.updated_at || ''),
};
}
function StatusBadge(props: { status: string }) {
const active = () => props.status === 'ACTIVE';
const draft = () => props.status === 'DRAFT';
return (
<span style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${active() ? '#FFD8C2' : draft() ? '#E5E7EB' : '#D1D5DB'};background:${active() ? '#FFF1EB' : draft() ? '#F9FAFB' : '#F3F4F6'};color:${active() ? '#FF5E13' : draft() ? '#6B7280' : '#4B5563'};padding:2px 10px;font-size:12px;font-weight:500`}>
<span style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${active() ? '#FF5E13' : draft() ? '#9CA3AF' : '#9CA3AF'};margin-right:5px;flex-shrink:0`} />
{active() ? 'Active' : draft() ? 'Draft' : 'Inactive'}
<span style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${active() ? '#FFD8C2' : '#D1D5DB'};background:${active() ? '#FFF1EB' : '#F3F4F6'};color:${active() ? '#FF5E13' : '#4B5563'};padding:2px 10px;font-size:12px;font-weight:500`}>
{active() ? 'Active' : 'Inactive'}
</span>
);
}
export default function ExternalDashboardManagementPage() {
const [rows, setRows] = createSignal<ExternalDashboard[]>([]);
const [roles, setRoles] = createSignal<RoleOption[]>([]);
const [loading, setLoading] = createSignal(false);
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal('');
const [view, setView] = createSignal<'list' | 'form'>('list');
const [listTab, setListTab] = createSignal<'all' | 'create' | 'view'>('all');
const [formTab, setFormTab] = createSignal<'general' | 'builder' | 'visibility' | 'preview'>('general');
const [detailTab, setDetailTab] = createSignal<'overview' | 'widgets' | 'preview'>('overview');
const [editingId, setEditingId] = createSignal<string | null>(null);
const [formTab, setFormTab] = createSignal<'general' | 'tabs' | 'sidebar' | 'fields' | 'preview'>('general');
const [listTab, setListTab] = createSignal<'all' | 'create'>('all');
const [search, setSearch] = createSignal('');
const [statusFilter, setStatusFilter] = createSignal('all');
const [sortBy, setSortBy] = createSignal<'name_asc' | 'name_desc' | 'widgets_desc' | 'widgets_asc'>('name_asc');
const [statusFilter, setStatusFilter] = createSignal<'all' | 'active' | 'inactive'>('all');
const [sortBy, setSortBy] = createSignal<'name_asc' | 'name_desc' | 'updated_desc' | 'updated_asc'>('updated_desc');
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
const [rows, setRows] = createSignal<DashboardTemplate[]>([]);
const [viewingTemplate, setViewingTemplate] = createSignal<DashboardTemplate | null>(null);
const [editingId, setEditingId] = createSignal<string | null>(null);
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
const load = async () => {
setRows(FALLBACK_TEMPLATES);
const [name, setName] = createSignal('');
const [code, setCode] = createSignal('');
const [roleId, setRoleId] = createSignal('');
const [widgets, setWidgets] = createSignal<string[]>([]);
const [tabs, setTabs] = createSignal<string[]>([]);
const [sidebarItems, setSidebarItems] = createSignal<string[]>([]);
const [fields, setFields] = createSignal<string[]>([]);
const [previewPath, setPreviewPath] = createSignal('');
const [isActive, setIsActive] = createSignal(true);
const [dragWidget, setDragWidget] = createSignal<string | null>(null);
const [activePreviewSidebar, setActivePreviewSidebar] = createSignal('');
const [activePreviewTab, setActivePreviewTab] = createSignal('');
const rolePersonaById = createMemo(() => {
const map: Record<string, 'PROFESSIONAL' | 'COMPANY' | 'JOB_SEEKER' | 'CUSTOMER'> = {};
for (const role of roles()) {
const key = role.key.toUpperCase();
if (key.includes('COMPANY')) map[role.id] = 'COMPANY';
else if (key.includes('CUSTOMER')) map[role.id] = 'CUSTOMER';
else if (key.includes('JOB_SEEKER') || key.includes('JOBSEEKER')) map[role.id] = 'JOB_SEEKER';
else map[role.id] = 'PROFESSIONAL';
}
return map;
});
const sidebarLooksCustomer = createMemo(() => {
const joined = sidebarItems().join(' ').toLowerCase();
return joined.includes('my requirements')
|| joined.includes('received responses')
|| joined.includes('shortlisted responses');
});
const previewSidebarItems = createMemo(() => {
const selectedRoleId = roleId();
const persona = rolePersonaById()[selectedRoleId];
if (persona && ROLE_BASED_SIDEBAR[persona]?.length) return ROLE_BASED_SIDEBAR[persona];
if (sidebarLooksCustomer()) return ROLE_BASED_SIDEBAR.CUSTOMER;
if (sidebarItems().length) return sidebarItems();
return ['My Dashboard', 'My Profile', 'Switch Services', 'Logout'];
});
const previewTabs = createMemo(() => (tabs().length ? tabs() : ['overview']));
const selectedRoleKey = createMemo(() => {
const selected = roles().find((r) => r.id === roleId());
return selected?.key || '';
});
const authHeaders = () => {
const token = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('nxtgauge_admin_access_token') || '' : '';
return { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) };
};
onMount(() => void load());
const toggle = (list: () => string[], setter: (v: string[] | ((p: string[]) => string[])) => void, value: string) => {
setter((prev: string[]) => (prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value]));
};
const filteredRows = createMemo(() => {
let r = rows();
if (statusFilter() !== 'all') r = r.filter((d) => d.status === statusFilter().toUpperCase());
const q = search().toLowerCase();
if (q) {
r = r.filter(r => r.name.toLowerCase().includes(q) || r.code.toLowerCase().includes(q));
}
const sorted = [...r];
const mode = sortBy();
sorted.sort((a, b) => {
if (mode === 'name_desc') return b.name.localeCompare(a.name);
if (mode === 'widgets_desc') return b.widgetsCount - a.widgetsCount;
if (mode === 'widgets_asc') return a.widgetsCount - b.widgetsCount;
return a.name.localeCompare(b.name);
const addWidget = (key: string) => {
setWidgets((prev) => (prev.includes(key) ? prev : [...prev, key]));
};
const removeWidget = (key: string) => {
setWidgets((prev) => prev.filter((v) => v !== key));
};
const moveWidget = (movingKey: string, targetKey: string) => {
if (!movingKey || !targetKey || movingKey === targetKey) return;
setWidgets((prev) => {
const from = prev.indexOf(movingKey);
const to = prev.indexOf(targetKey);
if (from === -1 || to === -1) return prev;
const next = [...prev];
next.splice(from, 1);
next.splice(to, 0, movingKey);
return next;
});
return sorted;
};
const applySidebarPresetForRole = (selectedRoleId: string, force = false) => {
const persona = rolePersonaById()[selectedRoleId];
if (!persona) return;
if (!force && sidebarItems().length > 0) return;
setSidebarItems(ROLE_BASED_SIDEBAR[persona]);
};
const applyPreviewPathForRole = (selectedRoleId: string, force = false) => {
const role = roles().find((r) => r.id === selectedRoleId);
if (!role) return;
if (!force && previewPath().trim()) return;
setPreviewPath(rolePreviewPath(role.key));
};
const loadAll = async () => {
setLoading(true);
setError('');
try {
const [dashRes, rolesRes] = await Promise.all([
fetch(`${API}/api/admin/dashboard-config`, { headers: authHeaders(), credentials: 'include' }),
fetch(`${API}/api/admin/roles?audience=EXTERNAL&per_page=200`, { headers: authHeaders(), credentials: 'include' }),
]);
const dashData = dashRes.ok ? await dashRes.json().catch(() => []) : [];
const roleData = rolesRes.ok ? await rolesRes.json().catch(() => []) : [];
const dashRows = Array.isArray(dashData) ? dashData : (dashData?.items || dashData?.configs || []);
const roleRows = Array.isArray(roleData) ? roleData : (roleData?.roles || roleData?.items || []);
setRoles(roleRows
.filter((r: any) => String(r?.audience || '').toUpperCase() === 'EXTERNAL')
.map((r: any) => ({ id: String(r?.id || ''), key: String(r?.key || '').toUpperCase(), name: String(r?.name || r?.key || 'External Role') }))
.filter((r: RoleOption) => r.id));
setRows(dashRows
.filter((item: any) => String(item?.audience || '').toUpperCase() === 'EXTERNAL')
.map(normalizeDashboard)
.sort((a: ExternalDashboard, b: ExternalDashboard) => b.updatedAt.localeCompare(a.updatedAt)));
} catch (e: any) {
setRows([]);
setRoles([]);
setError(e?.message || 'Failed to load external dashboards.');
} finally {
setLoading(false);
}
};
onMount(() => void loadAll());
const filtered = createMemo(() => {
const q = search().trim().toLowerCase();
const next = rows().filter((row) => {
if (statusFilter() === 'active' && row.status !== 'ACTIVE') return false;
if (statusFilter() === 'inactive' && row.status === 'ACTIVE') return false;
if (!q) return true;
return row.name.toLowerCase().includes(q) || row.code.toLowerCase().includes(q) || row.roleKey.toLowerCase().includes(q);
});
next.sort((a, b) => {
if (sortBy() === 'name_asc') return a.name.localeCompare(b.name);
if (sortBy() === 'name_desc') return b.name.localeCompare(a.name);
if (sortBy() === 'updated_asc') return a.updatedAt.localeCompare(b.updatedAt);
return b.updatedAt.localeCompare(a.updatedAt);
});
return next;
});
const resetForm = () => {
setEditingId(null); setViewingTemplate(null); setFormTab('general');
setEditingId(null);
setFormTab('preview');
setName('');
setCode('');
setRoleId(roles()[0]?.id || '');
setWidgets([]);
setTabs([]);
setSidebarItems([]);
setFields([]);
setPreviewPath('');
setIsActive(true);
setActivePreviewSidebar('My Profile');
setActivePreviewTab('basic info');
};
const openCreate = () => { resetForm(); setView('form'); };
const openEdit = (row: DashboardTemplate) => { setEditingId(row.id); setViewingTemplate(row); setView('form'); setOpenMenuId(null); };
const openDetail = (row: DashboardTemplate) => { setViewingTemplate(row); setListTab('view'); setOpenMenuId(null); };
const openCreate = () => { resetForm(); setListTab('create'); setView('form'); };
const openEdit = (row: ExternalDashboard) => {
setEditingId(row.id);
setFormTab('preview');
setName(row.name);
setCode(row.code);
setRoleId(row.roleId);
setWidgets(row.widgets);
setTabs(row.tabs);
setSidebarItems(row.sidebarItems);
setFields(row.fields);
setPreviewPath(row.previewPath || rolePreviewPath(row.roleKey));
setIsActive(row.status === 'ACTIVE');
setActivePreviewSidebar('My Profile');
setActivePreviewTab('basic info');
setListTab('create');
setView('form');
};
createEffect(() => {
const selected = roleId();
if (!selected || view() !== 'form' || editingId()) return;
applySidebarPresetForRole(selected, false);
applyPreviewPathForRole(selected, false);
});
createEffect(() => {
const items = previewSidebarItems();
if (!items.includes(activePreviewSidebar())) setActivePreviewSidebar(items[0] || '');
});
createEffect(() => {
const list = previewTabs();
const active = normalizeToken(activePreviewTab());
if (!list.some((item) => normalizeToken(item) === active)) setActivePreviewTab(list[0] || '');
});
const hasTab = (key: string) => tabs().some((item) => normalizeToken(item) === normalizeToken(key));
const toggleTab = (key: string) => {
setTabs((prev) => (
prev.some((item) => normalizeToken(item) === normalizeToken(key))
? prev.filter((item) => normalizeToken(item) !== normalizeToken(key))
: [...prev, key]
));
};
const saveDashboard = async () => {
if (!name().trim()) return setError('Dashboard name is required.');
if (!roleId()) return setError('External role is required.');
const selectedRole = roles().find((r) => r.id === roleId());
if (!selectedRole) return setError('Selected role is invalid.');
setSaving(true);
setError('');
try {
const res = await fetch(`${API}/api/admin/dashboard-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
credentials: 'include',
body: JSON.stringify({
role_id: roleId(),
audience: 'EXTERNAL',
is_active: isActive(),
config_json: {
name: name().trim(),
code: code().trim() || `EXTERNAL-${selectedRole.key}`,
role_key: selectedRole.key,
widgets: widgets().map((key) => ({ key })),
tabs: tabs(),
sidebar_items: sidebarItems(),
fields: fields(),
preview_path: previewPath().trim() || rolePreviewPath(selectedRole.key),
switch_role: {
visible_if_multi_role: true,
},
explore_nxtgauge: {
enabled: true,
intent: 'role_marketplace_and_onboarding',
},
},
}),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(payload?.message || `Request failed (${res.status})`);
await loadAll();
setView('list');
setListTab('all');
resetForm();
} catch (e: any) {
setError(e?.message || 'Failed to save external dashboard.');
} finally {
setSaving(false);
}
};
return (
<AdminShell>
<div class="w-full space-y-6 pb-8">
<div style="margin-bottom: 1.5rem">
<div>
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">External Dashboard Management</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">Configure and manage dashboards for external user roles</p>
<p class="mt-1 text-[14px] text-[#6B7280]">Create dashboards with tabs, sidebar and fields, then preview before saving.</p>
</div>
{/* ── LIST VIEW ── */}
<Show when={view() === 'list'}>
<div>
{/* Tabs */}
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
{([
{ key: 'all', label: 'All Templates', action: () => { setListTab('all'); setStatusFilter('all'); } },
{ key: 'create', label: 'Create Template', action: () => { setListTab('create'); openCreate(); } },
{ key: 'view', label: 'View Template', action: () => setListTab('view') },
] as const).map((tab) => (
<button
type="button"
onClick={tab.action}
style={`padding-bottom:12px;font-size:14px;font-weight:500;background:none;border:none;cursor:pointer;${listTab() === tab.key ? 'color:#FF5E13;border-bottom:2px solid #FF5E13;margin-bottom:-1px' : 'color:#6B7280'}`}
>
{tab.label}
<Show when={error()}>
<div style="border-radius:10px;border:1px solid #FECACA;background:#FEF2F2;padding:12px 16px;font-size:13px;color:#B91C1C">{error()}</div>
</Show>
<Show when={view() === 'list'} fallback={
<div style="border-radius:16px;border:1px solid #E5E7EB;background:white;box-shadow:0 1px 4px rgba(0,0,0,0.06);overflow:hidden">
<div style="padding:18px 24px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center">
<p style="margin:0;font-size:16px;font-weight:700;color:#111827">{editingId() ? 'Edit External Dashboard' : 'Create External Dashboard'}</p>
<button type="button" onClick={() => { setView('list'); resetForm(); }} style="height:34px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 14px;font-size:12px;font-weight:700;color:#374151;cursor:pointer">Back</button>
</div>
<div style="display:flex;gap:4px;border-bottom:1px solid #E5E7EB;padding:0 16px;background:#FAFAFA">
{(['general', 'tabs', 'sidebar', 'fields', 'preview'] as const).map((tab) => (
<button type="button" onClick={() => setFormTab(tab)} style={`position:relative;padding:12px 10px;font-size:12px;font-weight:700;border:none;background:none;cursor:pointer;color:${formTab() === tab ? '#FF5E13' : '#6B7280'}`}>
{humanizeLabel(tab)}
<Show when={formTab() === tab}><span style="position:absolute;left:0;right:0;bottom:0;height:2px;background:#FF5E13" /></Show>
</button>
))}
</div>
{/* View Template panel */}
<Show when={listTab() === 'view'}>
<Show
when={!viewingTemplate()}
>
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;padding:48px 24px;text-align:center">
<p style="font-size:15px;font-weight:600;color:#111827">No template selected</p>
<p style="margin-top:6px;font-size:13px;color:#6B7280">Click the <strong></strong> menu on any template row and choose <strong>View Template</strong>.</p>
</div>
</Show>
<Show when={viewingTemplate()}>
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;box-shadow:0 1px 4px rgba(0,0,0,0.06);overflow:hidden">
<div style="padding:20px 24px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between">
<div>
<h2 style="font-size:18px;font-weight:700;color:#111827">{viewingTemplate()!.name}</h2>
<p style="margin-top:2px;font-size:13px;color:#6B7280">{viewingTemplate()!.code} {viewingTemplate()!.userType} {viewingTemplate()!.role && `(${viewingTemplate()!.role})`}</p>
</div>
<StatusBadge status={viewingTemplate()!.status} />
</div>
<div style="display:flex;align-items:center;gap:4px;border-bottom:1px solid #E5E7EB;padding:0 24px;background:#FAFAFA">
{(['overview', 'widgets', 'preview'] as const).map((tab, i) => {
const labels = ['Overview', 'Widget List', 'Live Preview'];
const active = () => detailTab() === tab;
return (
<button type="button" onClick={() => setDetailTab(tab)} style={`position:relative;padding:14px 12px;font-size:13px;font-weight:600;background:none;border:none;cursor:pointer;color:${active() ? '#FF5E13' : '#6B7280'}`}>
{labels[i]}
<Show when={active()}><span style="position:absolute;left:0;right:0;bottom:0;height:2px;background:#FF5E13;border-radius:2px 2px 0 0" /></Show>
</button>
);
})}
</div>
<div style="padding:24px">
<Show when={detailTab() === 'overview'}>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px">
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:20px">
<h3 style="font-size:14px;font-weight:700;color:#111827;margin-bottom:16px">Config Summary</h3>
<div style="display:flex;flex-direction:column;gap:12px">
{[
{ l: 'Template Code', v: viewingTemplate()!.code },
{ l: 'User Type', v: viewingTemplate()!.userType },
{ l: 'Widgets Count', v: viewingTemplate()!.widgetsCount },
{ l: 'Last Updated', v: viewingTemplate()!.lastUpdated || '—' },
].map(item => (
<div style="display:flex;justify-content:space-between">
<span style="font-size:13px;color:#6B7280">{item.l}</span>
<span style="font-size:13px;font-weight:600;color:#111827">{item.v}</span>
</div>
))}
</div>
<div style="padding:24px">
<Show when={formTab() === 'general'}>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<label style="display:block"><span style="font-size:13px;font-weight:600;color:#374151">Dashboard Name *</span><input value={name()} onInput={(e) => setName(e.currentTarget.value)} style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;padding:0 14px;font-size:13px" /></label>
<label style="display:block"><span style="font-size:13px;font-weight:600;color:#374151">Code</span><input value={code()} onInput={(e) => setCode(e.currentTarget.value)} placeholder="Optional" style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;padding:0 14px;font-size:13px" /></label>
<label style="display:block;grid-column:1 / -1"><span style="font-size:13px;font-weight:600;color:#374151">External Role *</span><select value={roleId()} onChange={(e) => setRoleId(e.currentTarget.value)} style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;padding:0 14px;font-size:13px;background:white"><For each={roles()}>{(r) => <option value={r.id}>{r.name} ({r.key})</option>}</For></select></label>
<div style="grid-column:1 / -1">
<span style="font-size:13px;font-weight:600;color:#374151">Widgets (Drag & Drop)</span>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:8px">
<div style="border:1px solid #E5E7EB;border-radius:10px;padding:10px;background:#FAFAFA">
<p style="margin:0;font-size:11px;font-weight:800;color:#6B7280;text-transform:uppercase">Available Widgets</p>
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:8px">
<For each={AVAILABLE_WIDGETS}>
{(key) => (
<button
type="button"
onClick={() => addWidget(key)}
style={`height:30px;border-radius:8px;padding:0 10px;font-size:11px;font-weight:700;cursor:pointer;border:1px solid ${widgets().includes(key) ? '#D1D5DB' : '#E5E7EB'};background:${widgets().includes(key) ? '#F3F4F6' : 'white'};color:#374151`}
>
+ {key}
</button>
)}
</For>
</div>
</div>
</Show>
</div>
<div style="display:flex;align-items:center;gap:10px;padding:14px 24px;border-top:1px solid #E5E7EB">
<button type="button" onClick={() => openEdit(viewingTemplate()!)} style="height:36px;border-radius:8px;background:#0D0D2A;padding:0 16px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer">Edit Template</button>
<button type="button" onClick={() => { setViewingTemplate(null); setListTab('all'); }} style="height:36px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 16px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Back to List</button>
<div
style="border:1px solid #E5E7EB;border-radius:10px;padding:10px;background:white;min-height:120px"
onDragOver={(e) => e.preventDefault()}
>
<p style="margin:0;font-size:11px;font-weight:800;color:#6B7280;text-transform:uppercase">Selected Order</p>
<Show
when={widgets().length > 0}
fallback={<p style="margin-top:10px;font-size:12px;color:#9CA3AF">No widgets selected yet.</p>}
>
<div style="display:flex;flex-direction:column;gap:8px;margin-top:8px">
<For each={widgets()}>
{(key) => (
<div
draggable
onDragStart={(e) => {
setDragWidget(key);
e.dataTransfer?.setData('text/plain', key);
}}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
moveWidget(dragWidget() || '', key);
setDragWidget(null);
}}
style={`display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px;border:1px solid #E5E7EB;border-radius:8px;background:${dragWidget() === key ? '#FFF7ED' : '#F9FAFB'};cursor:grab`}
>
<span style="font-size:12px;font-weight:700;color:#374151">:: {key}</span>
<button
type="button"
onClick={() => removeWidget(key)}
style="height:24px;border-radius:6px;border:1px solid #FECACA;background:#FEF2F2;padding:0 8px;font-size:11px;font-weight:700;color:#B91C1C;cursor:pointer"
>
Remove
</button>
</div>
)}
</For>
</div>
</Show>
</div>
</div>
</div>
<label style="display:flex;align-items:center;gap:8px;grid-column:1 / -1;font-size:13px;color:#374151"><input type="checkbox" checked={isActive()} onChange={(e) => setIsActive(e.currentTarget.checked)} style="width:16px;height:16px;accent-color:#0D0D2A" />Active dashboard</label>
</div>
</Show>
</Show>
<div style={{ display: listTab() === 'view' ? 'none' : 'block' }}>
<div style="margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:hidden;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6">
<input
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
placeholder="Search templates..."
style="height:34px;flex:1;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none"
<Show when={formTab() === 'tabs'}>
<div><p style="font-size:13px;font-weight:600;color:#374151">Dashboard Tabs</p><div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:8px"><For each={AVAILABLE_TABS}>{(key) => <button type="button" onClick={() => toggleTab(key)} style={`height:30px;border-radius:8px;padding:0 10px;font-size:11px;font-weight:700;cursor:pointer;border:1px solid ${hasTab(key) ? '#0D0D2A' : '#E5E7EB'};background:${hasTab(key) ? '#0D0D2A' : 'white'};color:${hasTab(key) ? 'white' : '#374151'}`}>{humanizeLabel(key)}</button>}</For></div></div>
</Show>
<Show when={formTab() === 'sidebar'}>
<div><p style="font-size:13px;font-weight:600;color:#374151">Sidebar Items</p><div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:8px"><For each={AVAILABLE_SIDEBAR_ITEMS}>{(key) => <button type="button" onClick={() => toggle(sidebarItems, setSidebarItems, key)} style={`height:30px;border-radius:8px;padding:0 10px;font-size:11px;font-weight:700;cursor:pointer;border:1px solid ${sidebarItems().includes(key) ? '#0D0D2A' : '#E5E7EB'};background:${sidebarItems().includes(key) ? '#0D0D2A' : 'white'};color:${sidebarItems().includes(key) ? 'white' : '#374151'}`}>{key}</button>}</For></div></div>
</Show>
<Show when={formTab() === 'fields'}>
<div><p style="font-size:13px;font-weight:600;color:#374151">Visible Fields</p><div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:8px"><For each={AVAILABLE_FIELDS}>{(key) => <button type="button" onClick={() => toggle(fields, setFields, key)} style={`height:30px;border-radius:8px;padding:0 10px;font-size:11px;font-weight:700;cursor:pointer;border:1px solid ${fields().includes(key) ? '#0D0D2A' : '#E5E7EB'};background:${fields().includes(key) ? '#0D0D2A' : 'white'};color:${fields().includes(key) ? 'white' : '#374151'}`}>{key}</button>}</For></div></div>
</Show>
<Show when={formTab() === 'preview'}>
<div style="display:flex;flex-direction:column;gap:10px">
<div style="border:1px solid #E5E7EB;border-radius:12px;background:#F9FAFB;padding:10px 12px">
<p style="margin:0;font-size:12px;font-weight:800;color:#374151">Config Snapshot (Instant)</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:8px">
<div>
<p style="margin:0;font-size:11px;font-weight:800;color:#6B7280;text-transform:uppercase">Widgets</p>
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:6px">
<For each={widgets().length ? widgets() : ['(none)']}>
{(w) => <span style="padding:2px 8px;border-radius:999px;border:1px solid #E5E7EB;background:white;font-size:11px;color:#374151">{w}</span>}
</For>
</div>
</div>
<div>
<p style="margin:0;font-size:11px;font-weight:800;color:#6B7280;text-transform:uppercase">Tabs</p>
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:6px">
<For each={tabs().length ? tabs() : ['(none)']}>
{(t) => <span style="padding:2px 8px;border-radius:999px;border:1px solid #E5E7EB;background:white;font-size:11px;color:#374151">{t}</span>}
</For>
</div>
</div>
</div>
</div>
<DashboardDesignPreview
status={isActive() ? 'ACTIVE' : 'INACTIVE'}
sidebarItems={previewSidebarItems()}
activeSidebar={activePreviewSidebar()}
onSidebarSelect={setActivePreviewSidebar}
tabs={previewTabs()}
activeTab={activePreviewTab()}
onTabSelect={setActivePreviewTab}
widgets={widgets()}
fields={fields()}
mode={'customer_external'}
roleKey={selectedRoleKey()}
exploreRoles={roles().map((r) => ({ key: r.key, name: r.name }))}
/>
<div style="position:relative">
<button
type="button"
onClick={() => { setSortMenuOpen((v) => !v); setFilterMenuOpen(false); }}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 4v13"/><path d="m3 13 4 4 4-4"/><path d="M17 20V7"/><path d="m21 11-4-4-4 4"/></svg>
Sort
</button>
<Show when={sortMenuOpen()}>
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:200px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
{(['name_asc', 'name_desc', 'widgets_desc', 'widgets_asc'] as const).map((s, i) => (
<button type="button" onClick={() => { setSortBy(s); setSortMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === s ? '#FF5E13' : '#374151'};background:${sortBy() === s ? '#FFF1EB' : 'transparent'}`}>
{['Name (A-Z)', 'Name (Z-A)', 'Widgets (High-Low)', 'Widgets (Low-High)'][i]}
</button>
))}
</div>
</Show>
</div>
<div style="position:relative">
<button
type="button"
onClick={() => { setFilterMenuOpen((v) => !v); setSortMenuOpen(false); }}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h18M6 12h12M10 19h4"/></svg>
Filters
</button>
<Show when={filterMenuOpen()}>
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:160px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
{(['all', 'active', 'inactive', 'draft'] as const).map((s) => (
<button type="button" onClick={() => { setStatusFilter(s); setFilterMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === s ? '#FF5E13' : '#374151'};background:${statusFilter() === s ? '#FFF1EB' : 'transparent'}`}>
{s === 'all' ? 'All Status' : s === 'active' ? 'Active' : s === 'inactive' ? 'Inactive' : 'Draft'}
</button>
))}
</div>
</Show>
</div>
</div>
</Show>
</div>
<div class="overflow-x-auto">
<table class="min-w-full">
<thead>
<tr style="background:#0D0D2A;text-align:left">
{['Template Name', 'Code', 'User Type', 'Widgets', 'Status', 'Actions'].map(h => (
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;white-space:nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
<For each={filteredRows()}>
<div style="padding:14px 24px;border-top:1px solid #E5E7EB;display:flex;justify-content:flex-end;gap:10px">
<button type="button" onClick={() => { setView('list'); resetForm(); }} style="height:38px;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 20px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Cancel</button>
<button type="button" onClick={() => void saveDashboard()} disabled={saving()} style="height:38px;border-radius:10px;background:#0D0D2A;padding:0 24px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer;opacity:0.95">{saving() ? 'Saving...' : (editingId() ? 'Update Dashboard' : 'Create Dashboard')}</button>
</div>
</div>
}>
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
<button type="button" onClick={() => { setListTab('all'); setView('list'); }} style={`padding-bottom:12px;font-size:14px;font-weight:500;background:none;border:none;cursor:pointer;${listTab() === 'all' ? 'color:#FF5E13;border-bottom:2px solid #FF5E13;margin-bottom:-1px' : 'color:#6B7280'}`}>All Dashboards</button>
<button type="button" onClick={openCreate} style={`padding-bottom:12px;font-size:14px;font-weight:500;background:none;border:none;cursor:pointer;${listTab() === 'create' ? 'color:#FF5E13;border-bottom:2px solid #FF5E13;margin-bottom:-1px' : 'color:#6B7280'}`}>Create Dashboard</button>
</div>
<div style="position:relative;margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:visible;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6">
<input value={search()} onInput={(e) => setSearch(e.currentTarget.value)} placeholder="Search dashboards..." style="height:34px;flex:1;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none" />
<div style="position:relative">
<button type="button" onClick={() => { setSortMenuOpen((v) => !v); setFilterMenuOpen(false); }} style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 4v13"/><path d="m3 13 4 4 4-4"/><path d="M17 20V7"/><path d="m21 11-4-4-4 4"/></svg>
Sort
</button>
<Show when={sortMenuOpen()}>
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:200px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
{([
['name_asc', 'Name (A-Z)'],
['name_desc', 'Name (Z-A)'],
['updated_desc', 'Updated (Newest)'],
['updated_asc', 'Updated (Oldest)'],
] as const).map(([key, label]) => (
<button type="button" onClick={() => { setSortBy(key); setSortMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === key ? '#FF5E13' : '#374151'};background:${sortBy() === key ? '#FFF1EB' : 'transparent'}`}>{label}</button>
))}
</div>
</Show>
</div>
<div style="position:relative">
<button type="button" onClick={() => { setFilterMenuOpen((v) => !v); setSortMenuOpen(false); }} style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h18M6 12h12M10 19h4"/></svg>
Filters
</button>
<Show when={filterMenuOpen()}>
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:160px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
{([
['all', 'All Status'],
['active', 'Active'],
['inactive', 'Inactive'],
] as const).map(([key, label]) => (
<button type="button" onClick={() => { setStatusFilter(key); setFilterMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === key ? '#FF5E13' : '#374151'};background:${statusFilter() === key ? '#FFF1EB' : 'transparent'}`}>{label}</button>
))}
</div>
</Show>
</div>
<button type="button" style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:600;color:white;border:none;cursor:pointer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Export
</button>
</div>
<div class="overflow-x-auto overflow-y-visible">
<table class="min-w-full">
<thead>
<tr style="background:#0D0D2A;text-align:left">
{['Name', 'Code', 'Role', 'Widgets', 'Status', 'Updated', 'Actions'].map((h) => (
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;white-space:nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
<Show when={!loading()} fallback={<tr><td colspan="7" style="padding:18px 20px;color:#6B7280;font-size:13px">Loading dashboards...</td></tr>}>
<Show when={filtered().length > 0} fallback={<tr><td colspan="7" style="padding:18px 20px;color:#6B7280;font-size:13px">No external dashboards found.</td></tr>}>
<For each={filtered()}>
{(row) => (
<tr style="border-bottom:1px solid #F3F4F6" class="hover:bg-[#FAFAFA] transition-colors">
<td style="padding:12px 20px;font-size:14px;font-weight:600;color:#111827">{row.name}</td>
<td style="padding:12px 20px;font-size:12px;font-family:monospace;color:#6B7280">{row.code}</td>
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.userType} {row.role && `(${row.role})`}</td>
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.widgetsCount} widgets</td>
<td style="padding:12px 20px;font-size:12px;color:#6B7280;font-family:monospace">{row.code || '—'}</td>
<td style="padding:12px 20px;font-size:13px;color:#374151">{row.roleKey || '—'}</td>
<td style="padding:12px 20px;font-size:13px;color:#374151">{row.widgets.length}</td>
<td style="padding:12px 20px"><StatusBadge status={row.status} /></td>
<td style="padding:12px 20px;position:relative">
<button type="button" onClick={() => setOpenMenuId(openMenuId() === row.id ? null : row.id)} style="display:inline-flex;height:32px;width:32px;align-items:center;justify-content:center;border-radius:8px;color:#9CA3AF;background:none;border:none;cursor:pointer">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
</button>
<Show when={openMenuId() === row.id}>
<div style="position:absolute;right:20px;top:44px;z-index:20;width:180px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 20px rgba(0,0,0,0.12)">
<button type="button" onClick={() => openDetail(row)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">View Template</button>
<button type="button" onClick={() => openEdit(row)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">Edit Template</button>
<button type="button" style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#DC2626;background:none;border:none;cursor:pointer;text-align:left">Delete</button>
</div>
</Show>
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.updatedAt ? new Date(row.updatedAt).toLocaleString() : '—'}</td>
<td style="padding:12px 20px">
<button type="button" onClick={() => openEdit(row)} style="height:30px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:700;color:#374151;cursor:pointer">Edit</button>
</td>
</tr>
)}
</For>
</tbody>
</table>
</Show>
</Show>
</tbody>
</table>
</div>
<Show when={filtered().length > 0}>
<div style="display:flex;align-items:center;justify-content:space-between;border-top:1px solid #F3F4F6;padding:12px 20px">
<p style="font-size:13px;color:#6B7280">
Showing <strong style="font-weight:600;color:#111827">1{filtered().length}</strong> of <strong style="font-weight:600;color:#111827">{filtered().length}</strong> dashboards
</p>
<div style="display:flex;align-items:center;gap:4px">
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#6B7280;cursor:pointer;font-size:15px"></button>
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;background:#FF5E13;color:white;font-size:13px;font-weight:600;border:none;cursor:pointer">1</button>
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#6B7280;cursor:pointer;font-size:15px"></button>
</div>
</div>
</div>
</Show>
</div>
</Show>
{/* ── FORM VIEW ── */}
<Show when={view() === 'form'}>
<div>
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
<button type="button" onClick={() => { setView('list'); resetForm(); }} style="padding-bottom:12px;font-size:14px;font-weight:500;color:#6B7280;background:none;border:none;cursor:pointer">All Templates</button>
<button type="button" style="padding-bottom:12px;font-size:14px;font-weight:500;color:#FF5E13;border-bottom:2px solid #FF5E13;background:none;cursor:pointer;margin-bottom:-1px">{editingId() ? 'Edit Template' : 'Create Template'}</button>
</div>
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;box-shadow:0 1px 4px rgba(0,0,0,0.06);overflow:hidden">
<div style="display:flex;align-items:center;gap:4px;border-bottom:1px solid #E5E7EB;padding:0 24px">
{(['general', 'builder', 'visibility', 'preview'] as const).map((tab, i) => {
const labels = ['General Config', 'Widget Builder', 'Visibility Rules', 'Real Preview'];
const active = () => formTab() === tab;
return (
<button type="button" onClick={() => setFormTab(tab)} style={`position:relative;padding:14px 8px;font-size:13px;font-weight:500;background:none;border:none;cursor:pointer;color:${active() ? '#FF5E13' : '#6B7280'}`}>
{labels[i]}
<Show when={active()}><span style="position:absolute;left:0;right:0;bottom:0;height:2px;background:#FF5E13;border-radius:2px 2px 0 0" /></Show>
</button>
);
})}
</div>
<div style="padding:24px">
<Show when={formTab() === 'general'}>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
<label style="display:block">
<span style="font-size:13px;font-weight:600;color:#374151">Dashboard Name *</span>
<input value={viewingTemplate()?.name || ''} style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;padding:0 14px;font-size:13px" />
</label>
<label style="display:block">
<span style="font-size:13px;font-weight:600;color:#374151">External User Type *</span>
<select style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;padding:0 14px;font-size:13px;background:white">
<option selected={viewingTemplate()?.userType === 'CUSTOMER'}>Customer</option>
<option selected={viewingTemplate()?.userType === 'PROFESSIONAL'}>Professional</option>
<option selected={viewingTemplate()?.userType === 'COMPANY'}>Company</option>
<option selected={viewingTemplate()?.userType === 'JOBSEEKER'}>Jobseeker</option>
</select>
</label>
</div>
</Show>
<Show when={formTab() === 'builder'}>
<div style="display:grid;grid-template-columns:250px 1fr;gap:24px;min-height:400px">
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:16px;background:#F9FAFB">
<h4 style="font-size:12px;font-weight:700;color:#9CA3AF;text-transform:uppercase;margin-bottom:12px">Available Widgets</h4>
<div style="display:flex;flex-direction:column;gap:8px">
<For each={AVAILABLE_WIDGETS}>
{(w) => (
<div style="padding:10px;background:white;border:1px solid #E5E7EB;border-radius:8px;font-size:12px;font-weight:600;color:#374151;cursor:move">
{w}
</div>
)}
</For>
</div>
</div>
<div style="border:2px dashed #E5E7EB;border-radius:12px;display:flex;align-items:center;justify-content:center;background:#FAFAFA">
<p style="font-size:14px;color:#9CA3AF">Drag and drop widgets here to build the layout</p>
</div>
</div>
</Show>
<Show when={formTab() === 'preview'}>
<div style="border:1px solid #E5E7EB;border-radius:12px;overflow:hidden;background:#F3F4F6;min-height:500px">
<div style="background:white;padding:16px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center">
<h3 style="font-size:16px;font-weight:700;color:#111827">Dashboard Preview</h3>
<div style="display:flex;gap:8px">
<button style="height:32px;padding:0 12px;border-radius:6px;background:#0D0D2A;color:white;font-size:12px;font-weight:600;border:none">Customer View</button>
<button style="height:32px;padding:0 12px;border-radius:6px;background:white;border:1px solid #E5E7EB;color:#374151;font-size:12px;font-weight:600">Professional View</button>
</div>
</div>
<div style="padding:24px">
<div style="display:grid;grid-template-columns:repeat(3, 1fr);gap:16px">
<div style="height:100px;background:white;border-radius:12px;border:1px solid #E5E7EB;padding:16px">
<p style="font-size:11px;color:#6B7280;text-transform:uppercase;font-weight:700">Profile Completion</p>
<p style="font-size:24px;font-weight:700;color:#111827;margin-top:8px">85%</p>
</div>
<div style="height:100px;background:white;border-radius:12px;border:1px solid #E5E7EB;padding:16px">
<p style="font-size:11px;color:#6B7280;text-transform:uppercase;font-weight:700">Active Requests</p>
<p style="font-size:24px;font-weight:700;color:#111827;margin-top:8px">4</p>
</div>
<div style="height:100px;background:white;border-radius:12px;border:1px solid #E5E7EB;padding:16px">
<p style="font-size:11px;color:#6B7280;text-transform:uppercase;font-weight:700">Verification</p>
<StatusBadge status="ACTIVE" />
</div>
</div>
</div>
</div>
</Show>
</div>
<div style="display:flex;align-items:center;justify-content:flex-end;gap:10px;border-top:1px solid #E5E7EB;padding:14px 24px">
<button type="button" onClick={() => { setView('list'); resetForm(); }} style="height:38px;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 20px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Cancel</button>
<button type="button" style="height:38px;border-radius:10px;background:#0D0D2A;padding:0 24px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer">
{editingId() ? 'Update Template' : 'Create Template'}
</button>
</div>
</div>
</div>
</Show>
</div>
</AdminShell>
);
}

View file

@ -25,22 +25,40 @@ type ExternalRoleRecord = {
updatedAt: string;
};
const FALLBACK_ROLES: ExternalRoleRecord[] = [
{
id: 'er1', name: 'Professional Photographer', code: 'photographer', vertical: 'marketplace', category: 'provider',
onboardingSchemaId: 'photographer_onboarding_v1', modules: ['dashboard', 'profile', 'portfolio', 'services', 'leads', 'wallet'],
permissions: { dashboard: ['read'], profile: ['read', 'update'], leads: ['read', 'update'] },
requiresOnboardingApproval: true, requiresLeadApproval: true, requiresJobApproval: false,
featureLimits: '{}', status: 'ACTIVE', assignedUsers: 45, assignedUserTypes: ['PHOTOGRAPHER', 'CREATIVE'], createdDate: '2026-01-15', updatedAt: '2026-03-27'
},
{
id: 'er2', name: 'Verified Company', code: 'company', vertical: 'jobs', category: 'employer',
onboardingSchemaId: 'company_onboarding_v1', modules: ['dashboard', 'profile', 'jobs', 'applications'],
permissions: { jobs: ['read', 'create', 'update'], applications: ['read', 'approve'] },
requiresOnboardingApproval: true, requiresLeadApproval: false, requiresJobApproval: true,
featureLimits: '{"maxActiveJobs": 5}', status: 'ACTIVE', assignedUsers: 120, assignedUserTypes: ['COMPANY', 'ENTERPRISE'], createdDate: '2026-02-10', updatedAt: '2026-03-27'
},
];
function normalizeExternalRole(item: any, index: number): ExternalRoleRecord {
const runtime = item?.runtime ?? item?.config_json ?? {};
const permissionsObj = runtime?.permissions && typeof runtime.permissions === 'object'
? runtime.permissions
: (item?.permissions && typeof item.permissions === 'object' ? item.permissions : {});
return {
id: String(item?.id ?? `external-role-${index + 1}`),
name: String(item?.name ?? runtime?.displayName ?? ''),
code: String(item?.code ?? item?.key ?? runtime?.roleKey ?? ''),
vertical: String(item?.vertical ?? runtime?.vertical ?? 'marketplace') === 'jobs' ? 'jobs' : 'marketplace',
category: (String(item?.category ?? runtime?.category ?? runtime?.roleCategory ?? 'provider') as ExternalRoleRecord['category']),
onboardingSchemaId: String(item?.onboarding_schema_id ?? runtime?.onboarding_schema_id ?? runtime?.onboardingSchemaId ?? ''),
modules: Array.isArray(item?.modules)
? item.modules.map((v: any) => String(v))
: (Array.isArray(runtime?.enabledModules)
? runtime.enabledModules.map((v: any) => String(v))
: (Array.isArray(runtime?.modules) ? runtime.modules.map((v: any) => String(v)) : [])),
permissions: Object.entries(permissionsObj || {}).reduce((acc, [k, v]) => {
acc[String(k)] = Array.isArray(v) ? v.map((x: any) => String(x).toLowerCase()) : [];
return acc;
}, {} as Record<string, string[]>),
requiresOnboardingApproval: Boolean(item?.requires_onboarding_approval ?? runtime?.requiresOnboardingApproval ?? runtime?.requires?.onboarding ?? false),
requiresLeadApproval: Boolean(item?.requires_lead_approval ?? runtime?.requiresLeadApproval ?? runtime?.requires?.lead ?? false),
requiresJobApproval: Boolean(item?.requires_job_approval ?? runtime?.requiresJobApproval ?? runtime?.requires?.job ?? false),
featureLimits: JSON.stringify(item?.feature_limits ?? runtime?.featureLimits ?? runtime?.feature_limits ?? {}, null, 2),
status: item?.is_active === false || String(item?.status ?? '').toUpperCase() === 'INACTIVE' ? 'INACTIVE' : 'ACTIVE',
assignedUsers: Number(item?.assigned_users ?? 0),
assignedUserTypes: Array.isArray(item?.assigned_user_types)
? item.assigned_user_types.map((v: any) => String(v))
: (Array.isArray(runtime?.assignedUserTypes) ? runtime.assignedUserTypes.map((v: any) => String(v)) : []),
createdDate: String(item?.created_date ?? ''),
updatedAt: String(item?.updated_at ?? ''),
};
}
const USER_TYPE_OPTIONS = ['COMPANY', 'CANDIDATE', 'PHOTOGRAPHER', 'MAKEUP_ARTIST', 'TUTOR', 'DEVELOPER', 'VIDEO_EDITOR', 'FITNESS_TRAINER', 'CATERER', 'GRAPHIC_DESIGNER', 'SOCIAL_MEDIA_MANAGER', 'CUSTOMER'];
@ -168,7 +186,36 @@ export default function ExternalRoleManagementPage() {
}
});
const load = async () => { setRows(FALLBACK_ROLES); };
const load = async () => {
setError('');
try {
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
const params = new URLSearchParams({ page: '1', per_page: '100' });
const q = search().trim();
if (q) params.set('q', q);
if (statusFilter() !== 'all') params.set('status', statusFilter());
if (verticalFilter() !== 'all') params.set('vertical', verticalFilter());
if (categoryFilter() !== 'all') params.set('category', categoryFilter());
params.set('audience', 'EXTERNAL');
const res = await fetch(`${API}/api/admin/roles?${params.toString()}`, {
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
});
if (!res.ok) throw new Error(`Request failed (${res.status})`);
const payload = await res.json().catch(() => null);
const list = Array.isArray(payload) ? payload : (payload?.roles || payload?.items || []);
const externals = (Array.isArray(list) ? list : []).filter((row: any) => String(row?.audience || '').toUpperCase() === 'EXTERNAL');
setRows(externals.map(normalizeExternalRole));
} catch (e: any) {
setRows([]);
setError(e?.message || 'Could not reach external roles API.');
}
};
onMount(() => void load());
const filteredRows = createMemo(() => {
@ -191,6 +238,31 @@ export default function ExternalRoleManagementPage() {
return sorted;
});
const exportCsv = () => {
const headers = ['Role Name', 'Role Code', 'Vertical', 'Category', 'Onboarding Schema', 'Status', 'Users'];
const rowsData = filteredRows().map((row) => [
row.name,
row.code,
row.vertical,
row.category,
row.onboardingSchemaId,
row.status,
String(row.assignedUsers),
]);
const csv = [headers, ...rowsData]
.map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `external-roles-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const moduleOptions = createMemo(() => MODULES_BY_VERTICAL[vertical()]);
const resetForm = () => {
@ -202,6 +274,14 @@ export default function ExternalRoleManagementPage() {
};
const openCreate = () => { resetForm(); setView('form'); };
const backToList = async () => {
resetForm();
setView('list');
setListTab('all');
setViewingRole(null);
setOpenMenuId(null);
await load();
};
const openEdit = (row: ExternalRoleRecord) => {
setEditingId(row.id); setName(row.name); setCode(row.code); setVertical(row.vertical);
setCategory(row.category); setAssignedUserTypes(row.assignedUserTypes || []); setOnboardingId(row.onboardingSchemaId);
@ -258,10 +338,64 @@ export default function ExternalRoleManagementPage() {
setError('');
try {
// Simulate API call
await new Promise(r => setTimeout(r, 1000));
resetForm();
setView('list');
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
const runtimePayload = {
vertical: vertical(),
category: category(),
onboarding_schema_id: onboardingId(),
modules: enabledModules(),
permissions: permissions(),
assigned_user_types: assignedUserTypes(),
requires: {
onboarding: reqOnbAppr(),
lead: reqLeadAppr(),
job: reqJobAppr(),
},
feature_limits: JSON.parse(limitsJson()),
};
const endpoint = editingId()
? `${API}/api/admin/roles/${editingId()}`
: `${API}/api/admin/roles`;
const method = editingId() ? 'PATCH' : 'POST';
const body = {
key: code().trim().toUpperCase(),
name: name().trim(),
audience: 'EXTERNAL',
config_json: {
roleKey: code().trim().toLowerCase(),
displayName: name().trim(),
vertical: runtimePayload.vertical,
roleCategory: runtimePayload.category,
enabledModules: runtimePayload.modules,
permissions: runtimePayload.permissions,
onboardingSchemaId: runtimePayload.onboarding_schema_id,
requiresOnboardingApproval: runtimePayload.requires.onboarding,
requiresLeadApproval: runtimePayload.requires.lead,
requiresJobApproval: runtimePayload.requires.job,
featureLimits: runtimePayload.feature_limits,
assignedUserTypes: runtimePayload.assigned_user_types,
version: 1,
},
is_active: status() === 'ACTIVE',
};
const res = await fetch(endpoint, {
method,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error((data as any).message || `Request failed (${res.status})`);
}
await load();
await backToList();
} catch (err: any) {
setError(err?.message || 'Failed to save role.');
} finally {
@ -269,6 +403,29 @@ export default function ExternalRoleManagementPage() {
}
};
const deleteRole = async (id: string, roleName: string) => {
if (!window.confirm(`Delete external role "${roleName}"?`)) return;
try {
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
const res = await fetch(`${API}/api/admin/roles/${id}`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
});
if (!res.ok && res.status !== 204) throw new Error(`Request failed (${res.status})`);
if (viewingRole()?.id === id) setViewingRole(null);
setOpenMenuId(null);
await load();
} catch (e: any) {
setError(e?.message || 'Failed to delete role.');
}
};
return (
<AdminShell>
<div class="w-full space-y-6 pb-8">
@ -496,6 +653,10 @@ export default function ExternalRoleManagementPage() {
</div>
</Show>
</div>
<button type="button" onClick={exportCsv} style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:600;color:white;border:none;cursor:pointer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Export
</button>
</div>
<div class="overflow-x-auto">
@ -532,7 +693,7 @@ export default function ExternalRoleManagementPage() {
<div style="position:absolute;right:20px;top:44px;z-index:20;width:180px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 20px rgba(0,0,0,0.12)">
<button type="button" onClick={() => { setViewingRole(row); setListTab('view'); setOpenMenuId(null); }} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">View Details</button>
<button type="button" onClick={() => openEdit(row)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">Edit Role</button>
<button type="button" style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#DC2626;background:none;border:none;cursor:pointer;text-align:left">Delete Role</button>
<button type="button" onClick={() => void deleteRole(row.id, row.name)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#DC2626;background:none;border:none;cursor:pointer;text-align:left">Delete Role</button>
</div>
</Show>
</td>
@ -551,7 +712,7 @@ export default function ExternalRoleManagementPage() {
<Show when={view() === 'form'}>
<div>
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
<button type="button" onClick={() => resetForm()} style="padding-bottom:12px;font-size:14px;font-weight:500;color:#6B7280;background:none;border:none;cursor:pointer">All External Roles</button>
<button type="button" onClick={() => void backToList()} style="padding-bottom:12px;font-size:14px;font-weight:500;color:#6B7280;background:none;border:none;cursor:pointer">All External Roles</button>
<button type="button" style="padding-bottom:12px;font-size:14px;font-weight:500;color:#FF5E13;border-bottom:2px solid #FF5E13;background:none;cursor:pointer;margin-bottom:-1px">{editingId() ? 'Edit External Role' : 'Create External Role'}</button>
</div>
@ -713,7 +874,7 @@ export default function ExternalRoleManagementPage() {
</div>
<div style="display:flex;align-items:center;justify-content:flex-end;gap:10px;border-top:1px solid #E5E7EB;padding:14px 24px">
<button type="button" onClick={() => resetForm()} style="height:38px;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 20px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Cancel</button>
<button type="button" onClick={() => void backToList()} style="height:38px;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 20px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Cancel</button>
<button type="button" onClick={() => void save()} disabled={isSaving()} style={`height:38px;border-radius:10px;background:#0D0D2A;padding:0 24px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer;opacity:${isSaving() ? 0.7 : 1}`}>
{isSaving() ? 'Saving...' : editingId() ? 'Update Role' : 'Create Role'}
</button>
@ -831,7 +992,7 @@ export default function ExternalRoleManagementPage() {
</div>
<div style="padding:16px 24px;border-top:1px solid #E5E7EB;display:flex;justify-content:flex-end">
<button type="button" onClick={() => resetForm()} style="height:36px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 16px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Back to List</button>
<button type="button" onClick={() => void backToList()} style="height:36px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 16px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Back to List</button>
</div>
</div>
</Show>

View file

@ -1,125 +1,13 @@
import { A } from '@solidjs/router';
import { createMemo, createResource, createSignal, For, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
async function fetchUsers(): Promise<any[]> {
try {
const res = await fetch(`${API}/api/admin/users?role=fitness_trainer`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.users || []);
} catch {
return [];
}
}
import RoleUserManagementTablePage from '~/components/admin/RoleUserManagementTablePage';
export default function FitnessTrainersPage() {
const [users] = createResource(fetchUsers);
const [search, setSearch] = createSignal('');
const [statusFilter, setStatusFilter] = createSignal('');
const filtered = createMemo(() => {
const list = users() ?? [];
const q = search().toLowerCase();
const s = statusFilter();
return list.filter((u) => {
const matchSearch =
!q ||
(u.name || u.full_name || '').toLowerCase().includes(q) ||
(u.email || '').toLowerCase().includes(q);
const matchStatus = !s || (u.status || '').toUpperCase() === s;
return matchSearch && matchStatus;
});
});
return (
<AdminShell>
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
<div class="bg-white border-b border-gray-200 px-6 py-4">
<h1 class="text-xl font-semibold text-gray-900">Fitness Trainer Management</h1>
<p class="text-sm text-gray-500 mt-0.5">Manage all fitness trainer accounts on the platform.</p>
</div>
<div class="flex-1 p-6">
<div class="table-card">
<div style="display:flex;gap:12px;padding:16px;border-bottom:1px solid #e2e8f0;flex-wrap:wrap;">
<input
type="text"
placeholder="Search by name or email..."
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
style="border:1px solid #cbd5e1;border-radius:6px;padding:8px 12px;font-size:14px;width:260px;"
/>
<select
value={statusFilter()}
onChange={(e) => setStatusFilter(e.currentTarget.value)}
style="border:1px solid #cbd5e1;border-radius:6px;padding:8px 12px;font-size:14px;"
>
<option value="">All Status</option>
<option value="ACTIVE">ACTIVE</option>
<option value="INACTIVE">INACTIVE</option>
<option value="PENDING">PENDING</option>
</select>
</div>
<div class="overflow-x-auto">
<table data-table class="w-full text-sm">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Registered</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={users.loading}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!users.loading && users.error}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length === 0}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#94a3b8">No fitness trainer users found.</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length > 0}>
<For each={filtered()}>
{(item) => (
<tr class="hover:bg-slate-50">
<td class="font-semibold text-slate-900">{item.name || item.full_name || '—'}</td>
<td class="text-slate-500">{item.email}</td>
<td>
{item.status?.toUpperCase() === 'ACTIVE' && (
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-700">ACTIVE</span>
)}
{item.status?.toUpperCase() === 'INACTIVE' && (
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">INACTIVE</span>
)}
{item.status?.toUpperCase() === 'PENDING' && (
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600" style="background:#fff7ed;color:#c2410c;border-color:#fed7aa;">PENDING</span>
)}
{!item.status && <span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600"></span>}
</td>
<td class="text-slate-500">
{item.created_at ? new Date(item.created_at).toLocaleDateString() : '—'}
</td>
<td>
<div class="flex items-center justify-end gap-1">
<A class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href={`/admin/users/${item.id}`}>View</A>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</div>
</div>
</div>
</AdminShell>
<RoleUserManagementTablePage
role="fitness_trainer"
title="Fitness Trainer Management"
subtitle="Manage all fitness trainer accounts on the platform."
emptyLabel="No fitness trainer users found."
viewHref={(id) => `/admin/users/${id}`}
/>
);
}

View file

@ -1,125 +1,13 @@
import { A } from '@solidjs/router';
import { createMemo, createResource, createSignal, For, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
async function fetchUsers(): Promise<any[]> {
try {
const res = await fetch(`${API}/api/admin/users?role=graphic_designer`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.users || []);
} catch {
return [];
}
}
import RoleUserManagementTablePage from '~/components/admin/RoleUserManagementTablePage';
export default function GraphicDesignersPage() {
const [users] = createResource(fetchUsers);
const [search, setSearch] = createSignal('');
const [statusFilter, setStatusFilter] = createSignal('');
const filtered = createMemo(() => {
const list = users() ?? [];
const q = search().toLowerCase();
const s = statusFilter();
return list.filter((u) => {
const matchSearch =
!q ||
(u.name || u.full_name || '').toLowerCase().includes(q) ||
(u.email || '').toLowerCase().includes(q);
const matchStatus = !s || (u.status || '').toUpperCase() === s;
return matchSearch && matchStatus;
});
});
return (
<AdminShell>
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
<div class="bg-white border-b border-gray-200 px-6 py-4">
<h1 class="text-xl font-semibold text-gray-900">Graphic Designer Management</h1>
<p class="text-sm text-gray-500 mt-0.5">Manage all graphic designer accounts on the platform.</p>
</div>
<div class="flex-1 p-6">
<div class="table-card">
<div style="display:flex;gap:12px;padding:16px;border-bottom:1px solid #e2e8f0;flex-wrap:wrap;">
<input
type="text"
placeholder="Search by name or email..."
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
style="border:1px solid #cbd5e1;border-radius:6px;padding:8px 12px;font-size:14px;width:260px;"
/>
<select
value={statusFilter()}
onChange={(e) => setStatusFilter(e.currentTarget.value)}
style="border:1px solid #cbd5e1;border-radius:6px;padding:8px 12px;font-size:14px;"
>
<option value="">All Status</option>
<option value="ACTIVE">ACTIVE</option>
<option value="INACTIVE">INACTIVE</option>
<option value="PENDING">PENDING</option>
</select>
</div>
<div class="overflow-x-auto">
<table data-table class="w-full text-sm">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Registered</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={users.loading}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!users.loading && users.error}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length === 0}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#94a3b8">No graphic designer users found.</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length > 0}>
<For each={filtered()}>
{(item) => (
<tr class="hover:bg-slate-50">
<td class="font-semibold text-slate-900">{item.name || item.full_name || '—'}</td>
<td class="text-slate-500">{item.email}</td>
<td>
{item.status?.toUpperCase() === 'ACTIVE' && (
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-700">ACTIVE</span>
)}
{item.status?.toUpperCase() === 'INACTIVE' && (
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">INACTIVE</span>
)}
{item.status?.toUpperCase() === 'PENDING' && (
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600" style="background:#fff7ed;color:#c2410c;border-color:#fed7aa;">PENDING</span>
)}
{!item.status && <span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600"></span>}
</td>
<td class="text-slate-500">
{item.created_at ? new Date(item.created_at).toLocaleDateString() : '—'}
</td>
<td>
<div class="flex items-center justify-end gap-1">
<A class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href={`/admin/users/${item.id}`}>View</A>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</div>
</div>
</div>
</AdminShell>
<RoleUserManagementTablePage
role="graphic_designer"
title="Graphics Designer Management"
subtitle="Manage all graphics designer accounts on the platform."
emptyLabel="No graphics designer users found."
viewHref={(id) => `/admin/users/${id}`}
/>
);
}

View file

@ -1,322 +1,436 @@
import { For, Show, createMemo, createSignal, onMount } from 'solid-js';
import { For, Show, createEffect, createMemo, createSignal, onMount } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import type { CrudRecord } from '~/lib/admin/types';
import DashboardDesignPreview from '~/components/admin/DashboardDesignPreview';
const API = '/api/gateway';
type RoleOption = { id: string; key: string; name: string };
type InternalDashboard = {
id: string;
roleId: string;
roleKey: string;
name: string;
code: string;
assignedRole: string;
widgetsCount: number;
widgets: string[];
tabs: string[];
sidebarItems: string[];
fields: string[];
status: 'ACTIVE' | 'INACTIVE' | 'DRAFT';
updatedAt: string;
lastUpdated?: string;
};
const FALLBACK_DASHBOARDS: InternalDashboard[] = [
{ id: 'id1', name: 'Global Admin Overview', code: 'INT-DASH-ADM', assignedRole: 'System Administrator', widgetsCount: 18, status: 'ACTIVE', lastUpdated: '2026-03-27', updatedAt: '2026-03-27' },
{ id: 'id2', name: 'HR Operations Board', code: 'INT-DASH-HR', assignedRole: 'HR Manager', widgetsCount: 10, status: 'ACTIVE', lastUpdated: '2026-03-26', updatedAt: '2026-03-27' },
{ id: 'id3', name: 'Finance Audit View', code: 'INT-DASH-FIN', assignedRole: 'Finance Controller', widgetsCount: 14, status: 'DRAFT', lastUpdated: '2026-03-25', updatedAt: '2026-03-27' },
];
const AVAILABLE_WIDGETS = ['kpi_summary', 'pending_approvals', 'user_growth', 'active_sessions', 'system_health', 'recent_activity', 'quick_actions', 'team_performance'];
const AVAILABLE_TABS = ['overview', 'approvals', 'users', 'reports', 'audit_logs', 'settings'];
const AVAILABLE_SIDEBAR_ITEMS = ['dashboard', 'user_management', 'role_management', 'approvals', 'reports', 'settings'];
const AVAILABLE_FIELDS = ['employee_name', 'employee_email', 'department', 'designation', 'status', 'joined_date'];
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((item) => String(item || '').trim()).filter(Boolean);
}
function normalizeDashboard(item: any): InternalDashboard {
const cfg = item?.config_json ?? {};
const widgets = asStringArray(Array.isArray(cfg?.widgets) ? cfg.widgets.map((w: any) => (typeof w === 'string' ? w : (w?.key || w?.id))) : []);
const tabs = asStringArray(cfg?.tabs);
const sidebarItems = asStringArray(cfg?.sidebar_items ?? cfg?.sidebarItems);
const fields = asStringArray(cfg?.fields);
const isInactive = item?.is_active === false || String(item?.status || '').toUpperCase() === 'INACTIVE';
const isDraft = String(item?.status || '').toUpperCase() === 'DRAFT';
return {
id: String(item?.id || ''),
roleId: String(item?.role_id || ''),
roleKey: String(cfg?.role_key || cfg?.roleKey || item?.role_key || ''),
name: String(cfg?.name || item?.name || 'Internal Dashboard'),
code: String(cfg?.code || item?.code || ''),
widgets,
tabs,
sidebarItems,
fields,
status: isInactive ? 'INACTIVE' : (isDraft ? 'DRAFT' : 'ACTIVE'),
updatedAt: String(item?.updated_at || ''),
};
}
function StatusBadge(props: { status: string }) {
const active = () => props.status === 'ACTIVE';
const draft = () => props.status === 'DRAFT';
return (
<span style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${active() ? '#FFD8C2' : draft() ? '#E5E7EB' : '#D1D5DB'};background:${active() ? '#FFF1EB' : draft() ? '#F9FAFB' : '#F3F4F6'};color:${active() ? '#FF5E13' : draft() ? '#6B7280' : '#4B5563'};padding:2px 10px;font-size:12px;font-weight:500`}>
<span style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${active() ? '#FF5E13' : draft() ? '#9CA3AF' : '#9CA3AF'};margin-right:5px;flex-shrink:0`} />
{active() ? 'Active' : draft() ? 'Draft' : 'Inactive'}
<span style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${active() ? '#FFD8C2' : '#D1D5DB'};background:${active() ? '#FFF1EB' : '#F3F4F6'};color:${active() ? '#FF5E13' : '#4B5563'};padding:2px 10px;font-size:12px;font-weight:500`}>
{active() ? 'Active' : 'Inactive'}
</span>
);
}
export default function InternalDashboardManagementPage() {
const [rows, setRows] = createSignal<InternalDashboard[]>([]);
const [roles, setRoles] = createSignal<RoleOption[]>([]);
const [loading, setLoading] = createSignal(false);
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal('');
const [view, setView] = createSignal<'list' | 'form'>('list');
const [listTab, setListTab] = createSignal<'all' | 'create' | 'view'>('all');
const [formTab, setFormTab] = createSignal<'general' | 'builder' | 'permissions'>('general');
const [detailTab, setDetailTab] = createSignal<'overview' | 'widgets' | 'preview'>('overview');
const [editingId, setEditingId] = createSignal<string | null>(null);
const [formTab, setFormTab] = createSignal<'general' | 'tabs' | 'sidebar' | 'fields' | 'preview'>('general');
const [listTab, setListTab] = createSignal<'all' | 'create'>('all');
const [search, setSearch] = createSignal('');
const [statusFilter, setStatusFilter] = createSignal('all');
const [sortBy, setSortBy] = createSignal<'name_asc' | 'name_desc' | 'widgets_desc' | 'widgets_asc'>('name_asc');
const [statusFilter, setStatusFilter] = createSignal<'all' | 'active' | 'inactive'>('all');
const [sortBy, setSortBy] = createSignal<'name_asc' | 'name_desc' | 'updated_desc' | 'updated_asc'>('updated_desc');
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
const [rows, setRows] = createSignal<InternalDashboard[]>([]);
const [viewingDashboard, setViewingDashboard] = createSignal<InternalDashboard | null>(null);
const [editingId, setEditingId] = createSignal<string | null>(null);
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
const load = async () => {
setRows(FALLBACK_DASHBOARDS);
const [name, setName] = createSignal('');
const [code, setCode] = createSignal('');
const [roleId, setRoleId] = createSignal('');
const [widgets, setWidgets] = createSignal<string[]>([]);
const [tabs, setTabs] = createSignal<string[]>([]);
const [sidebarItems, setSidebarItems] = createSignal<string[]>([]);
const [fields, setFields] = createSignal<string[]>([]);
const [isActive, setIsActive] = createSignal(true);
const [activePreviewSidebar, setActivePreviewSidebar] = createSignal('');
const [activePreviewTab, setActivePreviewTab] = createSignal('');
const previewSidebarItems = createMemo(() => (sidebarItems().length ? sidebarItems() : ['dashboard', 'user_management', 'settings']));
const previewTabs = createMemo(() => (tabs().length ? tabs() : ['overview']));
createEffect(() => {
const items = previewSidebarItems();
if (!items.includes(activePreviewSidebar())) setActivePreviewSidebar(items[0] || '');
});
createEffect(() => {
const list = previewTabs();
if (!list.includes(activePreviewTab())) setActivePreviewTab(list[0] || '');
});
const authHeaders = () => {
const token = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('nxtgauge_admin_access_token') || '' : '';
return { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) };
};
onMount(() => void load());
const toggle = (list: () => string[], setter: (v: string[] | ((p: string[]) => string[])) => void, value: string) => {
setter((prev: string[]) => (prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value]));
};
const filteredRows = createMemo(() => {
let r = rows();
if (statusFilter() !== 'all') r = r.filter((d) => d.status === statusFilter().toUpperCase());
const q = search().toLowerCase();
if (q) {
r = r.filter(r => r.name.toLowerCase().includes(q) || r.code.toLowerCase().includes(q));
const loadAll = async () => {
setLoading(true);
setError('');
try {
const [dashRes, rolesRes] = await Promise.all([
fetch(`${API}/api/admin/dashboard-config`, { headers: authHeaders(), credentials: 'include' }),
fetch(`${API}/api/admin/roles?audience=INTERNAL&per_page=200`, { headers: authHeaders(), credentials: 'include' }),
]);
const dashData = dashRes.ok ? await dashRes.json().catch(() => []) : [];
const roleData = rolesRes.ok ? await rolesRes.json().catch(() => []) : [];
const dashRows = Array.isArray(dashData) ? dashData : (dashData?.items || dashData?.configs || []);
const roleRows = Array.isArray(roleData) ? roleData : (roleData?.roles || roleData?.items || []);
setRoles(roleRows
.filter((r: any) => String(r?.audience || '').toUpperCase() === 'INTERNAL')
.map((r: any) => ({ id: String(r?.id || ''), key: String(r?.key || '').toUpperCase(), name: String(r?.name || r?.key || 'Internal Role') }))
.filter((r: RoleOption) => r.id));
setRows(dashRows
.filter((item: any) => String(item?.audience || '').toUpperCase() === 'INTERNAL')
.map(normalizeDashboard)
.sort((a: InternalDashboard, b: InternalDashboard) => b.updatedAt.localeCompare(a.updatedAt)));
} catch (e: any) {
setRows([]);
setRoles([]);
setError(e?.message || 'Failed to load internal dashboards.');
} finally {
setLoading(false);
}
const sorted = [...r];
const mode = sortBy();
sorted.sort((a, b) => {
if (mode === 'name_desc') return b.name.localeCompare(a.name);
if (mode === 'widgets_desc') return b.widgetsCount - a.widgetsCount;
if (mode === 'widgets_asc') return a.widgetsCount - b.widgetsCount;
return a.name.localeCompare(b.name);
};
onMount(() => void loadAll());
const filtered = createMemo(() => {
const q = search().trim().toLowerCase();
const next = rows().filter((row) => {
if (statusFilter() === 'active' && row.status !== 'ACTIVE') return false;
if (statusFilter() === 'inactive' && row.status === 'ACTIVE') return false;
if (!q) return true;
return row.name.toLowerCase().includes(q) || row.code.toLowerCase().includes(q) || row.roleKey.toLowerCase().includes(q);
});
return sorted;
next.sort((a, b) => {
if (sortBy() === 'name_asc') return a.name.localeCompare(b.name);
if (sortBy() === 'name_desc') return b.name.localeCompare(a.name);
if (sortBy() === 'updated_asc') return a.updatedAt.localeCompare(b.updatedAt);
return b.updatedAt.localeCompare(a.updatedAt);
});
return next;
});
const resetForm = () => {
setEditingId(null); setViewingDashboard(null); setFormTab('general');
setEditingId(null);
setFormTab('general');
setName('');
setCode('');
setRoleId(roles()[0]?.id || '');
setWidgets([]);
setTabs([]);
setSidebarItems([]);
setFields([]);
setIsActive(true);
};
const openCreate = () => { resetForm(); setView('form'); };
const openEdit = (row: InternalDashboard) => { setEditingId(row.id); setViewingDashboard(row); setView('form'); setOpenMenuId(null); };
const openDetail = (row: InternalDashboard) => { setViewingDashboard(row); setListTab('view'); setOpenMenuId(null); };
const openCreate = () => { resetForm(); setListTab('create'); setView('form'); };
const openEdit = (row: InternalDashboard) => {
setEditingId(row.id);
setFormTab('general');
setName(row.name);
setCode(row.code);
setRoleId(row.roleId);
setWidgets(row.widgets);
setTabs(row.tabs);
setSidebarItems(row.sidebarItems);
setFields(row.fields);
setIsActive(row.status === 'ACTIVE');
setListTab('create');
setView('form');
};
const saveDashboard = async () => {
if (!name().trim()) return setError('Dashboard name is required.');
if (!roleId()) return setError('Internal role is required.');
const selectedRole = roles().find((r) => r.id === roleId());
if (!selectedRole) return setError('Selected role is invalid.');
setSaving(true);
setError('');
try {
const res = await fetch(`${API}/api/admin/dashboard-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
credentials: 'include',
body: JSON.stringify({
role_id: roleId(),
audience: 'INTERNAL',
is_active: isActive(),
config_json: {
name: name().trim(),
code: code().trim() || `INTERNAL-${selectedRole.key}`,
role_key: selectedRole.key,
widgets: widgets().map((key) => ({ key })),
tabs: tabs(),
sidebar_items: sidebarItems(),
fields: fields(),
},
}),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(payload?.message || `Request failed (${res.status})`);
await loadAll();
setView('list');
setListTab('all');
resetForm();
} catch (e: any) {
setError(e?.message || 'Failed to save internal dashboard.');
} finally {
setSaving(false);
}
};
return (
<AdminShell>
<div class="w-full space-y-6 pb-8">
<div style="margin-bottom: 1.5rem">
<div>
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Internal Dashboard Management</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">Create and customize internal control panels for different admin roles</p>
<p class="mt-1 text-[14px] text-[#6B7280]">Create dashboards with tabs, sidebar and fields, then preview before saving.</p>
</div>
{/* ── LIST VIEW ── */}
<Show when={view() === 'list'}>
<div>
{/* Tabs */}
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
{([
{ key: 'all', label: 'All Dashboards', action: () => { setListTab('all'); setStatusFilter('all'); } },
{ key: 'create', label: 'Create Dashboard', action: () => { setListTab('create'); openCreate(); } },
{ key: 'view', label: 'View Details', action: () => setListTab('view') },
] as const).map((tab) => (
<button
type="button"
onClick={tab.action}
style={`padding-bottom:12px;font-size:14px;font-weight:500;background:none;border:none;cursor:pointer;${listTab() === tab.key ? 'color:#FF5E13;border-bottom:2px solid #FF5E13;margin-bottom:-1px' : 'color:#6B7280'}`}
>
{tab.label}
<Show when={error()}>
<div style="border-radius:10px;border:1px solid #FECACA;background:#FEF2F2;padding:12px 16px;font-size:13px;color:#B91C1C">{error()}</div>
</Show>
<Show when={view() === 'list'} fallback={
<div style="border-radius:16px;border:1px solid #E5E7EB;background:white;box-shadow:0 1px 4px rgba(0,0,0,0.06);overflow:hidden">
<div style="padding:18px 24px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center">
<p style="margin:0;font-size:16px;font-weight:700;color:#111827">{editingId() ? 'Edit Internal Dashboard' : 'Create Internal Dashboard'}</p>
<button type="button" onClick={() => { setView('list'); resetForm(); }} style="height:34px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 14px;font-size:12px;font-weight:700;color:#374151;cursor:pointer">Back</button>
</div>
<div style="display:flex;gap:4px;border-bottom:1px solid #E5E7EB;padding:0 16px;background:#FAFAFA">
{(['general', 'tabs', 'sidebar', 'fields', 'preview'] as const).map((tab) => (
<button type="button" onClick={() => setFormTab(tab)} style={`position:relative;padding:12px 10px;font-size:12px;font-weight:700;border:none;background:none;cursor:pointer;color:${formTab() === tab ? '#FF5E13' : '#6B7280'}`}>
{tab.toUpperCase()}
<Show when={formTab() === tab}><span style="position:absolute;left:0;right:0;bottom:0;height:2px;background:#FF5E13" /></Show>
</button>
))}
</div>
{/* View Details panel */}
<Show when={listTab() === 'view'}>
<Show
when={!viewingDashboard()}
>
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;padding:48px 24px;text-align:center">
<p style="font-size:15px;font-weight:600;color:#111827">No dashboard selected</p>
<p style="margin-top:6px;font-size:13px;color:#6B7280">Click the <strong></strong> menu on any dashboard row and choose <strong>View Details</strong>.</p>
<div style="padding:24px">
<Show when={formTab() === 'general'}>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<label style="display:block"><span style="font-size:13px;font-weight:600;color:#374151">Dashboard Name *</span><input value={name()} onInput={(e) => setName(e.currentTarget.value)} style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;padding:0 14px;font-size:13px" /></label>
<label style="display:block"><span style="font-size:13px;font-weight:600;color:#374151">Code</span><input value={code()} onInput={(e) => setCode(e.currentTarget.value)} placeholder="Optional" style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;padding:0 14px;font-size:13px" /></label>
<label style="display:block;grid-column:1 / -1"><span style="font-size:13px;font-weight:600;color:#374151">Internal Role *</span><select value={roleId()} onChange={(e) => setRoleId(e.currentTarget.value)} style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;padding:0 14px;font-size:13px;background:white"><For each={roles()}>{(r) => <option value={r.id}>{r.name} ({r.key})</option>}</For></select></label>
<div style="grid-column:1 / -1"><span style="font-size:13px;font-weight:600;color:#374151">Widgets</span><div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:8px"><For each={AVAILABLE_WIDGETS}>{(key) => <button type="button" onClick={() => toggle(widgets, setWidgets, key)} style={`height:30px;border-radius:8px;padding:0 10px;font-size:11px;font-weight:700;cursor:pointer;border:1px solid ${widgets().includes(key) ? '#0D0D2A' : '#E5E7EB'};background:${widgets().includes(key) ? '#0D0D2A' : 'white'};color:${widgets().includes(key) ? 'white' : '#374151'}`}>{key}</button>}</For></div></div>
<label style="display:flex;align-items:center;gap:8px;grid-column:1 / -1;font-size:13px;color:#374151"><input type="checkbox" checked={isActive()} onChange={(e) => setIsActive(e.currentTarget.checked)} style="width:16px;height:16px;accent-color:#0D0D2A" />Active dashboard</label>
</div>
</Show>
<Show when={viewingDashboard()}>
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;box-shadow:0 1px 4px rgba(0,0,0,0.06);overflow:hidden">
<div style="padding:20px 24px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between">
<div>
<h2 style="font-size:18px;font-weight:700;color:#111827">{viewingDashboard()!.name}</h2>
<p style="margin-top:2px;font-size:13px;color:#6B7280">{viewingDashboard()!.code} {viewingDashboard()!.assignedRole}</p>
<Show when={formTab() === 'tabs'}>
<div><p style="font-size:13px;font-weight:600;color:#374151">Dashboard Tabs</p><div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:8px"><For each={AVAILABLE_TABS}>{(key) => <button type="button" onClick={() => toggle(tabs, setTabs, key)} style={`height:30px;border-radius:8px;padding:0 10px;font-size:11px;font-weight:700;cursor:pointer;border:1px solid ${tabs().includes(key) ? '#0D0D2A' : '#E5E7EB'};background:${tabs().includes(key) ? '#0D0D2A' : 'white'};color:${tabs().includes(key) ? 'white' : '#374151'}`}>{key}</button>}</For></div></div>
</Show>
<Show when={formTab() === 'sidebar'}>
<div><p style="font-size:13px;font-weight:600;color:#374151">Sidebar Items</p><div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:8px"><For each={AVAILABLE_SIDEBAR_ITEMS}>{(key) => <button type="button" onClick={() => toggle(sidebarItems, setSidebarItems, key)} style={`height:30px;border-radius:8px;padding:0 10px;font-size:11px;font-weight:700;cursor:pointer;border:1px solid ${sidebarItems().includes(key) ? '#0D0D2A' : '#E5E7EB'};background:${sidebarItems().includes(key) ? '#0D0D2A' : 'white'};color:${sidebarItems().includes(key) ? 'white' : '#374151'}`}>{key}</button>}</For></div></div>
</Show>
<Show when={formTab() === 'fields'}>
<div><p style="font-size:13px;font-weight:600;color:#374151">Visible Fields</p><div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:8px"><For each={AVAILABLE_FIELDS}>{(key) => <button type="button" onClick={() => toggle(fields, setFields, key)} style={`height:30px;border-radius:8px;padding:0 10px;font-size:11px;font-weight:700;cursor:pointer;border:1px solid ${fields().includes(key) ? '#0D0D2A' : '#E5E7EB'};background:${fields().includes(key) ? '#0D0D2A' : 'white'};color:${fields().includes(key) ? 'white' : '#374151'}`}>{key}</button>}</For></div></div>
</Show>
<Show when={formTab() === 'preview'}>
<div style="display:flex;flex-direction:column;gap:10px">
<div style="border:1px solid #E5E7EB;border-radius:12px;background:#F9FAFB;padding:10px 12px">
<p style="margin:0;font-size:12px;font-weight:800;color:#374151">Config Snapshot (Instant)</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:8px">
<div>
<p style="margin:0;font-size:11px;font-weight:800;color:#6B7280;text-transform:uppercase">Widgets</p>
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:6px">
<For each={widgets().length ? widgets() : ['(none)']}>
{(w) => <span style="padding:2px 8px;border-radius:999px;border:1px solid #E5E7EB;background:white;font-size:11px;color:#374151">{w}</span>}
</For>
</div>
</div>
<div>
<p style="margin:0;font-size:11px;font-weight:800;color:#6B7280;text-transform:uppercase">Tabs</p>
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:6px">
<For each={tabs().length ? tabs() : ['(none)']}>
{(t) => <span style="padding:2px 8px;border-radius:999px;border:1px solid #E5E7EB;background:white;font-size:11px;color:#374151">{t}</span>}
</For>
</div>
</div>
</div>
<StatusBadge status={viewingDashboard()!.status} />
</div>
<div style="display:flex;align-items:center;gap:4px;border-bottom:1px solid #E5E7EB;padding:0 24px;background:#FAFAFA">
{(['overview', 'widgets', 'preview'] as const).map((tab, i) => {
const labels = ['Overview', 'Widget Configuration', 'Live Preview'];
const active = () => detailTab() === tab;
return (
<button type="button" onClick={() => setDetailTab(tab)} style={`position:relative;padding:14px 12px;font-size:13px;font-weight:600;background:none;border:none;cursor:pointer;color:${active() ? '#FF5E13' : '#6B7280'}`}>
{labels[i]}
<Show when={active()}><span style="position:absolute;left:0;right:0;bottom:0;height:2px;background:#FF5E13;border-radius:2px 2px 0 0" /></Show>
</button>
);
})}
</div>
<div style="padding:24px">
<Show when={detailTab() === 'overview'}>
<div style="display:grid;grid-template-columns:repeat(4, 1fr);gap:16px">
{[
{ l: 'Total Users', v: '450' },
{ l: 'Pending Approvals', v: '12' },
{ l: 'System Health', v: 'Optimal' },
{ l: 'Active Sessions', v: '28' }
].map(stat => (
<div style="padding:16px;border:1px solid #E5E7EB;border-radius:12px;background:#F9FAFB">
<p style="font-size:11px;color:#6B7280;font-weight:700;text-transform:uppercase">{stat.l}</p>
<p style="font-size:20px;font-weight:700;color:#111827;margin-top:8px">{stat.v}</p>
</div>
))}
</div>
</Show>
</div>
<div style="display:flex;align-items:center;gap:10px;padding:14px 24px;border-top:1px solid #E5E7EB">
<button type="button" onClick={() => openEdit(viewingDashboard()!)} style="height:36px;border-radius:8px;background:#0D0D2A;padding:0 16px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer">Edit Layout</button>
<button type="button" onClick={() => { setViewingDashboard(null); setListTab('all'); }} style="height:36px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 16px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Back to List</button>
</div>
<DashboardDesignPreview
status={isActive() ? 'ACTIVE' : 'INACTIVE'}
sidebarItems={previewSidebarItems()}
activeSidebar={activePreviewSidebar()}
onSidebarSelect={setActivePreviewSidebar}
tabs={previewTabs()}
activeTab={activePreviewTab()}
onTabSelect={setActivePreviewTab}
widgets={widgets()}
fields={fields()}
/>
</div>
</Show>
</Show>
</div>
<div style={{ display: listTab() === 'view' ? 'none' : 'block' }}>
<div style="margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:hidden;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6">
<input
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
placeholder="Search dashboards..."
style="height:34px;flex:1;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none"
/>
<div style="position:relative">
<button
type="button"
onClick={() => { setSortMenuOpen((v) => !v); setFilterMenuOpen(false); }}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 4v13"/><path d="m3 13 4 4 4-4"/><path d="M17 20V7"/><path d="m21 11-4-4-4 4"/></svg>
Sort
</button>
<Show when={sortMenuOpen()}>
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:200px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
{(['name_asc', 'name_desc', 'widgets_desc', 'widgets_asc'] as const).map((s, i) => (
<button type="button" onClick={() => { setSortBy(s); setSortMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === s ? '#FF5E13' : '#374151'};background:${sortBy() === s ? '#FFF1EB' : 'transparent'}`}>
{['Name (A-Z)', 'Name (Z-A)', 'Widgets (High-Low)', 'Widgets (Low-High)'][i]}
</button>
))}
</div>
</Show>
</div>
<div style="position:relative">
<button
type="button"
onClick={() => { setFilterMenuOpen((v) => !v); setSortMenuOpen(false); }}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h18M6 12h12M10 19h4"/></svg>
Filters
</button>
<Show when={filterMenuOpen()}>
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:160px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
{(['all', 'active', 'inactive', 'draft'] as const).map((s) => (
<button type="button" onClick={() => { setStatusFilter(s); setFilterMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === s ? '#FF5E13' : '#374151'};background:${statusFilter() === s ? '#FFF1EB' : 'transparent'}`}>
{s === 'all' ? 'All Status' : s === 'active' ? 'Active' : s === 'inactive' ? 'Inactive' : 'Draft'}
</button>
))}
</div>
</Show>
</div>
</div>
<div style="padding:14px 24px;border-top:1px solid #E5E7EB;display:flex;justify-content:flex-end;gap:10px">
<button type="button" onClick={() => { setView('list'); resetForm(); }} style="height:38px;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 20px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Cancel</button>
<button type="button" onClick={() => void saveDashboard()} disabled={saving()} style="height:38px;border-radius:10px;background:#0D0D2A;padding:0 24px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer;opacity:0.95">{saving() ? 'Saving...' : (editingId() ? 'Update Dashboard' : 'Create Dashboard')}</button>
</div>
</div>
}>
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
<button type="button" onClick={() => { setListTab('all'); setView('list'); }} style={`padding-bottom:12px;font-size:14px;font-weight:500;background:none;border:none;cursor:pointer;${listTab() === 'all' ? 'color:#FF5E13;border-bottom:2px solid #FF5E13;margin-bottom:-1px' : 'color:#6B7280'}`}>All Dashboards</button>
<button type="button" onClick={openCreate} style={`padding-bottom:12px;font-size:14px;font-weight:500;background:none;border:none;cursor:pointer;${listTab() === 'create' ? 'color:#FF5E13;border-bottom:2px solid #FF5E13;margin-bottom:-1px' : 'color:#6B7280'}`}>Create Dashboard</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full">
<thead>
<tr style="background:#0D0D2A;text-align:left">
{['Dashboard Name', 'Code', 'Assigned Role', 'Widgets', 'Status', 'Actions'].map(h => (
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;white-space:nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
<For each={filteredRows()}>
<div style="position:relative;margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:visible;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6">
<input value={search()} onInput={(e) => setSearch(e.currentTarget.value)} placeholder="Search dashboards..." style="height:34px;flex:1;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none" />
<div style="position:relative">
<button type="button" onClick={() => { setSortMenuOpen((v) => !v); setFilterMenuOpen(false); }} style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 4v13"/><path d="m3 13 4 4 4-4"/><path d="M17 20V7"/><path d="m21 11-4-4-4 4"/></svg>
Sort
</button>
<Show when={sortMenuOpen()}>
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:200px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
{([
['name_asc', 'Name (A-Z)'],
['name_desc', 'Name (Z-A)'],
['updated_desc', 'Updated (Newest)'],
['updated_asc', 'Updated (Oldest)'],
] as const).map(([key, label]) => (
<button type="button" onClick={() => { setSortBy(key); setSortMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === key ? '#FF5E13' : '#374151'};background:${sortBy() === key ? '#FFF1EB' : 'transparent'}`}>{label}</button>
))}
</div>
</Show>
</div>
<div style="position:relative">
<button type="button" onClick={() => { setFilterMenuOpen((v) => !v); setSortMenuOpen(false); }} style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h18M6 12h12M10 19h4"/></svg>
Filters
</button>
<Show when={filterMenuOpen()}>
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:160px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
{([
['all', 'All Status'],
['active', 'Active'],
['inactive', 'Inactive'],
] as const).map(([key, label]) => (
<button type="button" onClick={() => { setStatusFilter(key); setFilterMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === key ? '#FF5E13' : '#374151'};background:${statusFilter() === key ? '#FFF1EB' : 'transparent'}`}>{label}</button>
))}
</div>
</Show>
</div>
<button type="button" style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:600;color:white;border:none;cursor:pointer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Export
</button>
</div>
<div class="overflow-x-auto overflow-y-visible">
<table class="min-w-full">
<thead>
<tr style="background:#0D0D2A;text-align:left">
{['Name', 'Code', 'Role', 'Widgets', 'Status', 'Updated', 'Actions'].map((h) => (
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;white-space:nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
<Show when={!loading()} fallback={<tr><td colspan="7" style="padding:18px 20px;color:#6B7280;font-size:13px">Loading dashboards...</td></tr>}>
<Show when={filtered().length > 0} fallback={<tr><td colspan="7" style="padding:18px 20px;color:#6B7280;font-size:13px">No internal dashboards found.</td></tr>}>
<For each={filtered()}>
{(row) => (
<tr style="border-bottom:1px solid #F3F4F6" class="hover:bg-[#FAFAFA] transition-colors">
<td style="padding:12px 20px;font-size:14px;font-weight:600;color:#111827">{row.name}</td>
<td style="padding:12px 20px;font-size:12px;font-family:monospace;color:#6B7280">{row.code}</td>
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.assignedRole}</td>
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.widgetsCount} widgets</td>
<td style="padding:12px 20px;font-size:12px;color:#6B7280;font-family:monospace">{row.code || '—'}</td>
<td style="padding:12px 20px;font-size:13px;color:#374151">{row.roleKey || '—'}</td>
<td style="padding:12px 20px;font-size:13px;color:#374151">{row.widgets.length}</td>
<td style="padding:12px 20px"><StatusBadge status={row.status} /></td>
<td style="padding:12px 20px;position:relative">
<button type="button" onClick={() => setOpenMenuId(openMenuId() === row.id ? null : row.id)} style="display:inline-flex;height:32px;width:32px;align-items:center;justify-content:center;border-radius:8px;color:#9CA3AF;background:none;border:none;cursor:pointer">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
</button>
<Show when={openMenuId() === row.id}>
<div style="position:absolute;right:20px;top:44px;z-index:20;width:180px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 20px rgba(0,0,0,0.12)">
<button type="button" onClick={() => openDetail(row)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">View Details</button>
<button type="button" onClick={() => openEdit(row)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">Edit Layout</button>
<button type="button" style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:8px 12px;font-size:13px;color:#DC2626;background:none;border:none;cursor:pointer;text-align:left">Deactivate</button>
</div>
</Show>
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{row.updatedAt ? new Date(row.updatedAt).toLocaleString() : '—'}</td>
<td style="padding:12px 20px">
<button type="button" onClick={() => openEdit(row)} style="height:30px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:700;color:#374151;cursor:pointer">Edit</button>
</td>
</tr>
)}
</For>
</tbody>
</table>
</Show>
</Show>
</tbody>
</table>
</div>
<Show when={filtered().length > 0}>
<div style="display:flex;align-items:center;justify-content:space-between;border-top:1px solid #F3F4F6;padding:12px 20px">
<p style="font-size:13px;color:#6B7280">
Showing <strong style="font-weight:600;color:#111827">1{filtered().length}</strong> of <strong style="font-weight:600;color:#111827">{filtered().length}</strong> dashboards
</p>
<div style="display:flex;align-items:center;gap:4px">
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#6B7280;cursor:pointer;font-size:15px"></button>
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;background:#FF5E13;color:white;font-size:13px;font-weight:600;border:none;cursor:pointer">1</button>
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#6B7280;cursor:pointer;font-size:15px"></button>
</div>
</div>
</div>
</Show>
</div>
</Show>
{/* ── FORM VIEW ── */}
<Show when={view() === 'form'}>
<div>
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
<button type="button" onClick={() => { setView('list'); resetForm(); }} style="padding-bottom:12px;font-size:14px;font-weight:500;color:#6B7280;background:none;border:none;cursor:pointer">All Dashboards</button>
<button type="button" style="padding-bottom:12px;font-size:14px;font-weight:500;color:#FF5E13;border-bottom:2px solid #FF5E13;background:none;cursor:pointer;margin-bottom:-1px">{editingId() ? 'Edit Dashboard' : 'Create Dashboard'}</button>
</div>
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;box-shadow:0 1px 4px rgba(0,0,0,0.06);overflow:hidden">
<div style="display:flex;align-items:center;gap:4px;border-bottom:1px solid #E5E7EB;padding:0 24px">
{(['general', 'builder', 'permissions'] as const).map((tab, i) => {
const labels = ['General Config', 'Internal Builder', 'Role Assignments'];
const active = () => formTab() === tab;
return (
<button type="button" onClick={() => setFormTab(tab)} style={`position:relative;padding:14px 8px;font-size:13px;font-weight:500;background:none;border:none;cursor:pointer;color:${active() ? '#FF5E13' : '#6B7280'}`}>
{labels[i]}
<Show when={active()}><span style="position:absolute;left:0;right:0;bottom:0;height:2px;background:#FF5E13;border-radius:2px 2px 0 0" /></Show>
</button>
);
})}
</div>
<div style="padding:24px">
<Show when={formTab() === 'general'}>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
<label style="display:block">
<span style="font-size:13px;font-weight:600;color:#374151">Dashboard Name *</span>
<input value={viewingDashboard()?.name || ''} style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;padding:0 14px;font-size:13px" />
</label>
<label style="display:block">
<span style="font-size:13px;font-weight:600;color:#374151">Internal Role *</span>
<select style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;padding:0 14px;font-size:13px;background:white">
<option selected={viewingDashboard()?.assignedRole === 'System Administrator'}>System Administrator</option>
<option selected={viewingDashboard()?.assignedRole === 'HR Manager'}>HR Manager</option>
<option selected={viewingDashboard()?.assignedRole === 'Finance Controller'}>Finance Controller</option>
</select>
</label>
</div>
</Show>
<Show when={formTab() === 'builder'}>
<div style="text-align:center;padding:100px;border:2px dashed #E5E7EB;border-radius:12px;background:#FAFAFA">
<p style="font-size:14px;color:#9CA3AF">Internal dashboard builder canvas goes here</p>
</div>
</Show>
</div>
<div style="display:flex;align-items:center;justify-content:flex-end;gap:10px;border-top:1px solid #E5E7EB;padding:14px 24px">
<button type="button" onClick={() => { setView('list'); resetForm(); }} style="height:38px;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 20px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Cancel</button>
<button type="button" style="height:38px;border-radius:10px;background:#0D0D2A;padding:0 24px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer">
{editingId() ? 'Update Dashboard' : 'Create Dashboard'}
</button>
</div>
</div>
</div>
</Show>
</div>
</AdminShell>
);
}

View file

@ -45,6 +45,10 @@ export default function JobsManagementPage() {
const [view, setView] = createSignal<'list' | 'detail'>('list');
const [listTab, setListTab] = createSignal<'all' | 'active' | 'pending'>('all');
const [search, setSearch] = createSignal('');
const [statusFilter, setStatusFilter] = createSignal<'all' | 'active' | 'pending' | 'draft' | 'closed'>('all');
const [sortBy, setSortBy] = createSignal<'title_asc' | 'title_desc' | 'posted_newest' | 'posted_oldest'>('posted_newest');
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
const [rows, setRows] = createSignal<JobRecord[]>([]);
const [selectedJob, setSelectedJob] = createSignal<JobRecord | null>(null);
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
@ -78,11 +82,52 @@ export default function JobsManagementPage() {
let list = rows();
if (listTab() === 'active') list = list.filter(r => r.status === 'ACTIVE');
if (listTab() === 'pending') list = list.filter(r => r.status === 'PENDING_APPROVAL');
if (statusFilter() !== 'all') {
const map: Record<string, JobRecord['status']> = {
active: 'ACTIVE',
pending: 'PENDING_APPROVAL',
draft: 'DRAFT',
closed: 'CLOSED',
};
list = list.filter((r) => r.status === map[statusFilter()]);
}
const q = search().toLowerCase();
if (q) list = list.filter(r => r.title.toLowerCase().includes(q) || r.company?.toLowerCase().includes(q));
const sorted = [...list];
sorted.sort((a, b) => {
if (sortBy() === 'title_asc') return a.title.localeCompare(b.title);
if (sortBy() === 'title_desc') return b.title.localeCompare(a.title);
const aDate = new Date(a.updatedAt || a.postedDate || 0).getTime();
const bDate = new Date(b.updatedAt || b.postedDate || 0).getTime();
if (sortBy() === 'posted_oldest') return aDate - bDate;
return bDate - aDate;
});
list = sorted;
return list;
});
const exportCsv = () => {
const headers = ['Job Title', 'Company', 'Rate', 'Location', 'Status'];
const body = filteredRows().map((row) => [
row.title || '',
row.company || '',
row.rate || '',
row.location || '',
row.status || '',
]);
const csv = [headers, ...body]
.map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'jobs.csv';
link.click();
URL.revokeObjectURL(url);
};
const openDetail = (row: JobRecord) => { setSelectedJob(row); setView('detail'); setOpenMenuId(null); };
return (
@ -118,7 +163,60 @@ export default function JobsManagementPage() {
placeholder="Search jobs..."
style="height:34px;flex:1;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none"
/>
<button type="button" style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer">Filters</button>
<div style="position:relative">
<button
type="button"
onClick={() => { setSortMenuOpen((v) => !v); setFilterMenuOpen(false); }}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
>
Sort
</button>
<Show when={sortMenuOpen()}>
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:190px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
{([
{ key: 'posted_newest', label: 'Posted (Newest)' },
{ key: 'posted_oldest', label: 'Posted (Oldest)' },
{ key: 'title_asc', label: 'Title (A-Z)' },
{ key: 'title_desc', label: 'Title (Z-A)' },
] as const).map((item) => (
<button type="button" onClick={() => { setSortBy(item.key); setSortMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === item.key ? '#FF5E13' : '#374151'};background:${sortBy() === item.key ? '#FFF1EB' : 'transparent'}`}>
{item.label}
</button>
))}
</div>
</Show>
</div>
<div style="position:relative">
<button
type="button"
onClick={() => { setFilterMenuOpen((v) => !v); setSortMenuOpen(false); }}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
>
Filters
</button>
<Show when={filterMenuOpen()}>
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:180px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
{([
{ key: 'all', label: 'All Status' },
{ key: 'active', label: 'Active' },
{ key: 'pending', label: 'Pending Approval' },
{ key: 'draft', label: 'Draft' },
{ key: 'closed', label: 'Closed' },
] as const).map((item) => (
<button type="button" onClick={() => { setStatusFilter(item.key); setFilterMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === item.key ? '#FF5E13' : '#374151'};background:${statusFilter() === item.key ? '#FFF1EB' : 'transparent'}`}>
{item.label}
</button>
))}
</div>
</Show>
</div>
<button
type="button"
onClick={exportCsv}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #D1D5DB;background:#fff;padding:0 12px;font-size:12px;font-weight:600;color:#0f172a;cursor:pointer"
>
Export
</button>
</div>
<div class="overflow-x-auto">

View file

@ -31,13 +31,15 @@ export default function LeadsPage() {
const [search, setSearch] = createSignal('');
const [statusFilter, setStatusFilter] = createSignal('');
const [roleFilter, setRoleFilter] = createSignal('');
const [sortBy, setSortBy] = createSignal<'newest' | 'oldest' | 'title_asc' | 'title_desc'>('newest');
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
const filtered = createMemo(() => {
const list = leads() ?? [];
const q = search().toLowerCase();
const sf = statusFilter().toUpperCase();
const rf = roleFilter().toLowerCase();
return list.filter((item) => {
const rows = list.filter((item) => {
const title = (item.title || '').toLowerCase();
const loc = (item.location || '').toLowerCase();
const matchQ = !q || title.includes(q) || loc.includes(q);
@ -45,8 +47,38 @@ export default function LeadsPage() {
const matchR = !rf || (item.profession || item.role || '').toLowerCase() === rf;
return matchQ && matchS && matchR;
});
rows.sort((a, b) => {
if (sortBy() === 'title_asc') return String(a.title || '').localeCompare(String(b.title || ''));
if (sortBy() === 'title_desc') return String(b.title || '').localeCompare(String(a.title || ''));
const aDate = new Date(a.created_at || a.updated_at || 0).getTime();
const bDate = new Date(b.created_at || b.updated_at || 0).getTime();
if (sortBy() === 'oldest') return aDate - bDate;
return bDate - aDate;
});
return rows;
});
const exportCsv = () => {
const headers = ['Title', 'Role', 'Budget', 'Location', 'Status'];
const body = filtered().map((item) => [
String(item.title || ''),
String(item.profession || item.role || ''),
String(item.budget_range || (item.budget_min != null ? `${item.budget_min}–₹${item.budget_max}` : '')),
String(item.location || ''),
String(item.status || ''),
]);
const csv = [headers, ...body]
.map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'leads.csv';
link.click();
URL.revokeObjectURL(url);
};
return (
<AdminShell>
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
@ -87,6 +119,36 @@ export default function LeadsPage() {
{(r) => <option value={r}>{r.replace(/_/g, ' ')}</option>}
</For>
</select>
<div style="position:relative">
<button
type="button"
onClick={() => setSortMenuOpen((v) => !v)}
style="display:inline-flex;height:38px;align-items:center;gap:6px;border-radius:8px;border:1px solid #cbd5e1;background:white;padding:0 12px;font-size:13px;font-weight:500;color:#374151;cursor:pointer"
>
Sort
</button>
<Show when={sortMenuOpen()}>
<div style="position:absolute;left:0;top:42px;z-index:30;min-width:180px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
{([
{ key: 'newest', label: 'Newest First' },
{ key: 'oldest', label: 'Oldest First' },
{ key: 'title_asc', label: 'Title (A-Z)' },
{ key: 'title_desc', label: 'Title (Z-A)' },
] as const).map((item) => (
<button type="button" onClick={() => { setSortBy(item.key); setSortMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === item.key ? '#FF5E13' : '#374151'};background:${sortBy() === item.key ? '#FFF1EB' : 'transparent'}`}>
{item.label}
</button>
))}
</div>
</Show>
</div>
<button
type="button"
onClick={exportCsv}
style="display:inline-flex;height:38px;align-items:center;gap:6px;border-radius:8px;border:1px solid #D1D5DB;background:#fff;padding:0 12px;font-size:13px;font-weight:600;color:#0f172a;cursor:pointer"
>
Export
</button>
<Show when={search() || statusFilter() || roleFilter()}>
<span style="font-size:13px;color:#64748b">{filtered().length} result{filtered().length !== 1 ? 's' : ''}</span>
</Show>

View file

@ -1,127 +1,13 @@
import { A } from '@solidjs/router';
import { createMemo, createResource, createSignal, For, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
async function fetchUsers(): Promise<any[]> {
try {
const res = await fetch(`${API}/api/admin/users?role=makeup_artist`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.users || []);
} catch {
return [];
}
}
import RoleUserManagementTablePage from '~/components/admin/RoleUserManagementTablePage';
export default function MakeupArtistPage() {
const [users] = createResource(fetchUsers);
const [search, setSearch] = createSignal('');
const [statusFilter, setStatusFilter] = createSignal('');
const filtered = createMemo(() => {
const list = users() ?? [];
const q = search().toLowerCase();
const s = statusFilter();
return list.filter((u) => {
const matchSearch =
!q ||
(u.name || u.full_name || '').toLowerCase().includes(q) ||
(u.email || '').toLowerCase().includes(q);
const matchStatus = !s || (u.status || '').toUpperCase() === s;
return matchSearch && matchStatus;
});
});
return (
<AdminShell>
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
{/* White page header */}
<div class="bg-white border-b border-gray-200 px-6 py-4">
<h1 class="text-xl font-semibold text-gray-900">Makeup Artist Management</h1>
<p class="text-sm text-gray-500 mt-0.5">Manage all makeup artist accounts on the platform.</p>
</div>
{/* Content */}
<div class="flex-1 p-6">
<div class="mb-4 flex flex-wrap items-center gap-3">
<input
type="text"
placeholder="Search by name or email..."
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
class="rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] w-64"
/>
<select
value={statusFilter()}
onChange={(e) => setStatusFilter(e.currentTarget.value)}
class="rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37]"
>
<option value="">All Status</option>
<option value="ACTIVE">ACTIVE</option>
<option value="INACTIVE">INACTIVE</option>
<option value="PENDING">PENDING</option>
</select>
</div>
<div class="table-card">
<div class="overflow-x-auto">
<table class="data-table w-full text-sm">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Registered</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={users.loading}>
<tr><td colspan="5" class="text-center py-8 text-slate-500">Loading...</td></tr>
</Show>
<Show when={!users.loading && users.error}>
<tr><td colspan="5" class="text-center py-8 text-red-700">Failed to load. Is the backend running?</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length === 0}>
<tr><td colspan="5" class="text-center py-8 text-slate-400">No makeup artist users found.</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length > 0}>
<For each={filtered()}>
{(item) => (
<tr class="hover:bg-slate-50">
<td class="font-semibold text-slate-900">{item.name || item.full_name || '—'}</td>
<td class="text-slate-500">{item.email}</td>
<td>
{item.status?.toUpperCase() === 'ACTIVE' && (
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-700">ACTIVE</span>
)}
{item.status?.toUpperCase() === 'INACTIVE' && (
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">INACTIVE</span>
)}
{item.status?.toUpperCase() === 'PENDING' && (
<span class="inline-flex items-center rounded-full bg-orange-50 px-2.5 py-0.5 text-xs font-medium text-orange-700">PENDING</span>
)}
{!item.status && <span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600"></span>}
</td>
<td class="text-slate-500">
{item.created_at ? new Date(item.created_at).toLocaleDateString() : '—'}
</td>
<td>
<div class="flex items-center justify-end gap-1">
<A class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm hover:bg-gray-50" href={`/admin/users/${item.id}`}>View</A>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</div>
</div>
</div>
</AdminShell>
<RoleUserManagementTablePage
role="makeup_artist"
title="Makeup Artist Management"
subtitle="Manage all makeup artist accounts on the platform."
emptyLabel="No makeup artist users found."
viewHref={(id) => `/admin/users/${id}`}
/>
);
}

View file

@ -1,393 +1,5 @@
import { createMemo, createResource, createSignal, For, Show } from 'solid-js';
import { A } from '@solidjs/router';
import AdminShell from '~/components/AdminShell';
import { MoreVertical } from 'lucide-solid';
import { Navigate } from '@solidjs/router';
const API = '/api/gateway';
type OnboardingFlow = {
id: string;
name: string;
userType: string;
totalSteps: number;
requiredDocs: number;
verificationType: string;
status: string;
lastUpdated: string;
};
const FALLBACK_FLOWS: OnboardingFlow[] = [
{ id: 'f1', name: 'Customer Service Onboarding', userType: 'Customer', totalSteps: 6, requiredDocs: 2, verificationType: 'Identity Verification', status: 'ACTIVE', lastUpdated: '2024-03-20' },
{ id: 'f2', name: 'Professional Photographer Onboarding', userType: 'Professional', totalSteps: 6, requiredDocs: 3, verificationType: 'Identity Verification', status: 'ACTIVE', lastUpdated: '2024-03-19' },
{ id: 'f3', name: 'Company Hiring Onboarding', userType: 'Company', totalSteps: 6, requiredDocs: 4, verificationType: 'Business Verification', status: 'ACTIVE', lastUpdated: '2024-03-18' },
{ id: 'f4', name: 'Jobseeker Profile Onboarding', userType: 'Jobseeker', totalSteps: 6, requiredDocs: 2, verificationType: 'Identity Verification', status: 'ACTIVE', lastUpdated: '2024-03-17' },
{ id: 'f5', name: 'Professional Developer Onboarding', userType: 'Professional', totalSteps: 6, requiredDocs: 2, verificationType: 'Identity Verification', status: 'DRAFT', lastUpdated: '2024-03-16' },
{ id: 'f6', name: 'Customer Requirements Collection', userType: 'Customer', totalSteps: 5, requiredDocs: 1, verificationType: 'No Verification', status: 'INACTIVE', lastUpdated: '2024-03-15' },
];
async function loadFlows(): Promise<OnboardingFlow[]> {
try {
const res = await fetch(`${API}/api/admin/onboarding-schemas`);
if (!res.ok) {
const res2 = await fetch(`${API}/api/admin/onboarding-flows`);
if (!res2.ok) throw new Error('Failed');
const data2 = await res2.json();
const rows2 = Array.isArray(data2) ? data2 : (data2.flows || data2.schemas || []);
if (!rows2.length) return FALLBACK_FLOWS;
return rows2.map((item: any) => ({
id: String(item.id || ''),
name: String(item.name || item.title || 'Untitled Flow'),
userType: String(item.user_type || item.userType || item.role_key || ''),
totalSteps: Number(item.step_count || item.steps || item.totalSteps || 0),
requiredDocs: Number(item.required_docs || item.requiredDocs || 0),
verificationType: String(item.verification_type || item.verificationType || ''),
status: item.is_active ? 'ACTIVE' : String(item.status || 'DRAFT').toUpperCase(),
lastUpdated: String(item.updated_at || item.lastUpdated || '').slice(0, 10),
}));
}
const data = await res.json();
const rows = Array.isArray(data) ? data : (data.flows || data.schemas || []);
if (!rows.length) return FALLBACK_FLOWS;
return rows.map((item: any) => ({
id: String(item.id || ''),
name: String(item.name || item.title || 'Untitled Flow'),
userType: String(item.user_type || item.userType || item.role_key || ''),
totalSteps: Number(item.step_count || item.steps || item.totalSteps || 0),
requiredDocs: Number(item.required_docs || item.requiredDocs || 0),
verificationType: String(item.verification_type || item.verificationType || ''),
status: item.is_active ? 'ACTIVE' : String(item.status || 'DRAFT').toUpperCase(),
lastUpdated: String(item.updated_at || item.lastUpdated || '').slice(0, 10),
}));
} catch {
return FALLBACK_FLOWS;
}
}
function StatusBadge(props: { status: string }) {
const s = props.status;
if (s === 'ACTIVE') {
return (
<span style="display:inline-flex;border-radius:9999px;border:1px solid #FFD8C2;background:#FFF1EB;color:#FF5E13;padding:2px 10px;font-size:12px;font-weight:500">
Active
</span>
);
}
if (s === 'DRAFT') {
return (
<span style="display:inline-flex;border-radius:9999px;border:1px solid #FDE68A;background:#FFFBEB;color:#D97706;padding:2px 10px;font-size:12px;font-weight:500">
Draft
</span>
);
}
return (
<span style="display:inline-flex;border-radius:9999px;border:1px solid #D1D5DB;background:#F3F4F6;color:#4B5563;padding:2px 10px;font-size:12px;font-weight:500">
Inactive
</span>
);
}
function VerificationBadge(props: { type: string }) {
const t = props.type;
if (t === 'Identity Verification') {
return (
<span style="display:inline-flex;border-radius:9999px;border:1px solid #BFDBFE;background:#EFF6FF;color:#1D4ED8;padding:2px 10px;font-size:12px;font-weight:500">
{t}
</span>
);
}
if (t === 'Business Verification') {
return (
<span style="display:inline-flex;border-radius:9999px;border:1px solid #E9D5FF;background:#F5F3FF;color:#7C3AED;padding:2px 10px;font-size:12px;font-weight:500">
{t}
</span>
);
}
return (
<span style="display:inline-flex;border-radius:9999px;border:1px solid #D1D5DB;background:#F3F4F6;color:#4B5563;padding:2px 10px;font-size:12px;font-weight:500">
{t || '—'}
</span>
);
}
function ActionsMenu(props: { flowId: string; status: string }) {
const [open, setOpen] = createSignal(false);
return (
<div style="position:relative;display:inline-block">
<button
onClick={() => setOpen((v) => !v)}
style="width:32px;height:32px;border-radius:6px;border:1px solid #E5E7EB;background:white;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;color:#6B7280"
>
<MoreVertical size={15} />
</button>
<Show when={open()}>
<div
style="position:absolute;right:0;top:36px;background:white;border:1px solid #E5E7EB;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,0.10);z-index:100;min-width:168px;padding:4px 0"
onMouseLeave={() => setOpen(false)}
>
<button style="display:flex;align-items:center;gap:8px;width:100%;padding:8px 14px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
View Flow
</button>
<button style="display:flex;align-items:center;gap:8px;width:100%;padding:8px 14px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Edit Flow
</button>
<button style="display:flex;align-items:center;gap:8px;width:100%;padding:8px 14px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Duplicate Flow
</button>
<div style="height:1px;background:#F3F4F6;margin:4px 0" />
<Show when={props.status !== 'ACTIVE'}>
<button style="display:flex;align-items:center;gap:8px;width:100%;padding:8px 14px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Activate Flow
</button>
</Show>
<Show when={props.status === 'ACTIVE'}>
<button style="display:flex;align-items:center;gap:8px;width:100%;padding:8px 14px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
Deactivate Flow
</button>
</Show>
<button style="display:flex;align-items:center;gap:8px;width:100%;padding:8px 14px;font-size:13px;color:#EF4444;background:none;border:none;cursor:pointer;text-align:left">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#EF4444" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
Delete Flow
</button>
</div>
</Show>
</div>
);
}
export default function OnboardingManagementPage() {
const [flows] = createResource(loadFlows);
const [search, setSearch] = createSignal('');
const [statusFilter, setStatusFilter] = createSignal('All');
const [userTypeFilter, setUserTypeFilter] = createSignal('All');
const [activeTab, setActiveTab] = createSignal<'flow' | 'preview'>('flow');
const allFlows = () => flows() || FALLBACK_FLOWS;
const filtered = createMemo(() => {
const q = search().toLowerCase();
const st = statusFilter();
const ut = userTypeFilter();
return allFlows().filter((f) => {
const matchesSearch = !q || f.name.toLowerCase().includes(q) || f.userType.toLowerCase().includes(q);
const matchesStatus = st === 'All' || f.status === st;
const matchesType = ut === 'All' || f.userType === ut;
return matchesSearch && matchesStatus && matchesType;
});
});
const totalFlows = () => allFlows().length;
const activeFlows = () => allFlows().filter((f) => f.status === 'ACTIVE').length;
const draftFlows = () => allFlows().filter((f) => f.status === 'DRAFT').length;
const requireVerification = () => allFlows().filter((f) => f.verificationType && f.verificationType !== 'No Verification').length;
const recentlyUpdated = () => allFlows().filter((f) => f.lastUpdated >= '2024-03-18').length;
const STATS = [
{ label: 'Total Flows', value: () => totalFlows(), color: '#111827' },
{ label: 'Active Flows', value: () => activeFlows(), color: '#10B981' },
{ label: 'Draft Flows', value: () => draftFlows(), color: '#F59E0B' },
{ label: 'Flows Requiring Verification', value: () => requireVerification(), color: '#FF5E13' },
{ label: 'Recently Updated', value: () => recentlyUpdated(), color: '#3B82F6' },
];
return (
<AdminShell>
<div style="width:100%;padding-bottom:32px">
{/* Page Header */}
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:24px">
<div>
<h1 style="font-size:24px;font-weight:700;color:#111827;margin:0 0 4px 0">External Onboarding Management</h1>
<p style="font-size:14px;color:#6B7280;margin:0">Create and manage onboarding flows for external users.</p>
</div>
<div style="display:flex;border-radius:10px;overflow:hidden;border:1px solid #E5E7EB">
<button
onClick={() => setActiveTab('flow')}
style={activeTab() === 'flow'
? 'background:#0D0D2A;color:white;padding:8px 16px;font-size:13px;font-weight:600;border:none;cursor:pointer;border-radius:0'
: 'background:white;color:#374151;padding:8px 16px;font-size:13px;font-weight:500;border-left:1px solid #E5E7EB;border:1px solid #E5E7EB;cursor:pointer'}
>
Flow Management
</button>
<button
onClick={() => setActiveTab('preview')}
style={activeTab() === 'preview'
? 'background:#0D0D2A;color:white;padding:8px 16px;font-size:13px;font-weight:600;border:none;cursor:pointer;border-radius:0'
: 'background:white;color:#374151;padding:8px 16px;font-size:13px;font-weight:500;border-left:1px solid #E5E7EB;border:1px solid #E5E7EB;cursor:pointer'}
>
User Preview
</button>
</div>
</div>
{/* Stats Cards */}
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:16px;margin-bottom:24px">
<For each={STATS}>
{(stat) => (
<div style="border-radius:12px;border:1px solid #E5E7EB;background:white;padding:16px">
<p style="font-size:12px;color:#6B7280;margin:0 0 8px 0">{stat.label}</p>
<p style={`font-size:28px;font-weight:700;color:${stat.color};margin:0`}>
{flows.loading ? '—' : stat.value()}
</p>
</div>
)}
</For>
</div>
{/* Flow Management View */}
<Show when={activeTab() === 'flow'}>
<div style="border-radius:12px;border:1px solid #E5E7EB;background:white;overflow:hidden">
{/* Section Header */}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0;padding:16px 20px;border-bottom:1px solid #F3F4F6">
<h2 style="font-size:18px;font-weight:700;color:#111827;margin:0">Onboarding Flow Management</h2>
<div style="display:flex;gap:8px;align-items:center">
<button style="height:34px;padding:0 14px;border-radius:8px;border:1px solid #E5E7EB;background:white;font-size:13px;color:#374151;cursor:pointer">
Import Flow
</button>
<button style="height:34px;padding:0 14px;border-radius:8px;border:1px solid #E5E7EB;background:white;font-size:13px;color:#374151;cursor:pointer">
Export Flow
</button>
<A
href="/admin/onboarding-management/new"
style="height:34px;padding:0 14px;border-radius:8px;background:#0D0D2A;color:white;font-size:13px;font-weight:500;border:none;cursor:pointer;display:inline-flex;align-items:center;gap:6px;text-decoration:none"
>
+ Create Onboarding Flow
</A>
</div>
</div>
{/* Filter Bar */}
<div style="display:flex;gap:8px;margin-bottom:0;padding:12px 20px;border-bottom:1px solid #F3F4F6">
<div style="position:relative;flex:1;max-width:280px">
<svg style="position:absolute;left:10px;top:50%;transform:translateY(-50%);color:#9CA3AF" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input
type="text"
placeholder="Search flows..."
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
style="height:34px;width:100%;border:1px solid #E5E7EB;border-radius:8px;padding:0 12px 0 30px;font-size:13px;color:#111827;outline:none;box-sizing:border-box;background:white"
/>
</div>
<select
value={statusFilter()}
onChange={(e) => setStatusFilter(e.currentTarget.value)}
style="height:34px;border:1px solid #E5E7EB;border-radius:8px;padding:0 12px;font-size:13px;color:#374151;background:white;outline:none;cursor:pointer"
>
<option value="All">Status: All</option>
<option value="ACTIVE">Active</option>
<option value="DRAFT">Draft</option>
<option value="INACTIVE">Inactive</option>
</select>
<select
value={userTypeFilter()}
onChange={(e) => setUserTypeFilter(e.currentTarget.value)}
style="height:34px;border:1px solid #E5E7EB;border-radius:8px;padding:0 12px;font-size:13px;color:#374151;background:white;outline:none;cursor:pointer"
>
<option value="All">User Type: All</option>
<option value="Customer">Customer</option>
<option value="Professional">Professional</option>
<option value="Company">Company</option>
<option value="Jobseeker">Jobseeker</option>
</select>
</div>
{/* Table */}
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;min-width:960px">
<thead>
<tr style="background:#0D0D2A">
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left">FLOW NAME</th>
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left">USER TYPE</th>
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left">TOTAL STEPS</th>
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left">REQUIRED DOCUMENTS</th>
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left">VERIFICATION TYPE</th>
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left">STATUS</th>
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left">LAST UPDATED</th>
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;text-align:left">ACTIONS</th>
</tr>
</thead>
<tbody>
<Show when={flows.loading}>
<tr>
<td colspan="8" style="padding:32px 20px;text-align:center;color:#9CA3AF;font-size:14px">Loading flows...</td>
</tr>
</Show>
<Show when={!flows.loading && filtered().length === 0}>
<tr>
<td colspan="8" style="padding:32px 20px;text-align:center;color:#9CA3AF;font-size:14px">No onboarding flows found.</td>
</tr>
</Show>
<For each={filtered()}>
{(flow) => (
<tr style="border-bottom:1px solid #F3F4F6">
<td style="padding:12px 20px;font-size:14px;color:#111827;font-weight:600">{flow.name}</td>
<td style="padding:12px 20px;font-size:14px;color:#374151">{flow.userType}</td>
<td style="padding:12px 20px;font-size:14px;color:#374151">{flow.totalSteps}</td>
<td style="padding:12px 20px;font-size:14px;color:#374151">{flow.requiredDocs}</td>
<td style="padding:12px 20px">
<VerificationBadge type={flow.verificationType} />
</td>
<td style="padding:12px 20px">
<StatusBadge status={flow.status} />
</td>
<td style="padding:12px 20px;font-size:13px;color:#6B7280">{flow.lastUpdated || '—'}</td>
<td style="padding:12px 20px">
<ActionsMenu flowId={flow.id} status={flow.status} />
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
{/* Pagination */}
<div style="display:flex;justify-content:space-between;align-items:center;border-top:1px solid #F3F4F6;padding:12px 20px">
<span style="font-size:13px;color:#6B7280">
Showing {filtered().length} of {allFlows().length} flows
</span>
<div style="display:flex;gap:4px">
<button style="height:32px;min-width:32px;border:1px solid #E5E7EB;background:white;border-radius:6px;font-size:13px;color:#374151;cursor:pointer;padding:0 10px">
Previous
</button>
<button style="height:32px;min-width:32px;border:1px solid #E5E7EB;background:#0D0D2A;border-radius:6px;font-size:13px;color:white;cursor:pointer;padding:0 10px">
1
</button>
<button style="height:32px;min-width:32px;border:1px solid #E5E7EB;background:white;border-radius:6px;font-size:13px;color:#374151;cursor:pointer;padding:0 10px">
Next
</button>
</div>
</div>
</div>
</Show>
{/* User Preview View */}
<Show when={activeTab() === 'preview'}>
<div style="border-radius:12px;border:1px solid #E5E7EB;background:white;padding:32px;text-align:center">
<div style="margin-bottom:24px">
<label style="font-size:13px;font-weight:500;color:#374151;display:block;margin-bottom:8px">
Select a flow to preview
</label>
<select style="height:36px;border:1px solid #E5E7EB;border-radius:8px;padding:0 12px;font-size:13px;color:#374151;background:white;outline:none;cursor:pointer;min-width:280px">
<option value=""> Choose a flow </option>
<For each={allFlows()}>
{(f) => <option value={f.id}>{f.name}</option>}
</For>
</select>
</div>
<div style="padding:48px 0;color:#9CA3AF">
<svg style="margin:0 auto 16px;display:block;color:#D1D5DB" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
<p style="font-size:14px;color:#9CA3AF;margin:0">Select a flow above to see a user-facing preview.</p>
</div>
</div>
</Show>
</div>
</AdminShell>
);
export default function OnboardingManagementAliasPage() {
return <Navigate href="/admin/onboarding-schemas" />;
}

View file

@ -1,5 +1,5 @@
import { A, useParams } from '@solidjs/router';
import { createEffect, createResource, createSignal, Show } from 'solid-js';
import { createEffect, createResource, createSignal, onMount, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import OnboardingManagementTabs from '~/components/admin/OnboardingManagementTabs';
import OnboardingFlowBuilder, {
@ -46,6 +46,9 @@ function flattenSteps(steps: OnboardingStep[]): OnboardingField[] {
export default function OnboardingSchemaDetailPage() {
const params = useParams();
const [schema] = createResource(() => params.schemaId, loadSchema);
const [roleMap, setRoleMap] = createSignal<Record<string, string>>({});
const [roleKeyById, setRoleKeyById] = createSignal<Record<string, string>>({});
const [roleOptions, setRoleOptions] = createSignal<{ value: string; label: string }[]>([]);
const [title, setTitle] = createSignal('');
const [roleKey, setRoleKey] = createSignal('');
@ -58,13 +61,55 @@ export default function OnboardingSchemaDetailPage() {
const [loaded, setLoaded] = createSignal(false);
const [livePreviewUrl, setLivePreviewUrl] = createSignal('');
onMount(async () => {
try {
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
const res = await fetch(`${API}/api/admin/roles?audience=EXTERNAL`, {
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
});
if (!res.ok) return;
const payload = await res.json();
const rows = Array.isArray(payload) ? payload : (payload.roles || []);
const keyToId: Record<string, string> = {};
const idToKey: Record<string, string> = {};
const options: { value: string; label: string }[] = [];
rows
.filter((item: any) => String(item?.audience || '').toUpperCase() === 'EXTERNAL')
.forEach((item: any) => {
const key = String(item?.key || '').trim().toUpperCase();
const id = String(item?.id || '').trim();
if (!key || !id) return;
keyToId[key] = id;
idToKey[id] = key.toLowerCase();
options.push({ value: key.toLowerCase(), label: String(item?.name || key).trim() });
});
setRoleMap(keyToId);
setRoleKeyById(idToKey);
setRoleOptions(options);
if (!roleKey() && idToKey[String(params.schemaId || '').trim()]) {
setRoleKey(idToKey[String(params.schemaId || '').trim()]);
}
} catch {
setRoleMap({});
setRoleKeyById({});
setRoleOptions([]);
}
});
createEffect(() => {
const next = schema();
if (!next || loaded()) return;
const payload = next.schema_json || {};
const steps = payload.steps || [];
setTitle(payload.title || '');
setRoleKey(payload.roleKey || 'company');
const fallbackRole = roleKeyById()[String(params.schemaId || '').trim()] || 'company';
setRoleKey(payload.roleKey || fallbackRole);
setDescription(payload.description || '');
setFinalSubmissionMessage(payload.finalSubmissionMessage || 'Your onboarding has been submitted for review. We will notify you once it is approved.');
setStepCount(inferStepCount(steps));
@ -72,6 +117,13 @@ export default function OnboardingSchemaDetailPage() {
setLoaded(true);
});
createEffect(() => {
if (!loaded()) return;
if (roleKey()) return;
const fallback = roleKeyById()[String(params.schemaId || '').trim()];
if (fallback) setRoleKey(fallback);
});
const handleChange = (next: {
title?: string;
roleKey?: string;
@ -93,11 +145,20 @@ export default function OnboardingSchemaDetailPage() {
setSaving(true);
setError('');
const current = schema();
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
const normalizedRole = normalizeRoleKey(roleKey());
const selectedRoleId = roleMap()[normalizedRole] || String(params.schemaId || '').trim();
const response = await fetch(`${API}/api/admin/onboarding-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
body: JSON.stringify({
role_id: params.schemaId,
role_id: selectedRoleId,
schema_json: {
title: title(),
roleKey: roleKey(),
@ -110,6 +171,11 @@ export default function OnboardingSchemaDetailPage() {
});
const payload = await response.json();
if (!response.ok) throw new Error(payload?.message || 'Failed to save onboarding flow');
const nextRoleId = String(payload?.role_id || selectedRoleId).trim();
if (nextRoleId && nextRoleId !== String(params.schemaId || '').trim()) {
window.location.href = `/admin/onboarding-schemas/${encodeURIComponent(nextRoleId)}`;
return;
}
setLoaded(false);
await schema.refetch();
} catch (nextError: any) {
@ -140,7 +206,7 @@ export default function OnboardingSchemaDetailPage() {
<p class="text-sm text-gray-500 mt-0.5">Open one onboarding form at a time, check if it is published, then update the role, questions, steps, and final success message.</p>
</div>
<div class="flex items-center gap-2">
<button class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" type="button" disabled={saving()} onClick={() => void persist(true)}>
<button class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" type="button" disabled={saving()} onClick={() => void persist()}>
Save Active Version
</button>
<A class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href="/admin/onboarding-schemas">Back to Onboarding Management</A>
@ -177,6 +243,7 @@ export default function OnboardingSchemaDetailPage() {
error={error()}
livePreviewUrl={livePreviewUrl()}
livePreviewHint="Edit page preview loads the exact flow by schema id in the real onboarding UI."
roleOptions={roleOptions()}
primaryLabel="Save Onboarding Flow"
onChange={handleChange}
onSubmit={() => void persist()}

View file

@ -18,6 +18,7 @@ function normalizeRoleKey(value: string): string {
export default function NewOnboardingSchemaPage() {
const navigate = useNavigate();
const [roleMap, setRoleMap] = createSignal<Record<string, string>>({});
const [roleOptions, setRoleOptions] = createSignal<{ value: string; label: string }[]>([]);
const [title, setTitle] = createSignal('');
const [roleKey, setRoleKey] = createSignal('company');
const [description, setDescription] = createSignal('');
@ -29,21 +30,41 @@ export default function NewOnboardingSchemaPage() {
onMount(async () => {
try {
const res = await fetch(`${API}/api/admin/roles?audience=EXTERNAL`);
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
const res = await fetch(`${API}/api/admin/roles?audience=EXTERNAL`, {
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
});
if (!res.ok) return;
const payload = await res.json();
const rows = Array.isArray(payload) ? payload : (payload.roles || []);
const map: Record<string, string> = {};
const options: { value: string; label: string }[] = [];
rows
.filter((item: any) => String(item?.audience || '').toUpperCase() === 'EXTERNAL')
.forEach((item: any) => {
const key = String(item?.key || '').trim().toUpperCase();
if (!key) return;
map[key] = String(item?.id || '');
options.push({
value: key.toLowerCase(),
label: String(item?.name || key).trim(),
});
});
setRoleMap(map);
setRoleOptions(options);
if (options.length > 0 && !map[normalizeRoleKey(roleKey())]) {
setRoleKey(options[0].value);
setSelectedFields(createDefaultFields(options[0].value));
}
} catch {
setRoleMap({});
setRoleOptions([]);
}
});
@ -83,6 +104,9 @@ export default function NewOnboardingSchemaPage() {
try {
setSaving(true);
setError('');
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
const normalizedRole = normalizeRoleKey(roleKey());
const roleId = roleMap()[normalizedRole];
if (!roleId) {
@ -90,7 +114,11 @@ export default function NewOnboardingSchemaPage() {
}
const response = await fetch(`${API}/api/admin/onboarding-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
body: JSON.stringify({ role_id: roleId, schema_json: payload() }),
});
const body = await response.json();
@ -133,6 +161,7 @@ export default function NewOnboardingSchemaPage() {
error={error()}
livePreviewUrl={livePreviewUrl()}
livePreviewHint="Create page preview uses the role-level runtime onboarding flow. Save the flow first to preview the exact saved flow by schema id."
roleOptions={roleOptions()}
primaryLabel="Create Onboarding Flow"
onChange={handleChange}
onSubmit={handleSubmit}

View file

@ -1,127 +1,13 @@
import { A } from '@solidjs/router';
import { createMemo, createResource, createSignal, For, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
async function fetchUsers(): Promise<any[]> {
try {
const res = await fetch(`${API}/api/admin/users?role=photographer`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.users || []);
} catch {
return [];
}
}
import RoleUserManagementTablePage from '~/components/admin/RoleUserManagementTablePage';
export default function PhotographerPage() {
const [users] = createResource(fetchUsers);
const [search, setSearch] = createSignal('');
const [statusFilter, setStatusFilter] = createSignal('');
const filtered = createMemo(() => {
const list = users() ?? [];
const q = search().toLowerCase();
const s = statusFilter();
return list.filter((u) => {
const matchSearch =
!q ||
(u.name || u.full_name || '').toLowerCase().includes(q) ||
(u.email || '').toLowerCase().includes(q);
const matchStatus = !s || (u.status || '').toUpperCase() === s;
return matchSearch && matchStatus;
});
});
return (
<AdminShell>
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
{/* White page header */}
<div class="bg-white border-b border-gray-200 px-6 py-4">
<h1 class="text-xl font-semibold text-gray-900">Photographer Management</h1>
<p class="text-sm text-gray-500 mt-0.5">Manage all photographer accounts on the platform.</p>
</div>
{/* Content */}
<div class="flex-1 p-6">
<div class="mb-4 flex flex-wrap items-center gap-3">
<input
type="text"
placeholder="Search by name or email..."
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
class="rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] w-64"
/>
<select
value={statusFilter()}
onChange={(e) => setStatusFilter(e.currentTarget.value)}
class="rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37]"
>
<option value="">All Status</option>
<option value="ACTIVE">ACTIVE</option>
<option value="INACTIVE">INACTIVE</option>
<option value="PENDING">PENDING</option>
</select>
</div>
<div class="table-card">
<div class="overflow-x-auto">
<table class="data-table w-full text-sm">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Registered</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={users.loading}>
<tr><td colspan="5" class="text-center py-8 text-slate-500">Loading...</td></tr>
</Show>
<Show when={!users.loading && users.error}>
<tr><td colspan="5" class="text-center py-8 text-red-700">Failed to load. Is the backend running?</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length === 0}>
<tr><td colspan="5" class="text-center py-8 text-slate-400">No photographer users found.</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length > 0}>
<For each={filtered()}>
{(item) => (
<tr class="hover:bg-slate-50">
<td class="font-semibold text-slate-900">{item.name || item.full_name || '—'}</td>
<td class="text-slate-500">{item.email}</td>
<td>
{item.status?.toUpperCase() === 'ACTIVE' && (
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-700">ACTIVE</span>
)}
{item.status?.toUpperCase() === 'INACTIVE' && (
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">INACTIVE</span>
)}
{item.status?.toUpperCase() === 'PENDING' && (
<span class="inline-flex items-center rounded-full bg-orange-50 px-2.5 py-0.5 text-xs font-medium text-orange-700">PENDING</span>
)}
{!item.status && <span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600"></span>}
</td>
<td class="text-slate-500">
{item.created_at ? new Date(item.created_at).toLocaleDateString() : '—'}
</td>
<td>
<div class="flex items-center justify-end gap-1">
<A class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm hover:bg-gray-50" href={`/admin/photographer/${item.id}`}>View</A>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</div>
</div>
</div>
</AdminShell>
<RoleUserManagementTablePage
role="photographer"
title="Photographer Management"
subtitle="Manage all photographer accounts on the platform."
emptyLabel="No photographer users found."
viewHref={(id) => `/admin/photographer/${id}`}
/>
);
}

View file

@ -168,13 +168,22 @@ export default function EditInternalRolePage() {
};
const handleSave = async () => {
if (saving()) return;
if (!roleName().trim()) { setError('Role name is required'); setSubTab('general'); return; }
setError('');
try {
setSaving(true);
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const res = await fetch(`${API}/api/admin/roles/${params.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
body: JSON.stringify({
name: roleName().trim(),
description: description().trim() || null,
@ -185,13 +194,20 @@ export default function EditInternalRolePage() {
permission_keys: [...selectedKeys()],
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error((body as any).message || 'Failed to update role');
const raw = await res.text();
let message = '';
if (raw) {
try {
const parsed = JSON.parse(raw) as { message?: string; error?: string };
message = parsed?.message || parsed?.error || '';
} catch {
message = raw;
}
}
if (!res.ok) throw new Error(message || `Failed to update role (${res.status})`);
navigate(`/admin/roles/${params.id}`);
} catch (err: any) {
setError(err.message || 'Failed to update role');
setError(String(err?.message || '').trim() || 'Failed to update role');
} finally {
setSaving(false);
}

View file

@ -55,6 +55,7 @@ export default function RoleDetailPage() {
const [role] = createResource(() => params.id, loadRole);
const permSet = createMemo(() => new Set(role()?.permission_keys ?? []));
const isSuperAdmin = createMemo(() => (role()?.key || '').toUpperCase() === 'SUPER_ADMIN');
return (
<AdminShell>
@ -176,7 +177,7 @@ export default function RoleDetailPage() {
<For each={ACTIONS}>
{(action) => {
const key = makeKey(module, action);
const checked = () => permSet().has(key);
const checked = () => isSuperAdmin() || permSet().has(key);
return (
<td class="px-4 py-3.5 text-center">
<input

View file

@ -1,5 +1,5 @@
import { A, useNavigate } from '@solidjs/router';
import { createMemo, createResource, createSignal, For, Show } from 'solid-js';
import { createEffect, createMemo, createResource, createSignal, For, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
@ -7,6 +7,15 @@ const API = '/api/gateway';
type Permission = { key: string; module: string; action: string };
type Department = { id: string; name: string };
function formatRoleKey(input: string): string {
return input
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.replace(/_{2,}/g, '_');
}
async function loadPermissions(): Promise<Permission[]> {
try {
const res = await fetch(`${API}/api/admin/permissions`);
@ -79,6 +88,10 @@ export default function CreateInternalRolePage() {
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal('');
createEffect(() => {
setRoleCode(formatRoleKey(roleName()));
});
// Group permissions by module
const permsByModule = createMemo(() => {
const src = permissions() ?? STATIC_PERMISSIONS;
@ -125,17 +138,27 @@ export default function CreateInternalRolePage() {
};
const handleSave = async () => {
if (saving()) return;
if (!roleName().trim()) { setError('Role name is required'); setSubTab('general'); return; }
if (!roleCode().trim()) { setError('Role code is required'); setSubTab('general'); return; }
const normalizedRoleCode = formatRoleKey(roleName());
if (!normalizedRoleCode) { setError('Role code is required'); setSubTab('general'); return; }
setError('');
try {
setSaving(true);
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const res = await fetch(`${API}/api/admin/roles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
body: JSON.stringify({
key: roleCode().trim(),
key: normalizedRoleCode,
name: roleName().trim(),
audience: 'INTERNAL',
description: description().trim() || null,
@ -146,13 +169,20 @@ export default function CreateInternalRolePage() {
permission_keys: [...selectedKeys()],
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error((body as any).message || 'Failed to create role');
const raw = await res.text();
let message = '';
if (raw) {
try {
const parsed = JSON.parse(raw) as { message?: string; error?: string };
message = parsed?.message || parsed?.error || '';
} catch {
message = raw;
}
}
if (!res.ok) throw new Error(message || `Failed to create role (${res.status})`);
navigate('/admin/roles');
} catch (err: any) {
setError(err.message || 'Failed to create role');
setError(String(err?.message || '').trim() || 'Failed to create role');
} finally {
setSaving(false);
}
@ -229,11 +259,14 @@ export default function CreateInternalRolePage() {
</label>
<input
type="text"
placeholder="e.g., ENG-LEAD-001"
placeholder="Auto-generated from role name"
value={roleCode()}
onInput={(e) => setRoleCode(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#e5e7eb] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#0D0D2A] placeholder-[rgba(13,13,42,0.3)]"
readOnly
class="w-full px-3 py-2.5 text-[13px] border border-[#e5e7eb] rounded-lg bg-[#F9FAFB] text-[#0D0D2A]"
/>
<p class="mt-1 text-[11px] text-[rgba(13,13,42,0.5)]">
This value is generated automatically (example: HR_MANAGER).
</p>
</div>
</div>
<div>

View file

@ -1,8 +1,32 @@
import { For, Show, createMemo, createSignal, onMount } from 'solid-js';
import { For, Show, createEffect, createMemo, createResource, createSignal, onMount } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import type { CrudRecord } from '~/lib/admin/types';
const API = '/api/gateway';
const ACTIONS = ['View', 'Create', 'Update', 'Delete'] as const;
const STATIC_MODULES = [
'Department Management', 'Designation Management', 'Internal Role Management',
'Employee Management', 'External Role Management', 'External Onboarding Management',
'Internal Dashboard Management', 'External Dashboard Management', 'Verification Management',
'Approval Management', 'Users Management', 'Company Management', 'Candidate Management',
'Customer Management', 'Photographer Management', 'Makeup Artist Management',
'Tutor Management', 'Developer Management', 'Fitness Trainer Management',
'Graphic Designer Management', 'Social Media Management', 'Video Editor Management',
'Catering Services Management', 'Jobs Management', 'Leads Management',
'Applications Management', 'Responses Management', 'Review Management',
'Pricing Management', 'Credit Management', 'Coupon Management', 'Discount Management',
'Tax Management', 'Order Management', 'Invoice Management', 'Ledger Management',
'Knowledge Base Management', 'Support Management', 'Report Management', 'Notifications',
] as const;
function formatRoleKey(input: string): string {
return input
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.replace(/_{2,}/g, '_');
}
type RoleRecord = CrudRecord & {
key?: string;
@ -17,27 +41,29 @@ type RoleRecord = CrudRecord & {
};
type DepartmentOption = { id: string; name: string };
type Permission = { key: string; module: string; action: string };
// Permission matrix: each module maps to CRUD permission keys
const MODULE_PERMISSIONS = [
{ module: 'Dashboard', prefix: 'ADMIN_DASHBOARD' },
{ module: 'Employee Management', prefix: 'EMPLOYEES' },
{ module: 'Department Management', prefix: 'DEPARTMENTS' },
{ module: 'Designation Management', prefix: 'DESIGNATIONS' },
{ module: 'Role Management', prefix: 'ROLES' },
{ module: 'External Role Management', prefix: 'EXTERNAL_ROLES' },
{ module: 'External Onboarding Management', prefix: 'ONBOARDING' },
{ module: 'Internal Dashboard Management', prefix: 'INTERNAL_DASHBOARDS' },
{ module: 'External Dashboard Management', prefix: 'EXTERNAL_DASHBOARDS' },
{ module: 'Verification Management', prefix: 'VERIFICATIONS' },
{ module: 'Approval Management', prefix: 'APPROVALS' },
{ module: 'Users Management', prefix: 'USERS' },
{ module: 'Company Management', prefix: 'COMPANIES' },
];
const ACTIONS = ['VIEW', 'CREATE', 'UPDATE', 'DELETE'] as const;
function makeKey(module: string, action: string) {
return `${module.replace(/ /g, '_').toLowerCase()}:${action.toLowerCase()}`;
}
function permKey(prefix: string, action: string) {
return `${prefix}_${action}`;
async function loadPermissions(): Promise<Permission[]> {
try {
const res = await fetch(`${API}/api/admin/permissions`);
if (!res.ok) throw new Error();
const data = await res.json();
return Array.isArray(data)
? data
: [];
} catch {
return STATIC_MODULES.flatMap((module) =>
ACTIONS.map((action) => ({
key: makeKey(module, action),
module,
action,
})),
);
}
}
function normalizeRole(item: any, idx: number): RoleRecord {
@ -87,6 +113,7 @@ function FormInput(props: { label: string; required?: boolean; value: string; on
export default function RoleManagementPage() {
const [view, setView] = createSignal<'list' | 'form' | 'detail'>('list');
const [permissions] = createResource(loadPermissions);
const [listTab, setListTab] = createSignal<'all' | 'create' | 'view'>('all');
const [formTab, setFormTab] = createSignal<'general' | 'permissions' | 'settings'>('general');
const [detailTab, setDetailTab] = createSignal<'permissions' | 'users' | 'logs'>('permissions');
@ -99,6 +126,10 @@ export default function RoleManagementPage() {
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
const [isLoading, setIsLoading] = createSignal(false);
const [error, setError] = createSignal('');
const [statusFilter, setStatusFilter] = createSignal<'all' | 'ACTIVE' | 'INACTIVE'>('all');
const [sortBy, setSortBy] = createSignal<'name_asc' | 'name_desc' | 'users_desc' | 'users_asc'>('name_asc');
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
// Form state
const [name, setName] = createSignal('');
@ -112,6 +143,7 @@ export default function RoleManagementPage() {
const [isSaving, setIsSaving] = createSignal(false);
const [formError, setFormError] = createSignal('');
const [departments, setDepartments] = createSignal<DepartmentOption[]>([]);
const isViewingSuperAdmin = createMemo(() => (viewingRole()?.key || '').toUpperCase() === 'SUPER_ADMIN');
const load = async () => {
setIsLoading(true);
@ -167,12 +199,73 @@ export default function RoleManagementPage() {
const filteredRows = createMemo(() => {
const q = search().toLowerCase();
if (!q) return rows();
return rows().filter(r =>
r.name.toLowerCase().includes(q) || (r.key || '').toLowerCase().includes(q)
);
let list = rows();
if (statusFilter() !== 'all') {
list = list.filter((r) => r.status === statusFilter());
}
if (q) {
list = list.filter(r =>
r.name.toLowerCase().includes(q) || (r.key || '').toLowerCase().includes(q)
);
}
const sorted = [...list];
sorted.sort((a, b) => {
if (sortBy() === 'name_desc') return b.name.localeCompare(a.name);
if (sortBy() === 'users_desc') return Number(b.usersAssigned || 0) - Number(a.usersAssigned || 0);
if (sortBy() === 'users_asc') return Number(a.usersAssigned || 0) - Number(b.usersAssigned || 0);
return a.name.localeCompare(b.name);
});
return sorted;
});
const exportCsv = () => {
const headers = ['Role Name', 'Role Key', 'Department', 'Users', 'Permissions', 'Status'];
const rowsData = filteredRows().map((row) => [
row.name || '',
row.key || '',
row.department || '',
String(row.usersAssigned ?? 0),
(row.key || '').toUpperCase() === 'SUPER_ADMIN' ? 'All' : String(row.permissionsCount ?? 0),
row.status || '',
]);
const csv = [headers, ...rowsData]
.map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `internal-roles-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const permissionKeyByModuleAction = createMemo(() => {
const map = new Map<string, string>();
const src = permissions() ?? [];
src.forEach((p) => {
const moduleKey = String(p.module || '').trim().toUpperCase();
const actionKey = String(p.action || '').trim().toUpperCase();
if (!moduleKey || !actionKey) return;
map.set(`${moduleKey}::${actionKey}`, String(p.key || '').trim());
});
return map;
});
const orderedModules = createMemo(() => {
const fromApi = Array.from(
new Set((permissions() ?? []).map((p) => String(p.module || '').trim()).filter(Boolean)),
);
const ordered = [...STATIC_MODULES.filter((m) => fromApi.includes(m))];
const extras = fromApi.filter((m) => !ordered.includes(m)).sort();
return [...ordered, ...extras];
});
const permissionKeyFor = (module: string, action: string) =>
permissionKeyByModuleAction().get(`${module.toUpperCase()}::${action.toUpperCase()}`) || '';
const togglePermission = (key: string) => {
setSelectedPermissions(prev => {
const next = new Set(prev);
@ -223,7 +316,9 @@ export default function RoleManagementPage() {
};
const save = async () => {
if (!name().trim() || !roleKey().trim()) {
if (isSaving()) return;
const normalizedRoleKey = editingId() ? formatRoleKey(roleKey()) : formatRoleKey(name());
if (!name().trim() || !normalizedRoleKey) {
setFormError('Role name and role key are required.');
setFormTab('general');
return;
@ -232,7 +327,7 @@ export default function RoleManagementPage() {
setFormError('');
try {
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const isCreate = !editingId();
const endpoint = isCreate
@ -249,7 +344,7 @@ export default function RoleManagementPage() {
};
if (departmentId().trim()) body.department_id = departmentId().trim();
if (isCreate) {
body.key = roleKey().trim();
body.key = normalizedRoleKey;
body.audience = 'INTERNAL';
}
const res = await fetch(endpoint, {
@ -262,26 +357,38 @@ export default function RoleManagementPage() {
credentials: 'include',
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error((data as any).message || `Request failed (${res.status})`);
const raw = await res.text();
let message = '';
if (raw) {
try {
const parsed = JSON.parse(raw) as { message?: string; error?: string };
message = parsed?.message || parsed?.error || '';
} catch {
message = raw;
}
}
if (!res.ok) throw new Error(message || `Request failed (${res.status})`);
setView('list');
resetForm();
await load();
} catch (err: any) {
setFormError(err?.message || 'Failed to save role.');
setFormError(String(err?.message || '').trim() || 'Failed to save role.');
} finally {
setIsSaving(false);
}
};
createEffect(() => {
if (editingId()) return;
setRoleKey(formatRoleKey(name()));
});
const deleteRole = async (id: string, roleName: string) => {
if (!window.confirm(`Delete role "${roleName}"?`)) return;
setOpenMenuId(null);
try {
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const res = await fetch(`${API}/api/admin/roles/${id}`, {
method: 'DELETE',
@ -302,7 +409,7 @@ export default function RoleManagementPage() {
setOpenMenuId(null);
try {
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const res = await fetch(`${API}/api/admin/roles/${row.id}`, {
method: 'PATCH',
@ -360,7 +467,53 @@ export default function RoleManagementPage() {
placeholder="Search roles..."
style="height:34px;flex:1;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none"
/>
<button type="button" style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:600;color:white;border:none;cursor:pointer">Export</button>
<div style="position:relative">
<button
type="button"
onClick={() => { setSortMenuOpen((v) => !v); setFilterMenuOpen(false); }}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 4v13"/><path d="m3 13 4 4 4-4"/><path d="M17 20V7"/><path d="m21 11-4-4-4 4"/></svg>
Sort
</button>
<Show when={sortMenuOpen()}>
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:200px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
{([
['name_asc', 'Name (A-Z)'],
['name_desc', 'Name (Z-A)'],
['users_desc', 'Users (High-Low)'],
['users_asc', 'Users (Low-High)'],
] as const).map(([key, label]) => (
<button type="button" onClick={() => { setSortBy(key); setSortMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === key ? '#FF5E13' : '#374151'};background:${sortBy() === key ? '#FFF1EB' : 'transparent'}`}>{label}</button>
))}
</div>
</Show>
</div>
<div style="position:relative">
<button
type="button"
onClick={() => { setFilterMenuOpen((v) => !v); setSortMenuOpen(false); }}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h18M6 12h12M10 19h4"/></svg>
Filters
</button>
<Show when={filterMenuOpen()}>
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:160px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
{([
['all', 'All Status'],
['ACTIVE', 'Active'],
['INACTIVE', 'Inactive'],
] as const).map(([key, label]) => (
<button type="button" onClick={() => { setStatusFilter(key as any); setFilterMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === key ? '#FF5E13' : '#374151'};background:${statusFilter() === key ? '#FFF1EB' : 'transparent'}`}>{label}</button>
))}
</div>
</Show>
</div>
<button type="button" onClick={exportCsv} style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:600;color:white;border:none;cursor:pointer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Export
</button>
</div>
<div class="overflow-x-auto">
@ -403,7 +556,9 @@ export default function RoleManagementPage() {
<td style="padding:12px 20px;font-size:12px;font-family:monospace;color:#6B7280">{row.key || '—'}</td>
<td style="padding:12px 20px;font-size:13px;color:#374151">{row.department || '—'}</td>
<td style="padding:12px 20px;font-size:13px;color:#374151">{Number(row.usersAssigned || 0)} users</td>
<td style="padding:12px 20px;font-size:13px;color:#374151">{Number(row.permissionsCount || 0)}</td>
<td style="padding:12px 20px;font-size:13px;color:#374151">
{(row.key || '').toUpperCase() === 'SUPER_ADMIN' ? 'All' : Number(row.permissionsCount || 0)}
</td>
<td style="padding:12px 20px"><StatusBadge status={row.status} /></td>
<td style="padding:12px 20px;position:relative">
<button type="button" onClick={() => setOpenMenuId(openMenuId() === row.id ? null : row.id)} style="display:inline-flex;height:32px;width:32px;align-items:center;justify-content:center;border-radius:8px;color:#9CA3AF;background:none;border:none;cursor:pointer">
@ -472,7 +627,22 @@ export default function RoleManagementPage() {
<div style="display:flex;flex-direction:column;gap:20px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
<FormInput label="Role Name" required value={name()} onInput={setName} placeholder="e.g. HR Manager" />
<FormInput label="Role Key" required value={roleKey()} onInput={setRoleKey} placeholder="e.g. HR_MANAGER" />
<label style="display:block">
<span style="font-size:13px;font-weight:600;color:#374151">
Role Key
<span style="margin-left:2px;color:#FF5E13">*</span>
</span>
<input
type="text"
placeholder="Auto-generated from role name"
value={roleKey()}
readOnly
style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;background:#F9FAFB;padding:0 14px;font-size:13px;color:#111827;outline:none;box-sizing:border-box"
/>
<p style="margin-top:6px;font-size:11px;color:#6B7280">
Generated automatically from Role Name.
</p>
</label>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
<label style="display:block">
@ -525,25 +695,27 @@ export default function RoleManagementPage() {
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase">Module</th>
<For each={ACTIONS}>
{(action) => (
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase;text-align:center">{action.charAt(0) + action.slice(1).toLowerCase()}</th>
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase;text-align:center">{action}</th>
)}
</For>
</tr>
</thead>
<tbody>
<For each={MODULE_PERMISSIONS}>
{(mp) => (
<For each={orderedModules()}>
{(module) => (
<tr style="border-top:1px solid #E5E7EB">
<td style="padding:12px 16px;font-size:13px;font-weight:600;color:#111827">{mp.module}</td>
<td style="padding:12px 16px;font-size:13px;font-weight:600;color:#111827">{module}</td>
<For each={ACTIONS}>
{(action) => {
const pk = permKey(mp.prefix, action);
const pk = permissionKeyFor(module, action);
const disabled = !pk;
return (
<td style="padding:12px 16px;text-align:center">
<input
type="checkbox"
checked={selectedPermissions().has(pk)}
onChange={() => togglePermission(pk)}
checked={pk ? selectedPermissions().has(pk) : false}
disabled={disabled}
onChange={() => pk && togglePermission(pk)}
style="width:16px;height:16px;accent-color:#FF5E13;cursor:pointer"
/>
</td>
@ -649,20 +821,20 @@ export default function RoleManagementPage() {
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase">Module</th>
<For each={ACTIONS}>
{(action) => (
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase;text-align:center">{action.charAt(0) + action.slice(1).toLowerCase()}</th>
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase;text-align:center">{action}</th>
)}
</For>
</tr>
</thead>
<tbody>
<For each={MODULE_PERMISSIONS}>
{(mp) => (
<For each={orderedModules()}>
{(module) => (
<tr style="border-top:1px solid #E5E7EB">
<td style="padding:12px 16px;font-size:13px;font-weight:600;color:#111827">{mp.module}</td>
<td style="padding:12px 16px;font-size:13px;font-weight:600;color:#111827">{module}</td>
<For each={ACTIONS}>
{(action) => {
const pk = permKey(mp.prefix, action);
const has = () => viewingPermissions().includes(pk);
const pk = permissionKeyFor(module, action);
const has = () => isViewingSuperAdmin() || viewingPermissions().includes(pk);
return (
<td style="padding:12px 16px;text-align:center">
<input type="checkbox" checked={has()} disabled style={`width:16px;height:16px;accent-color:#FF5E13;cursor:default;opacity:${has() ? '1' : '0.4'}`} />

View file

@ -1,125 +1,13 @@
import { A } from '@solidjs/router';
import { createMemo, createResource, createSignal, For, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
async function fetchUsers(): Promise<any[]> {
try {
const res = await fetch(`${API}/api/admin/users?role=social_media_manager`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.users || []);
} catch {
return [];
}
}
import RoleUserManagementTablePage from '~/components/admin/RoleUserManagementTablePage';
export default function SocialMediaManagersPage() {
const [users] = createResource(fetchUsers);
const [search, setSearch] = createSignal('');
const [statusFilter, setStatusFilter] = createSignal('');
const filtered = createMemo(() => {
const list = users() ?? [];
const q = search().toLowerCase();
const s = statusFilter();
return list.filter((u) => {
const matchSearch =
!q ||
(u.name || u.full_name || '').toLowerCase().includes(q) ||
(u.email || '').toLowerCase().includes(q);
const matchStatus = !s || (u.status || '').toUpperCase() === s;
return matchSearch && matchStatus;
});
});
return (
<AdminShell>
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
<div class="bg-white border-b border-gray-200 px-6 py-4">
<h1 class="text-xl font-semibold text-gray-900">Social Media Manager Management</h1>
<p class="text-sm text-gray-500 mt-0.5">Manage all social media manager accounts on the platform.</p>
</div>
<div class="flex-1 p-6">
<div class="table-card">
<div style="display:flex;gap:12px;padding:16px;border-bottom:1px solid #e2e8f0;flex-wrap:wrap;">
<input
type="text"
placeholder="Search by name or email..."
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
style="border:1px solid #cbd5e1;border-radius:6px;padding:8px 12px;font-size:14px;width:260px;"
/>
<select
value={statusFilter()}
onChange={(e) => setStatusFilter(e.currentTarget.value)}
style="border:1px solid #cbd5e1;border-radius:6px;padding:8px 12px;font-size:14px;"
>
<option value="">All Status</option>
<option value="ACTIVE">ACTIVE</option>
<option value="INACTIVE">INACTIVE</option>
<option value="PENDING">PENDING</option>
</select>
</div>
<div class="overflow-x-auto">
<table data-table class="w-full text-sm">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Registered</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={users.loading}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!users.loading && users.error}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length === 0}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#94a3b8">No social media manager users found.</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length > 0}>
<For each={filtered()}>
{(item) => (
<tr class="hover:bg-slate-50">
<td class="font-semibold text-slate-900">{item.name || item.full_name || '—'}</td>
<td class="text-slate-500">{item.email}</td>
<td>
{item.status?.toUpperCase() === 'ACTIVE' && (
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-700">ACTIVE</span>
)}
{item.status?.toUpperCase() === 'INACTIVE' && (
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">INACTIVE</span>
)}
{item.status?.toUpperCase() === 'PENDING' && (
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600" style="background:#fff7ed;color:#c2410c;border-color:#fed7aa;">PENDING</span>
)}
{!item.status && <span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600"></span>}
</td>
<td class="text-slate-500">
{item.created_at ? new Date(item.created_at).toLocaleDateString() : '—'}
</td>
<td>
<div class="flex items-center justify-end gap-1">
<A class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href={`/admin/users/${item.id}`}>View</A>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</div>
</div>
</div>
</AdminShell>
<RoleUserManagementTablePage
role="social_media_manager"
title="Social Media Manager Management"
subtitle="Manage all social media manager accounts on the platform."
emptyLabel="No social media manager users found."
viewHref={(id) => `/admin/users/${id}`}
/>
);
}

View file

@ -1,129 +1,13 @@
import { A } from '@solidjs/router';
import { createMemo, createResource, createSignal, For, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
async function fetchUsers(): Promise<any[]> {
try {
const res = await fetch(`${API}/api/admin/users?role=tutor`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.users || []);
} catch {
return [];
}
}
import RoleUserManagementTablePage from '~/components/admin/RoleUserManagementTablePage';
export default function TutorsPage() {
const [users] = createResource(fetchUsers);
const [search, setSearch] = createSignal('');
const [statusFilter, setStatusFilter] = createSignal('');
const filtered = createMemo(() => {
const list = users() ?? [];
const q = search().toLowerCase();
const s = statusFilter();
return list.filter((u) => {
const matchSearch =
!q ||
(u.name || u.full_name || '').toLowerCase().includes(q) ||
(u.email || '').toLowerCase().includes(q);
const matchStatus = !s || (u.status || '').toUpperCase() === s;
return matchSearch && matchStatus;
});
});
return (
<AdminShell>
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
{/* White page header */}
<div class="bg-white border-b border-gray-200 px-6 py-4">
<h1 class="text-xl font-semibold text-gray-900">Tutors Management</h1>
<p class="text-sm text-gray-500 mt-0.5">Manage all tutor accounts on the platform.</p>
</div>
{/* Content */}
<div class="flex-1 p-6">
{/* Search / Filters */}
<div class="mb-4 flex flex-wrap items-center gap-3">
<input
type="text"
placeholder="Search by name or email..."
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
class="rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] w-64"
/>
<select
value={statusFilter()}
onChange={(e) => setStatusFilter(e.currentTarget.value)}
class="rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37]"
>
<option value="">All Status</option>
<option value="ACTIVE">ACTIVE</option>
<option value="INACTIVE">INACTIVE</option>
<option value="PENDING">PENDING</option>
</select>
</div>
<div class="table-card">
<div class="overflow-x-auto">
<table class="data-table w-full text-sm">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Registered</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={users.loading}>
<tr><td colspan="5" class="text-center py-8 text-slate-500">Loading...</td></tr>
</Show>
<Show when={!users.loading && users.error}>
<tr><td colspan="5" class="text-center py-8 text-red-700">Failed to load. Is the backend running?</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length === 0}>
<tr><td colspan="5" class="text-center py-8 text-slate-400">No tutor users found.</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length > 0}>
<For each={filtered()}>
{(item) => (
<tr class="hover:bg-slate-50">
<td class="font-semibold text-slate-900">{item.name || item.full_name || '—'}</td>
<td class="text-slate-500">{item.email}</td>
<td>
{item.status?.toUpperCase() === 'ACTIVE' && (
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-700">ACTIVE</span>
)}
{item.status?.toUpperCase() === 'INACTIVE' && (
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">INACTIVE</span>
)}
{item.status?.toUpperCase() === 'PENDING' && (
<span class="inline-flex items-center rounded-full bg-orange-50 px-2.5 py-0.5 text-xs font-medium text-orange-700 border border-orange-200">PENDING</span>
)}
{!item.status && <span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600"></span>}
</td>
<td class="text-slate-500">
{item.created_at ? new Date(item.created_at).toLocaleDateString() : '—'}
</td>
<td>
<div class="flex items-center justify-end gap-1">
<A class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href={`/admin/users/${item.id}`}>View</A>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</div>
</div>
</div>
</AdminShell>
<RoleUserManagementTablePage
role="tutor"
title="Tutors Management"
subtitle="Manage all tutor accounts on the platform."
emptyLabel="No tutor users found."
viewHref={(id) => `/admin/users/${id}`}
/>
);
}

View file

@ -15,14 +15,6 @@ type VerificationRecord = CrudRecord & {
status: 'PENDING' | 'IN_REVIEW' | 'PARTIALLY_VERIFIED' | 'VERIFIED' | 'FLAGGED' | 'RE_UPLOAD_REQUESTED' | 'REJECTED';
};
const FALLBACK_VERIFICATIONS: VerificationRecord[] = [
{ id: 'v1', name: 'Identity Check - Arun Kumar', applicantName: 'Arun Kumar', userType: 'PROFESSIONAL', verificationType: 'IDENTITY', submittedDate: '2026-03-25', documentsCount: 2, assignedVerifier: 'Suresh Menon', priority: 'HIGH', status: 'PENDING', updatedAt: '2026-03-25' },
{ id: 'v2', name: 'Business Verification - Tech Solutions', applicantName: 'Tech Solutions', userType: 'COMPANY', verificationType: 'BUSINESS', submittedDate: '2026-03-24', documentsCount: 5, assignedVerifier: 'Rekha Nair', priority: 'MEDIUM', status: 'IN_REVIEW', updatedAt: '2026-03-26' },
{ id: 'v3', name: 'Profile Review - Priya Sharma', applicantName: 'Priya Sharma', userType: 'CUSTOMER', verificationType: 'PROFILE', submittedDate: '2026-03-26', documentsCount: 1, assignedVerifier: 'Unassigned', priority: 'LOW', status: 'PENDING', updatedAt: '2026-03-26' },
{ id: 'v4', name: 'Mixed Verification - Deepak Verma', applicantName: 'Deepak Verma', userType: 'JOBSEEKER', verificationType: 'MIXED', submittedDate: '2026-03-23', documentsCount: 4, assignedVerifier: 'Anita Pillai', priority: 'HIGH', status: 'RE_UPLOAD_REQUESTED', updatedAt: '2026-03-25' },
{ id: 'v5', name: 'Document Audit - Manoj Iyer', applicantName: 'Manoj Iyer', userType: 'PROFESSIONAL', verificationType: 'DOCUMENT', submittedDate: '2026-03-22', documentsCount: 3, assignedVerifier: 'Arun Kumar', priority: 'MEDIUM', status: 'VERIFIED', updatedAt: '2026-03-24' },
];
function StatusBadge(props: { status: string }) {
const getColors = () => {
switch (props.status) {
@ -76,6 +68,7 @@ export default function VerificationManagementPage() {
const [ruleVerificationType, setRuleVerificationType] = createSignal<VerificationRecord['verificationType']>('IDENTITY');
const [ruleActive, setRuleActive] = createSignal(true);
const [formError, setFormError] = createSignal('');
const [error, setError] = createSignal('');
type VerificationRule = {
id: string;
@ -93,7 +86,56 @@ export default function VerificationManagementPage() {
]);
const load = async () => {
setRows(FALLBACK_VERIFICATIONS);
setError('');
try {
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
const res = await fetch(`${API}/api/admin/approvals?page=1&limit=100`, {
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
});
if (!res.ok) throw new Error(`Request failed (${res.status})`);
const payload = await res.json().catch(() => ({} as any));
const jobs = Array.isArray(payload?.jobs) ? payload.jobs : [];
const requirements = Array.isArray(payload?.requirements) ? payload.requirements : [];
const jobCases: VerificationRecord[] = jobs.map((job: any) => ({
id: `job-${String(job.id)}`,
name: `Job Verification - ${String(job.title || 'Untitled Job')}`,
applicantName: String(job.title || 'Untitled Job'),
userType: 'COMPANY',
verificationType: 'BUSINESS',
submittedDate: String(job.created_at || ''),
documentsCount: 1,
assignedVerifier: 'Unassigned',
priority: 'HIGH',
status: 'IN_REVIEW',
updatedAt: String(job.updated_at || job.created_at || ''),
}));
const requirementCases: VerificationRecord[] = requirements.map((req: any) => ({
id: `requirement-${String(req.id)}`,
name: `Requirement Verification - ${String(req.title || 'Untitled Requirement')}`,
applicantName: String(req.title || 'Untitled Requirement'),
userType: 'CUSTOMER',
verificationType: 'PROFILE',
submittedDate: String(req.created_at || ''),
documentsCount: 1,
assignedVerifier: 'Unassigned',
priority: 'MEDIUM',
status: 'PENDING',
updatedAt: String(req.updated_at || req.created_at || ''),
}));
setRows([...jobCases, ...requirementCases]);
} catch (e: any) {
setRows([]);
setError(e?.message || 'Could not reach verification API.');
}
};
onMount(() => void load());
@ -180,6 +222,11 @@ export default function VerificationManagementPage() {
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Verification Management</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">Manage user identity and business verification workflows</p>
</div>
<Show when={error()}>
<div style="margin-bottom:10px;border-radius:10px;border:1px solid #FECACA;background:#FEF2F2;padding:12px 16px;font-size:13px;color:#B91C1C">
{error()}
</div>
</Show>
{/* ── LIST VIEW ── */}
<Show when={view() === 'list'}>

View file

@ -1,125 +1,13 @@
import { A } from '@solidjs/router';
import { createMemo, createResource, createSignal, For, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
async function fetchUsers(): Promise<any[]> {
try {
const res = await fetch(`${API}/api/admin/users?role=video_editor`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.users || []);
} catch {
return [];
}
}
import RoleUserManagementTablePage from '~/components/admin/RoleUserManagementTablePage';
export default function VideoEditorsPage() {
const [users] = createResource(fetchUsers);
const [search, setSearch] = createSignal('');
const [statusFilter, setStatusFilter] = createSignal('');
const filtered = createMemo(() => {
const list = users() ?? [];
const q = search().toLowerCase();
const s = statusFilter();
return list.filter((u) => {
const matchSearch =
!q ||
(u.name || u.full_name || '').toLowerCase().includes(q) ||
(u.email || '').toLowerCase().includes(q);
const matchStatus = !s || (u.status || '').toUpperCase() === s;
return matchSearch && matchStatus;
});
});
return (
<AdminShell>
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
<div class="bg-white border-b border-gray-200 px-6 py-4">
<h1 class="text-xl font-semibold text-gray-900">Video Editor Management</h1>
<p class="text-sm text-gray-500 mt-0.5">Manage all video editor accounts on the platform.</p>
</div>
<div class="flex-1 p-6">
<div class="table-card">
<div style="display:flex;gap:12px;padding:16px;border-bottom:1px solid #e2e8f0;flex-wrap:wrap;">
<input
type="text"
placeholder="Search by name or email..."
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
style="border:1px solid #cbd5e1;border-radius:6px;padding:8px 12px;font-size:14px;width:260px;"
/>
<select
value={statusFilter()}
onChange={(e) => setStatusFilter(e.currentTarget.value)}
style="border:1px solid #cbd5e1;border-radius:6px;padding:8px 12px;font-size:14px;"
>
<option value="">All Status</option>
<option value="ACTIVE">ACTIVE</option>
<option value="INACTIVE">INACTIVE</option>
<option value="PENDING">PENDING</option>
</select>
</div>
<div class="overflow-x-auto">
<table data-table class="w-full text-sm">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Registered</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={users.loading}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!users.loading && users.error}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length === 0}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#94a3b8">No video editor users found.</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length > 0}>
<For each={filtered()}>
{(item) => (
<tr class="hover:bg-slate-50">
<td class="font-semibold text-slate-900">{item.name || item.full_name || '—'}</td>
<td class="text-slate-500">{item.email}</td>
<td>
{item.status?.toUpperCase() === 'ACTIVE' && (
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-700">ACTIVE</span>
)}
{item.status?.toUpperCase() === 'INACTIVE' && (
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">INACTIVE</span>
)}
{item.status?.toUpperCase() === 'PENDING' && (
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600" style="background:#fff7ed;color:#c2410c;border-color:#fed7aa;">PENDING</span>
)}
{!item.status && <span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600"></span>}
</td>
<td class="text-slate-500">
{item.created_at ? new Date(item.created_at).toLocaleDateString() : '—'}
</td>
<td>
<div class="flex items-center justify-end gap-1">
<A class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href={`/admin/users/${item.id}`}>View</A>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</div>
</div>
</div>
</AdminShell>
<RoleUserManagementTablePage
role="video_editor"
title="Video Editor Management"
subtitle="Manage all video editor accounts on the platform."
emptyLabel="No video editor users found."
viewHref={(id) => `/admin/users/${id}`}
/>
);
}

View file

@ -23,10 +23,10 @@ function pickMaskedEmail(payload: any, fallback: string): string {
/* Matches public website input exactly */
const inputCls =
'h-11 w-full rounded-xl border border-[#cfd4e3] bg-white px-4 text-sm text-[#101228] outline-none transition placeholder:text-[#9ba3bc] focus:border-[#fd6216] focus:ring-2 focus:ring-[#ffd8c3]';
'h-11 w-full rounded-xl border border-[#E5E7EB] bg-white px-4 text-sm text-[#111827] outline-none transition placeholder:text-[#9CA3AF] focus:border-[#FF5E13] focus:ring-2 focus:ring-[#FFE6D9]';
const labelCls =
'mb-2 block text-xs font-semibold uppercase tracking-[0.11em] text-[#4b546f]';
'mb-2 block text-[12px] font-semibold uppercase tracking-[0.11em] text-[#4B5563]';
export default function LoginPage() {
const navigate = useNavigate();
@ -154,68 +154,69 @@ export default function LoginPage() {
};
return (
<main
class="relative min-h-screen overflow-x-clip"
style="background: linear-gradient(135deg, #0a1d37 0%, #0d2347 40%, #0a1d37 70%, #081628 100%)"
>
{/* Radial glow accents matching public site MarketingBackground feel */}
<div class="pointer-events-none absolute inset-0 overflow-hidden">
<div class="absolute -top-40 -left-40 h-[500px] w-[500px] rounded-full bg-[#fd6216]/8 blur-[100px]" />
<div class="absolute top-1/2 right-0 h-[400px] w-[400px] -translate-y-1/2 rounded-full bg-[#fd6216]/5 blur-[80px]" />
<div class="absolute bottom-0 left-1/3 h-[300px] w-[300px] rounded-full bg-white/3 blur-[60px]" />
</div>
<main class="min-h-screen bg-[#F9FAFB] text-[#111827]">
<div class="mx-auto flex min-h-screen w-full max-w-[1260px] items-center px-4 py-8 sm:px-6 lg:py-10">
<div class="grid w-full gap-6 lg:grid-cols-[1.05fr_0.95fr]">
<section class="relative hidden overflow-hidden rounded-3xl border border-[#E5E7EB] bg-white p-10 shadow-sm lg:flex lg:min-h-[640px] lg:flex-col lg:justify-between">
<div class="pointer-events-none absolute -right-20 -top-20 h-72 w-72 rounded-full bg-[#FF5E13]/10 blur-3xl" />
<div class="pointer-events-none absolute bottom-[-80px] left-[-80px] h-72 w-72 rounded-full bg-[#0D0D2A]/10 blur-3xl" />
<div class="relative z-10 mx-auto grid min-h-screen w-full max-w-[1260px] items-center gap-6 px-4 py-8 sm:px-6 lg:grid-cols-[1.02fr_0.98fr] lg:py-10">
<div>
<img src="/nxtgauge-logo.png" alt="NXTGAUGE" class="h-auto w-[190px] object-contain" />
</div>
{/* ── Left brand panel (hidden on mobile) ── */}
<section class="relative hidden min-h-[620px] overflow-hidden rounded-[28px] border border-white/15 bg-white/5 p-10 text-white backdrop-blur-sm lg:flex lg:flex-col lg:justify-between">
{/* Inner glow */}
<div class="pointer-events-none absolute -top-24 -right-24 h-64 w-64 rounded-full bg-[#fd6216]/12 blur-3xl" />
<div class="space-y-6">
<p class="inline-flex items-center gap-2 rounded-full border border-[#FFE4D5] bg-[#FFF3EC] px-3 py-1 text-[11px] font-bold uppercase tracking-widest text-[#FF5E13]">
<span class="h-1.5 w-1.5 rounded-full bg-[#FF5E13]" />
Internal Admin Portal
</p>
<h1 class="text-[40px] font-extrabold leading-tight text-[#0D0D2A]">
Dashboard Access
<br />
For Operations Team
</h1>
<p class="max-w-[520px] text-[15px] leading-relaxed text-[#6B7280]">
Sign in to manage roles, approvals, user operations, and runtime module configuration from one control center.
</p>
{/* Logo */}
<img src="/nxtgauge-logo.png" alt="NXTGAUGE" class="h-10 w-auto brightness-0 invert" />
<div class="grid grid-cols-2 gap-3">
<div class="rounded-xl border border-[#E5E7EB] bg-[#FAFAFA] px-4 py-3">
<p class="text-[12px] font-semibold text-[#6B7280]">Access Control</p>
<p class="mt-1 text-[14px] font-semibold text-[#0D0D2A]">Role Management</p>
</div>
<div class="rounded-xl border border-[#E5E7EB] bg-[#FAFAFA] px-4 py-3">
<p class="text-[12px] font-semibold text-[#6B7280]">Workflows</p>
<p class="mt-1 text-[14px] font-semibold text-[#0D0D2A]">Approvals & Verification</p>
</div>
<div class="rounded-xl border border-[#E5E7EB] bg-[#FAFAFA] px-4 py-3">
<p class="text-[12px] font-semibold text-[#6B7280]">Operations</p>
<p class="mt-1 text-[14px] font-semibold text-[#0D0D2A]">Users & Companies</p>
</div>
<div class="rounded-xl border border-[#E5E7EB] bg-[#FAFAFA] px-4 py-3">
<p class="text-[12px] font-semibold text-[#6B7280]">Runtime</p>
<p class="mt-1 text-[14px] font-semibold text-[#0D0D2A]">Module Visibility</p>
</div>
</div>
</div>
{/* Main copy */}
<div class="space-y-5">
<p class="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/10 px-3 py-1 text-[11px] font-bold uppercase tracking-widest text-orange-300">
<span class="h-1.5 w-1.5 rounded-full bg-orange-400" />
Internal Admin Portal
<p class="rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] px-4 py-3 text-[13px] text-[#6B7280]">
Secured with internal access policies. Authorized personnel only.
</p>
<h1 class="text-[40px] font-extrabold leading-tight text-white xl:text-[46px]">
Welcome back<br />to Nxtgauge.
</h1>
<p class="text-[15px] leading-relaxed text-white/60">
Sign in to manage operations, roles, and approval workflows from one secure control panel.
</p>
<ul class="space-y-3 text-sm text-white/70">
{(['Role & permission management', 'Approval workflow control', 'User & company oversight', 'Dashboard configuration'] as const).map(item => (
<li class="flex items-center gap-3">
<span class="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-[#fd6216]/20 text-[#fd6216]">
<svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4l2.5 2.5L9 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
{item}
</li>
))}
</ul>
</div>
</section>
{/* Bottom tag */}
<p class="rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-[13px] text-white/50">
🔒 Secured with internal access policies. Authorised personnel only.
</p>
</section>
{/* ── Right form card — matches public website card exactly ── */}
<section class="rounded-[28px] border border-white/30 bg-white/92 p-5 text-[#101228] shadow-[0_28px_60px_-34px_rgba(2,6,23,0.88)] backdrop-blur-xl sm:p-6">
{/* Logo (always visible) */}
<img src="/nxtgauge-logo.png" alt="NXTGAUGE" class="h-8 w-auto" />
<section class="rounded-3xl border border-[#E5E7EB] bg-white p-6 shadow-sm sm:p-7">
<div class="mb-5 flex items-center justify-between">
<img src="/nxtgauge-logo.png" alt="NXTGAUGE" class="h-auto w-[170px] object-contain" />
<span class="rounded-full bg-[#FFF1EB] px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-[#FF5E13]">
Admin
</span>
</div>
<div class="mt-4">
<h2 class="text-3xl font-extrabold text-[#101228]">
<h2 class="text-[30px] font-extrabold text-[#0D0D2A]">
{mode() === 'login' ? 'Sign In' : 'Reset Password'}
</h2>
<p class="mt-1.5 text-sm text-[#535e7a]">
<p class="mt-1.5 text-sm text-[#6B7280]">
{mode() === 'login' ? 'Internal team access only.' : 'Use your internal email to reset access.'}
</p>
</div>
@ -302,7 +303,7 @@ export default function LoginPage() {
{/* Error / Info */}
<Show when={error()}>
<p class="text-sm font-medium text-red-600">{error()}</p>
<p class="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm font-medium text-red-600">{error()}</p>
</Show>
<Show when={info()}>
<p class="rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{info()}</p>
@ -312,13 +313,13 @@ export default function LoginPage() {
<Show when={mode() === 'login'}>
<button
type="button"
class="h-11 w-full rounded-xl bg-[#0a1d37] text-sm font-semibold text-white transition hover:bg-[#0f2a4e] disabled:cursor-not-allowed disabled:opacity-60"
class="h-11 w-full rounded-xl bg-[#0D0D2A] text-sm font-semibold text-white transition hover:bg-[#1A1A3A] disabled:cursor-not-allowed disabled:opacity-60"
disabled={isSubmitting()}
onClick={directSignIn}
>
{isSubmitting() ? 'Signing in…' : 'Sign In'}
</button>
<p class="text-xs text-[#6a7390]">Secure login with internal access policies.</p>
<p class="text-xs text-[#6B7280]">Secure login with internal access policies.</p>
</Show>
{/* Reset buttons */}
@ -326,7 +327,7 @@ export default function LoginPage() {
<Show when={resetStep() === 'request'} fallback={
<button
type="button"
class="h-11 w-full rounded-xl bg-[#0a1d37] text-sm font-semibold text-white transition hover:bg-[#0f2a4e] disabled:cursor-not-allowed disabled:opacity-60"
class="h-11 w-full rounded-xl bg-[#0D0D2A] text-sm font-semibold text-white transition hover:bg-[#1A1A3A] disabled:cursor-not-allowed disabled:opacity-60"
disabled={isSubmitting() || !canSubmitResetVerify()}
onClick={verifyResetCode}
>
@ -335,7 +336,7 @@ export default function LoginPage() {
}>
<button
type="button"
class="h-11 w-full rounded-xl bg-[#0a1d37] text-sm font-semibold text-white transition hover:bg-[#0f2a4e] disabled:cursor-not-allowed disabled:opacity-60"
class="h-11 w-full rounded-xl bg-[#0D0D2A] text-sm font-semibold text-white transition hover:bg-[#1A1A3A] disabled:cursor-not-allowed disabled:opacity-60"
disabled={isSubmitting() || !canSubmitResetRequest()}
onClick={requestResetCode}
>
@ -349,7 +350,7 @@ export default function LoginPage() {
</div>
</section>
</div>
</div>
</main>
);