Standardize admin tables and polish shell UX
This commit is contained in:
parent
d7bf5a6d56
commit
8ed523d80c
44 changed files with 5671 additions and 2346 deletions
|
|
@ -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
24
scripts/admin-3000-daemon.sh
Executable 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
95
scripts/admin-3000-service.sh
Executable 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
75
scripts/restart-admin-3000.sh
Executable 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
|
||||
125
src/app.css
125
src/app.css
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
2042
src/components/admin/DashboardDesignPreview.tsx
Normal file
2042
src/components/admin/DashboardDesignPreview.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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">
|
||||
|
|
|
|||
223
src/components/admin/RoleUserManagementTablePage.tsx
Normal file
223
src/components/admin/RoleUserManagementTablePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/lib/admin/module-access.test.ts
Normal file
30
src/lib/admin/module-access.test.ts
Normal 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']);
|
||||
});
|
||||
56
src/lib/admin/module-access.ts
Normal file
56
src/lib/admin/module-access.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => ({}));
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'}`} />
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'}>
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue