feat(admin): build complete admin panel with UI parity and search/filter

- Implement all admin management pages (employees, users, jobs, leads, orders, companies, customers, candidates, approval, invoices, reviews, support, KB, pricing, coupons, credits, discounts, tax, reports, ledger)
- Implement 9 professional vertical pages (developers, designers, tutors, video editors, photographers, makeup artists, graphic designers, social media managers, fitness trainers)
- Implement internal/external dashboard and role management with builder UI
- Fix tab styling: replace inline border-bottom styles with admin-tab CSS class across 8+ pages
- Add search/filter functionality to invoice and review pages
- Add toggle status (activate/deactivate) to employees page with PATCH /api/admin/employees/{id}
- Align UI styling with NextJS admin panel for visual parity
- Add stat cards to approval page showing counts by status
- Implement graceful empty states for all list views

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Ashwin Kumar 2026-03-19 13:04:10 +01:00
parent bbaeb1b2a9
commit 04a1079f68
50 changed files with 10823 additions and 434 deletions

11
.claude/launch.json Normal file
View file

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "admin-solid",
"runtimeExecutable": "sh",
"runtimeArgs": ["-c", "cd /Users/ashwin/workspace/nxtgauge-admin-solid && node .output/server/index.mjs"],
"port": 3000
}
]
}

View file

@ -243,7 +243,13 @@ body {
.shell {
display: grid;
grid-template-columns: 264px 1fr;
min-height: calc(100vh - 64px);
height: calc(100vh - 64px);
overflow: hidden;
transition: grid-template-columns 300ms ease;
}
.shell.sidebar-collapsed {
grid-template-columns: 72px 1fr;
}
.sidebar {
@ -252,7 +258,8 @@ body {
padding: 20px 12px 12px;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
overflow: hidden;
}
.sidebar-toggle-row {
@ -278,6 +285,43 @@ body {
color: #334155;
}
.sidebar-chevron {
display: inline-block;
transition: transform 300ms ease;
}
.sidebar-chevron.collapsed {
transform: rotate(180deg);
}
/* Collapsed sidebar */
.sidebar.sidebar-collapsed {
padding: 20px 6px 12px;
align-items: center;
}
.sidebar.sidebar-collapsed .sidebar-toggle-row {
padding: 0;
justify-content: center;
}
.sidebar.sidebar-collapsed .nav-item {
justify-content: center;
padding: 10px;
gap: 0;
}
.collapsed-dot {
position: absolute;
right: -2px;
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--brand-orange);
}
.sidebar-nav {
flex: 1;
min-height: 0;
@ -293,13 +337,15 @@ body {
text-decoration: none;
color: #475569;
border: 1px solid transparent;
border-radius: 12px;
padding: 11px 12px;
margin-bottom: 6px;
font-size: 15px;
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 2px;
font-size: 13.5px;
line-height: 1.35;
font-weight: 500;
transition: all 180ms ease;
transition: background 180ms ease, border-color 180ms ease, color 180ms ease, box-shadow 180ms ease;
/* Prevent layout shift: active state changes font-weight which could reflow text */
min-height: 40px;
}
.nav-dot {
@ -337,6 +383,17 @@ body {
.nav-title {
flex: 1;
/* Reserve space for bold state so text reflow doesn't shift layout */
display: grid;
}
.nav-title::after {
content: attr(data-text);
height: 0;
overflow: hidden;
visibility: hidden;
font-weight: 700;
pointer-events: none;
user-select: none;
}
.active-badge {
@ -353,18 +410,20 @@ body {
.main {
min-width: 0;
padding: 14px 16px 16px;
overflow-y: auto;
height: 100%;
}
.main-inner {
max-width: 1180px;
max-width: 1200px;
padding: 20px 24px 32px;
}
.admin-tab-wrap {
border-bottom: 1px solid #e2e8f0;
background: #fff;
margin: -2px -16px 16px;
padding: 0 16px;
margin: -20px -24px 20px;
padding: 0 24px;
}
.admin-tabs {
@ -375,12 +434,16 @@ body {
.admin-tab {
text-decoration: none;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
padding: 12px 0;
font-size: 14px;
font-weight: 600;
color: #475569;
margin-bottom: -1px;
cursor: pointer;
font-family: 'Exo 2', sans-serif;
transition: color 140ms ease, border-color 140ms ease;
}
@ -397,13 +460,14 @@ body {
/* ---- Shared Content ---- */
.page-title {
margin: 0;
font-size: 30px;
font-size: 22px;
font-weight: 800;
color: #0f172a;
}
.page-subtitle {
margin-top: 8px;
margin: 4px 0 0;
font-size: 13px;
color: #64748b;
}
@ -670,3 +734,692 @@ body {
height: 36px;
}
}
/* ---- Builder Components ---- */
.builder-header {
background: #fff1e8;
border: 1px solid #ffc9ac;
border-radius: 16px;
padding: 16px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.builder-header h2 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #111827;
}
.builder-header p {
margin: 4px 0 0;
font-size: 13px;
color: #475569;
}
.builder-header-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.builder-tab-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 8px;
margin-bottom: 16px;
}
.builder-tab-btn {
border: 1px solid transparent;
background: transparent;
border-radius: 8px;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
color: #475569;
cursor: pointer;
transition: all 150ms ease;
font-family: 'Exo 2', sans-serif;
}
.builder-tab-btn:hover {
background: #f8fafc;
}
.builder-tab-btn.active {
border-color: #ffc9ac;
background: #fff1e8;
color: #c2410c;
}
.builder-section {
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 16px;
margin-bottom: 12px;
}
.builder-section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.builder-section-header input {
flex: 1;
border: 1px solid #cbd5e1;
border-radius: 12px;
padding: 8px 12px;
font-size: 14px;
font-weight: 600;
outline: none;
font-family: 'Exo 2', sans-serif;
}
.builder-section-header input:focus {
border-color: var(--brand-orange);
box-shadow: 0 0 0 3px rgba(253, 98, 22, 0.14);
}
.builder-item {
display: grid;
gap: 8px;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 12px;
margin-bottom: 8px;
background: #fff;
}
.builder-item input,
.builder-item textarea,
.builder-item select {
border: 1px solid #cbd5e1;
border-radius: 12px;
padding: 8px 12px;
font-size: 14px;
outline: none;
background: #fff;
font-family: 'Exo 2', sans-serif;
}
.builder-item input:focus,
.builder-item textarea:focus,
.builder-item select:focus {
border-color: var(--brand-orange);
box-shadow: 0 0 0 3px rgba(253, 98, 22, 0.14);
}
.builder-item-row-4 {
grid-template-columns: 1fr 90px 90px 110px;
}
.btn.danger {
border-color: #fca5a5;
background: #fff;
color: #b91c1c;
}
.btn.danger:hover {
border-color: #ef4444;
background: #fef2f2;
}
.btn.orange {
border-color: #ffc9ac;
background: #fff1e8;
color: #c2410c;
font-size: 12px;
padding: 6px 12px;
}
.btn.orange:hover {
background: #ffe2d2;
}
.btn.navy {
border-color: #050026;
background: #050026;
color: #fff;
}
.btn.navy:hover {
background: #0a0040;
}
.field-grid-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.sub-card {
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
padding: 16px;
margin-top: 12px;
}
.sub-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.sub-card-header h4 {
margin: 0;
font-size: 14px;
font-weight: 700;
color: #111827;
}
.nested-card {
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
padding: 16px;
margin-bottom: 8px;
}
.nested-card-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.nested-card-header input {
flex: 1;
border: 1px solid #cbd5e1;
border-radius: 12px;
padding: 8px 12px;
font-size: 14px;
font-weight: 600;
outline: none;
font-family: 'Exo 2', sans-serif;
}
.nested-card-header input:focus {
border-color: var(--brand-orange);
box-shadow: 0 0 0 3px rgba(253, 98, 22, 0.14);
}
.widget-item {
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
padding: 12px;
margin-bottom: 8px;
}
.widget-item input,
.widget-item textarea {
width: 100%;
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 8px 12px;
font-size: 13px;
outline: none;
margin-bottom: 6px;
font-family: 'Exo 2', sans-serif;
}
.widget-item input:focus,
.widget-item textarea:focus {
border-color: var(--brand-orange);
box-shadow: 0 0 0 3px rgba(253, 98, 22, 0.14);
}
/* Permission table for internal role management */
.perm-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 13px;
overflow: hidden;
border-radius: 12px;
border: 1px solid #e2e8f0;
}
.perm-table thead th {
background: #0B0720;
color: #fff;
padding: 12px 16px;
text-align: left;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.perm-table thead th:not(:first-child) {
text-align: center;
}
.perm-table tbody td {
border-bottom: 1px solid #e2e8f0;
padding: 12px 16px;
color: #334155;
background: #fff;
}
.perm-table tbody td:not(:first-child) {
text-align: center;
}
.perm-table tbody tr:hover td {
background: #fff7ed;
}
.module-picker {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px;
margin-top: 12px;
}
.module-chip {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
background: #fff;
transition: all 150ms ease;
font-family: 'Exo 2', sans-serif;
text-align: left;
}
.module-chip:hover {
border-color: #ffc9ac;
background: #fff1e8;
}
.module-chip.selected {
border-color: #ffc9ac;
background: #fff1e8;
color: #c2410c;
font-weight: 600;
}
/* Builder preview */
.preview-shell {
border: 1px solid #e2e8f0;
border-radius: 16px;
overflow: hidden;
background: #f1f5f9;
}
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e2e8f0;
background: #fff;
padding: 16px 20px;
}
.preview-layout {
display: grid;
grid-template-columns: 260px 1fr;
min-height: 500px;
}
.preview-sidebar {
border-right: 1px solid #e2e8f0;
background: #fcfcfd;
padding: 12px;
}
.preview-sidebar-item {
display: flex;
align-items: center;
gap: 10px;
border-radius: 12px;
padding: 10px 12px;
margin-bottom: 4px;
cursor: pointer;
font-size: 14px;
color: #475569;
text-align: left;
border: 1px solid transparent;
background: transparent;
width: 100%;
transition: all 150ms ease;
font-family: 'Exo 2', sans-serif;
}
.preview-sidebar-item:hover {
background: #fff;
color: #0f172a;
}
.preview-sidebar-item.active {
background: #fff1e8;
color: #111827;
font-weight: 600;
box-shadow: inset 3px 0 0 0 #fd6216;
}
.preview-content {
padding: 24px;
overflow-y: auto;
}
.preview-section {
border: 1px solid #e2e8f0;
border-radius: 16px;
background: #fff;
padding: 20px;
margin-bottom: 16px;
}
.preview-tabs {
display: flex;
gap: 8px;
border-bottom: 1px solid #e2e8f0;
padding-bottom: 12px;
margin-top: 16px;
flex-wrap: wrap;
}
.preview-tab-btn {
border-radius: 8px;
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
background: transparent;
color: #475569;
font-family: 'Exo 2', sans-serif;
}
.preview-tab-btn.active {
border-color: #ffc9ac;
background: #fff1e8;
color: #c2410c;
}
.preview-fields-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-top: 20px;
}
.preview-field label {
display: block;
font-size: 13px;
font-weight: 600;
color: #334155;
margin-bottom: 6px;
}
.preview-field input,
.preview-field select {
width: 100%;
border: 1px solid #cbd5e1;
border-radius: 12px;
padding: 10px 12px;
font-size: 13px;
background: #fff;
outline: none;
}
.preview-widget-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-top: 20px;
}
.preview-widget {
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
padding: 16px;
}
.preview-widget .w-label {
font-size: 12px;
color: #64748b;
}
.preview-widget .w-metric {
font-size: 22px;
font-weight: 700;
color: #050026;
margin-top: 8px;
}
.preview-widget .w-desc {
font-size: 11px;
color: #64748b;
margin-top: 4px;
}
/* Onboarding step builder */
.step-builder {
border: 1px solid #e2e8f0;
border-radius: 16px;
background: #fff;
padding: 16px;
margin-bottom: 12px;
}
.step-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.step-num {
background: #050026;
color: #fff;
border-radius: 999px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
}
.step-title-input {
flex: 1;
border: 1px solid #cbd5e1;
border-radius: 12px;
padding: 8px 12px;
font-size: 14px;
font-weight: 600;
outline: none;
font-family: 'Exo 2', sans-serif;
}
.step-title-input:focus {
border-color: var(--brand-orange);
box-shadow: 0 0 0 3px rgba(253, 98, 22, 0.14);
}
.field-type-select {
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 6px 10px;
font-size: 12px;
background: #fff;
outline: none;
font-family: 'Exo 2', sans-serif;
}
.field-row {
display: grid;
grid-template-columns: 1fr 130px 100px auto;
gap: 8px;
align-items: center;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 6px;
background: #fff;
}
.field-row input,
.field-row select {
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 6px 10px;
font-size: 13px;
outline: none;
font-family: 'Exo 2', sans-serif;
}
.field-row input:focus,
.field-row select:focus {
border-color: var(--brand-orange);
}
.error-box {
background: #fef2f2;
border: 1px solid #fca5a5;
border-radius: 12px;
padding: 12px 16px;
color: #b91c1c;
font-size: 13px;
margin-bottom: 12px;
}
.success-note {
margin-top: 8px;
font-size: 12px;
color: #047857;
font-weight: 700;
}
.info-box {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 12px 16px;
font-size: 13px;
color: #475569;
}
.role-detail-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 24px;
margin-bottom: 16px;
}
.role-field-readonly {
width: 100%;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px 12px;
font-size: 14px;
background: #f8fafc;
color: #64748b;
cursor: not-allowed;
}
.page-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 20px;
}
.page-actions-right {
display: flex;
gap: 8px;
}
.role-form-section {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 24px;
margin-bottom: 16px;
}
.role-form-section h3 {
margin: 0 0 4px;
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #64748b;
}
.role-form-section p {
margin: 0 0 16px;
font-size: 13px;
color: #64748b;
}
.external-role-form .field select {
width: 100%;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: 16px;
height: 16px;
}
.onboarding-info-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.onboarding-stat {
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
padding: 14px 16px;
}
.onboarding-stat .stat-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #64748b;
}
.onboarding-stat .stat-value {
font-size: 18px;
font-weight: 700;
color: #050026;
margin-top: 4px;
}

View file

@ -1,24 +1,72 @@
import { A, useLocation, useNavigate } from '@solidjs/router';
import { createSignal, onMount, type JSX } from 'solid-js';
import { createMemo, createSignal, onMount, type JSX } from 'solid-js';
import AdminSidebar from './AdminSidebar';
import { clearAdminSession, hasAdminSession } from '~/lib/admin-session';
import { sidebarCollapsed } from '~/lib/sidebar-state';
type Tab = { href: string; label: string; exact?: boolean };
const TAB_SETS: Array<{ prefixes: string[]; tabs: Tab[] }> = [
{
prefixes: ['/admin/roles'],
tabs: [
{ href: '/admin/roles', label: 'Internal Roles', exact: true },
{ href: '/admin/roles/create', label: 'Create Role' },
],
},
{
prefixes: ['/admin/runtime-roles'],
tabs: [
{ href: '/admin/runtime-roles', label: 'External Roles', exact: true },
{ href: '/admin/runtime-roles/new', label: 'Create External Role' },
],
},
{
prefixes: ['/admin/onboarding-schemas'],
tabs: [
{ href: '/admin/onboarding-schemas', label: 'Onboarding Flows', exact: true },
{ href: '/admin/onboarding-schemas/new', label: 'Create Flow' },
],
},
{
prefixes: ['/admin/internal-dashboard-management'],
tabs: [
{ href: '/admin/internal-dashboard-management', label: 'Internal Dashboards' },
],
},
{
prefixes: ['/admin/external-dashboard-management'],
tabs: [
{ href: '/admin/external-dashboard-management', label: 'External Dashboards' },
],
},
{
prefixes: ['/admin/role-ui-configs'],
tabs: [
{ href: '/admin/role-ui-configs', label: 'Config Inspector', exact: true },
{ href: '/admin/role-ui-configs/new', label: 'Create Config' },
],
},
];
export default function AdminShell(props: { children: JSX.Element }) {
const location = useLocation();
const navigate = useNavigate();
const [checkedSession, setCheckedSession] = createSignal(false);
const tabs = [
{ href: '/admin/runtime-roles', label: 'View Roles' },
{ href: '/admin/runtime-roles/new', label: 'Create Role' },
{ href: '/admin/role-ui-configs', label: 'Inspector' },
{ href: '/admin/onboarding-schemas', label: 'Onboarding' },
];
const isTabActive = (href: string) => {
if (href === '/admin/runtime-roles') {
return location.pathname === href || (location.pathname.startsWith('/admin/runtime-roles/') && location.pathname !== '/admin/runtime-roles/new');
const tabs = createMemo<Tab[]>(() => {
const path = location.pathname;
for (const set of TAB_SETS) {
if (set.prefixes.some((p) => path === p || path.startsWith(`${p}/`))) {
return set.tabs;
}
}
return location.pathname === href || location.pathname.startsWith(`${href}/`);
return [];
});
const isTabActive = (tab: Tab) => {
if (tab.exact) return location.pathname === tab.href;
return location.pathname === tab.href || location.pathname.startsWith(`${tab.href}/`);
};
onMount(() => {
@ -54,18 +102,20 @@ export default function AdminShell(props: { children: JSX.Element }) {
</header>
{checkedSession() ? (
<div class="shell">
<div class={`shell${sidebarCollapsed() ? ' sidebar-collapsed' : ''}`}>
<AdminSidebar />
<main class="main">
<div class="admin-tab-wrap">
<nav class="admin-tabs">
{tabs.map((tab) => (
<A href={tab.href} class={`admin-tab ${isTabActive(tab.href) ? 'active' : ''}`}>
{tab.label}
</A>
))}
</nav>
</div>
{tabs().length > 0 ? (
<div class="admin-tab-wrap">
<nav class="admin-tabs">
{tabs().map((tab) => (
<A href={tab.href} class={`admin-tab ${isTabActive(tab) ? 'active' : ''}`}>
{tab.label}
</A>
))}
</nav>
</div>
) : null}
<div class="main-inner">{props.children}</div>
</main>
</div>

View file

@ -1,4 +1,5 @@
import { A, useLocation } from '@solidjs/router';
import { sidebarCollapsed, toggleSidebar } from '~/lib/sidebar-state';
type LinkItem = { legacyHref: string; href: string; label: string; icon: string; aliasPrefix?: string };
@ -7,9 +8,9 @@ const links: LinkItem[] = [
{ legacyHref: '/department', href: '/admin/department', label: 'Department Management', icon: 'department.svg' },
{ legacyHref: '/designation', href: '/admin/designation', label: 'Designation Management', icon: 'designation.svg' },
{ legacyHref: '/employees', href: '/admin/employees', label: 'Internal User Management', icon: 'users.svg' },
{ legacyHref: '/roles?scope=internal', href: '/admin/roles?scope=internal', label: 'Internal Role Management', icon: 'role.svg' },
{ legacyHref: '/roles?scope=internal', href: '/admin/roles', label: 'Internal Role Management', icon: 'role.svg' },
{ legacyHref: '/runtime-roles', href: '/admin/runtime-roles', label: 'External Role Management', icon: 'role.svg' },
{ legacyHref: '/onboarding-management', href: '/admin/onboarding-management', label: 'Onboarding Management', icon: 'reviews.svg', aliasPrefix: '/admin/onboarding-schemas' },
{ legacyHref: '/onboarding-management', href: '/admin/onboarding-schemas', label: 'Onboarding Management', icon: 'reviews.svg' },
{ legacyHref: '/internal-dashboard-management', href: '/admin/internal-dashboard-management', label: 'Internal Dashboard Management', icon: 'dashboard.svg' },
{ legacyHref: '/external-dashboard-management', href: '/admin/external-dashboard-management', label: 'External Dashboard Management', icon: 'dashboard.svg', aliasPrefix: '/admin/role-ui-configs' },
{ legacyHref: '/approval', href: '/admin/approval', label: 'Approval Management', icon: 'approval.svg' },
@ -45,6 +46,8 @@ const links: LinkItem[] = [
export default function AdminSidebar() {
const location = useLocation();
const collapsed = sidebarCollapsed;
const isLinkActive = (href: string, aliasPrefix?: string) => {
const pathOnly = href.split('?')[0] || href;
if (pathOnly === '/admin') return location.pathname === '/admin';
@ -53,18 +56,42 @@ export default function AdminSidebar() {
};
return (
<aside class="sidebar">
<aside class={`sidebar${collapsed() ? ' sidebar-collapsed' : ''}`}>
<div class="sidebar-toggle-row">
<button type="button" class="sidebar-toggle-btn" aria-label="Collapse sidebar"></button>
<button
type="button"
class="sidebar-toggle-btn"
aria-label={collapsed() ? 'Expand sidebar' : 'Collapse sidebar'}
onClick={toggleSidebar}
>
<span class={`sidebar-chevron${collapsed() ? ' collapsed' : ''}`}></span>
</button>
</div>
<nav class="sidebar-nav">
{links.map((item) => (
<A href={item.href} class={`nav-item ${isLinkActive(item.href, item.aliasPrefix) ? 'active' : ''}`} data-legacy-href={item.legacyHref}>
<img class="nav-icon" src={`/sidebar-icons/${item.icon}`} alt="" />
<span class="nav-title">{item.label}</span>
{isLinkActive(item.href, item.aliasPrefix) ? <span class="active-badge">Active</span> : null}
</A>
))}
{links.map((item) => {
const active = isLinkActive(item.href, item.aliasPrefix);
return (
<A
href={item.href}
class={`nav-item ${active ? 'active' : ''}`}
activeClass=""
inactiveClass=""
data-legacy-href={item.legacyHref}
title={collapsed() ? item.label : undefined}
>
<img class="nav-icon" src={`/sidebar-icons/${item.icon}`} alt="" />
{!collapsed() && (
<span class="nav-title" data-text={item.label}>{item.label}</span>
)}
{!collapsed() && active && (
<span class="active-badge">Active</span>
)}
{collapsed() && active && (
<span class="collapsed-dot" />
)}
</A>
);
})}
</nav>
</aside>
);

9
src/lib/sidebar-state.ts Normal file
View file

@ -0,0 +1,9 @@
import { createSignal } from 'solid-js';
const [sidebarCollapsed, setSidebarCollapsed] = createSignal(false);
export function toggleSidebar() {
setSidebarCollapsed((v) => !v);
}
export { sidebarCollapsed };

View file

@ -0,0 +1,386 @@
import { createMemo, createResource, createSignal, For, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
interface Approval {
id: string;
requestType?: string;
type?: string;
requestStatus?: string;
status?: string;
priority?: number;
createdAt?: string;
created_at?: string;
requester?: { name?: string; email?: string };
requesterName?: string;
requesterEmail?: string;
requester_name?: string;
requester_email?: string;
}
interface ApprovalRule {
id: string;
entityType: string;
entity_type?: string;
approverType: string;
approver_type?: string;
priority?: number;
}
const ENTITY_TYPE_OPTIONS = ['JOB_POST', 'COMPANY', 'LEAD', 'INVOICE'];
const APPROVER_TYPE_OPTIONS = ['USER', 'ROLE'];
type StatusTab = 'PENDING' | 'APPROVED' | 'REJECTED' | 'CHANGES_REQUESTED' | 'CANCELLED' | 'rules';
const STATUS_TABS: { key: StatusTab; label: string }[] = [
{ key: 'PENDING', label: 'Pending' },
{ key: 'APPROVED', label: 'Approved' },
{ key: 'REJECTED', label: 'Rejected' },
{ key: 'CHANGES_REQUESTED', label: 'Changes Requested' },
{ key: 'CANCELLED', label: 'Cancelled' },
{ key: 'rules', label: 'Rules' },
];
async function fetchApprovals(): Promise<Approval[]> {
try {
const res = await fetch(`${API}/api/admin/approvals`);
if (!res.ok) throw new Error('Failed to load approvals');
const data = await res.json();
return Array.isArray(data) ? data : (data.approvals || []);
} catch {
return [];
}
}
async function fetchRules(): Promise<ApprovalRule[]> {
try {
const res = await fetch(`${API}/api/admin/approval-rules`);
if (!res.ok) throw new Error('Failed to load rules');
const data = await res.json();
return Array.isArray(data) ? data : (data.rules || []);
} catch {
return [];
}
}
function StatusBadge(props: { status: string }) {
const s = (props.status || '').toUpperCase();
if (s === 'APPROVED') return <span class="status-chip active">APPROVED</span>;
if (s === 'REJECTED') return <span class="status-chip" style="background:#ef4444;color:#fff;border-color:#ef4444">REJECTED</span>;
if (s === 'CHANGES_REQUESTED') return <span class="status-chip" style="background:#3b82f6;color:#fff;border-color:#3b82f6">CHANGES REQUESTED</span>;
if (s === 'CANCELLED') return <span class="status-chip" style="background:#94a3b8;color:#fff;border-color:#94a3b8">CANCELLED</span>;
return <span class="status-chip" style="background:#f59e0b;color:#fff;border-color:#f59e0b">{props.status || 'PENDING'}</span>;
}
export default function ApprovalPage() {
const [activeTab, setActiveTab] = createSignal<StatusTab>('PENDING');
const [approvals, { refetch: refetchApprovals }] = createResource(fetchApprovals);
const [acting, setActing] = createSignal('');
const [approvalError, setApprovalError] = createSignal('');
const [rules, { refetch: refetchRules }] = createResource(fetchRules);
const [showAddRule, setShowAddRule] = createSignal(false);
const [newEntityType, setNewEntityType] = createSignal('JOB_POST');
const [newApproverType, setNewApproverType] = createSignal('USER');
const [newPriority, setNewPriority] = createSignal(1);
const [ruleError, setRuleError] = createSignal('');
const [deletingRule, setDeletingRule] = createSignal('');
const [submittingRule, setSubmittingRule] = createSignal(false);
const tabApprovals = createMemo(() => {
const tab = activeTab();
if (tab === 'rules') return [];
const list = approvals() ?? [];
return list.filter((a) => {
const s = (a.requestStatus || a.status || 'PENDING').toUpperCase();
return s === tab;
});
});
// Count per status for badges
const countFor = (status: string) => {
const list = approvals() ?? [];
if (status === 'rules') return (rules() ?? []).length;
return list.filter((a) => (a.requestStatus || a.status || 'PENDING').toUpperCase() === status).length;
};
const handleAction = async (id: string, newStatus: 'APPROVED' | 'REJECTED' | 'CHANGES_REQUESTED' | 'CANCELLED') => {
try {
setActing(`${id}-${newStatus}`);
setApprovalError('');
const res = await fetch(`${API}/api/admin/approvals/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
});
if (!res.ok) throw new Error(`Failed to ${newStatus.toLowerCase()} approval`);
refetchApprovals();
} catch (err: any) {
setApprovalError(err.message || 'Action failed');
} finally {
setActing('');
}
};
const handleAddRule = async () => {
try {
setSubmittingRule(true);
setRuleError('');
const res = await fetch(`${API}/api/admin/approval-rules`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entityType: newEntityType(),
approverType: newApproverType(),
priority: newPriority(),
}),
});
if (!res.ok) throw new Error('Failed to create rule');
setShowAddRule(false);
setNewEntityType('JOB_POST');
setNewApproverType('USER');
setNewPriority(1);
refetchRules();
} catch (err: any) {
setRuleError(err.message || 'Failed to create rule');
} finally {
setSubmittingRule(false);
}
};
const handleDeleteRule = async (id: string) => {
if (!confirm('Delete this approval rule?')) return;
try {
setDeletingRule(id);
setRuleError('');
const res = await fetch(`${API}/api/admin/approval-rules/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete rule');
refetchRules();
} catch (err: any) {
setRuleError(err.message || 'Failed to delete rule');
} finally {
setDeletingRule('');
}
};
return (
<AdminShell>
<div class="page-actions">
<div>
<h1 class="page-title">Approval Management</h1>
<p class="page-subtitle">Review and manage approvals and approval rules.</p>
</div>
</div>
{/* Status tabs */}
<div style="display:flex;border-bottom:2px solid #e2e8f0;margin-bottom:20px;gap:0;overflow-x:auto;">
{STATUS_TABS.map((t) => {
const count = countFor(t.key);
return (
<button
type="button"
class={`admin-tab${activeTab() === t.key ? ' active' : ''}`}
onClick={() => setActiveTab(t.key)}
style="white-space:nowrap;display:flex;align-items:center;gap:6px"
>
{t.label}
{!approvals.loading && count > 0 && (
<span style={`display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;border-radius:999px;font-size:10px;font-weight:700;padding:0 5px;background:${activeTab() === t.key ? 'var(--brand-orange)' : '#e2e8f0'};color:${activeTab() === t.key ? '#fff' : '#64748b'}`}>
{count}
</span>
)}
</button>
);
})}
</div>
{/* Approvals content */}
<Show when={activeTab() !== 'rules'}>
<Show when={approvalError()}>
<div class="error-box" style="margin-bottom:12px">{approvalError()}</div>
</Show>
<section class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Requester</th>
<th>Request Type</th>
<th>Status</th>
<th>Priority</th>
<th>Submitted At</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={approvals.loading}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!approvals.loading && approvals.error}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#b91c1c">Failed to load approvals.</td></tr>
</Show>
<Show when={!approvals.loading && !approvals.error && tabApprovals().length === 0}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#94a3b8">No {activeTab().toLowerCase().replace('_', ' ')} approvals.</td></tr>
</Show>
<Show when={!approvals.loading && !approvals.error && tabApprovals().length > 0}>
<For each={tabApprovals()}>
{(item) => {
const requesterName = item.requester?.name || item.requesterName || item.requester_name || '—';
const requesterEmail = item.requester?.email || item.requesterEmail || item.requester_email || '';
const status = (item.requestStatus || item.status || 'PENDING').toUpperCase();
const requestType = item.requestType || item.type || '—';
const submittedAt = item.createdAt || item.created_at;
return (
<tr>
<td>
<div style="font-weight:600;color:#0f172a">{requesterName}</div>
<Show when={requesterEmail}>
<div style="font-size:12px;color:#64748b">{requesterEmail}</div>
</Show>
</td>
<td style="color:#475569">{requestType}</td>
<td><StatusBadge status={status} /></td>
<td style="color:#475569">{item.priority ?? '—'}</td>
<td style="color:#475569">{submittedAt ? new Date(submittedAt).toLocaleString() : '—'}</td>
<td>
<div class="table-actions">
<Show when={status !== 'APPROVED'}>
<button
class="btn navy"
style="font-size:12px;padding:5px 10px"
disabled={!!acting()}
onClick={() => handleAction(item.id, 'APPROVED')}
>
{acting() === `${item.id}-APPROVED` ? '...' : 'Approve'}
</button>
</Show>
<Show when={status === 'PENDING'}>
<button
class="btn"
style="font-size:12px;padding:5px 10px"
disabled={!!acting()}
onClick={() => handleAction(item.id, 'CHANGES_REQUESTED')}
>
{acting() === `${item.id}-CHANGES_REQUESTED` ? '...' : 'Request Changes'}
</button>
</Show>
<Show when={status !== 'REJECTED' && status !== 'CANCELLED'}>
<button
class="btn danger"
style="font-size:12px;padding:5px 10px"
disabled={!!acting()}
onClick={() => handleAction(item.id, 'REJECTED')}
>
{acting() === `${item.id}-REJECTED` ? '...' : 'Reject'}
</button>
</Show>
</div>
</td>
</tr>
);
}}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</Show>
{/* Rules tab */}
<Show when={activeTab() === 'rules'}>
<Show when={ruleError()}>
<div class="error-box" style="margin-bottom:12px">{ruleError()}</div>
</Show>
<div class="page-actions" style="margin-bottom:16px;">
<div />
<button class="btn navy" onClick={() => setShowAddRule((v) => !v)}>
{showAddRule() ? 'Cancel' : '+ Add Rule'}
</button>
</div>
<Show when={showAddRule()}>
<div class="card" style="margin-bottom:16px;">
<h3 style="font-size:15px;font-weight:600;color:#0f172a;margin:0 0 14px 0;">New Approval Rule</h3>
<div class="field-grid-2">
<div class="field">
<label>Entity Type</label>
<select value={newEntityType()} onChange={(e) => setNewEntityType(e.currentTarget.value)}>
<For each={ENTITY_TYPE_OPTIONS}>{(et) => <option value={et}>{et}</option>}</For>
</select>
</div>
<div class="field">
<label>Approver Type</label>
<select value={newApproverType()} onChange={(e) => setNewApproverType(e.currentTarget.value)}>
<For each={APPROVER_TYPE_OPTIONS}>{(at) => <option value={at}>{at}</option>}</For>
</select>
</div>
<div class="field">
<label>Priority</label>
<input type="number" min="1" value={newPriority()} onInput={(e) => setNewPriority(parseInt(e.currentTarget.value) || 1)} />
</div>
</div>
<div class="actions" style="margin-top:14px;">
<button class="btn navy" disabled={submittingRule()} onClick={handleAddRule}>
{submittingRule() ? 'Saving...' : 'Save Rule'}
</button>
<button class="btn" onClick={() => setShowAddRule(false)}>Cancel</button>
</div>
</div>
</Show>
<section class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Entity Type</th>
<th>Approver Type</th>
<th>Priority</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={rules.loading}>
<tr><td colspan="4" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!rules.loading && rules.error}>
<tr><td colspan="4" style="text-align:center;padding:32px;color:#b91c1c">Failed to load rules.</td></tr>
</Show>
<Show when={!rules.loading && !rules.error && (rules()?.length ?? 0) === 0}>
<tr><td colspan="4" style="text-align:center;padding:32px;color:#94a3b8">No approval rules found.</td></tr>
</Show>
<Show when={!rules.loading && !rules.error && (rules()?.length ?? 0) > 0}>
<For each={rules()!}>
{(rule) => (
<tr>
<td style="font-weight:600;color:#0f172a">{rule.entityType || rule.entity_type || '—'}</td>
<td style="color:#475569">{rule.approverType || rule.approver_type || '—'}</td>
<td style="color:#475569">{rule.priority ?? '—'}</td>
<td>
<div class="table-actions">
<button
class="btn danger"
disabled={deletingRule() === rule.id}
onClick={() => handleDeleteRule(rule.id)}
>
{deletingRule() === rule.id ? '...' : 'Delete'}
</button>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</Show>
</AdminShell>
);
}

View file

@ -0,0 +1,124 @@
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=job_seeker`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.users || []);
} catch {
return [];
}
}
export default function CandidatePage() {
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="page-actions">
<div>
<h1 class="page-title">Candidate Management</h1>
<p class="page-subtitle">Manage all job seeker accounts on the platform.</p>
</div>
</div>
<section class="card" style="padding: 0; overflow: hidden;">
<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="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Registered</th>
<th class="align-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 job seeker users found.</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length > 0}>
<For each={filtered()}>
{(item) => (
<tr>
<td style="font-weight:600;color:#0f172a">{item.name || item.full_name || '—'}</td>
<td style="color:#475569">{item.email}</td>
<td>
{item.status?.toUpperCase() === 'ACTIVE' && (
<span class="status-chip active">ACTIVE</span>
)}
{item.status?.toUpperCase() === 'INACTIVE' && (
<span class="status-chip">INACTIVE</span>
)}
{item.status?.toUpperCase() === 'PENDING' && (
<span class="status-chip" style="background:#fff7ed;color:#c2410c;border-color:#fed7aa;">PENDING</span>
)}
{!item.status && <span class="status-chip"></span>}
</td>
<td style="color:#475569">
{item.created_at ? new Date(item.created_at).toLocaleDateString() : '—'}
</td>
<td>
<div class="table-actions">
<A class="btn" href={`/admin/users/${item.id}`}>View</A>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</AdminShell>
);
}

View file

@ -0,0 +1,124 @@
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 [];
}
}
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="page-actions">
<div>
<h1 class="page-title">Catering Services Management</h1>
<p class="page-subtitle">Manage all catering services accounts on the platform.</p>
</div>
</div>
<section class="card" style="padding: 0; overflow: hidden;">
<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="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Registered</th>
<th class="align-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>
<td style="font-weight:600;color:#0f172a">{item.name || item.full_name || '—'}</td>
<td style="color:#475569">{item.email}</td>
<td>
{item.status?.toUpperCase() === 'ACTIVE' && (
<span class="status-chip active">ACTIVE</span>
)}
{item.status?.toUpperCase() === 'INACTIVE' && (
<span class="status-chip">INACTIVE</span>
)}
{item.status?.toUpperCase() === 'PENDING' && (
<span class="status-chip" style="background:#fff7ed;color:#c2410c;border-color:#fed7aa;">PENDING</span>
)}
{!item.status && <span class="status-chip"></span>}
</td>
<td style="color:#475569">
{item.created_at ? new Date(item.created_at).toLocaleDateString() : '—'}
</td>
<td>
<div class="table-actions">
<A class="btn" href={`/admin/users/${item.id}`}>View</A>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</AdminShell>
);
}

View file

@ -0,0 +1,156 @@
import { A } from '@solidjs/router';
import { createMemo, createResource, createSignal, For, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
interface Company {
id: string;
company_name?: string;
companyName?: string;
name?: string;
industry?: string;
city?: string;
email?: string;
status: 'ACTIVE' | 'INACTIVE' | 'PENDING' | 'SUSPENDED' | 'REJECTED';
created_at?: string;
createdAt?: string;
}
async function fetchCompanies(): Promise<Company[]> {
try {
const res = await fetch(`${API}/api/admin/companies`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.companies || []);
} catch {
return [];
}
}
function StatusBadge(props: { status: string }) {
if (props.status === 'ACTIVE') {
return <span class="status-chip active">ACTIVE</span>;
}
if (props.status === 'PENDING') {
return <span class="status-chip" style="background:#f59e0b;color:#fff">PENDING</span>;
}
return <span class="status-chip">{props.status}</span>;
}
export default function CompanyPage() {
const [companies, { refetch }] = createResource(fetchCompanies);
const [search, setSearch] = createSignal('');
const [deleting, setDeleting] = createSignal('');
const [actionError, setActionError] = createSignal('');
const filtered = createMemo(() => {
const list = companies() ?? [];
const q = search().toLowerCase();
if (!q) return list;
return list.filter((c) => {
const name = (c.company_name || c.companyName || c.name || '').toLowerCase();
const email = (c.email || '').toLowerCase();
return name.includes(q) || email.includes(q);
});
});
const handleDelete = async (id: string, name: string) => {
if (!confirm(`Delete ${name}?`)) return;
try {
setDeleting(id);
setActionError('');
const res = await fetch(`${API}/api/admin/companies/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete');
refetch();
} catch (err: any) {
setActionError(err.message || 'Failed to delete company');
} finally {
setDeleting('');
}
};
return (
<AdminShell>
<div class="page-actions">
<div>
<h1 class="page-title">Company Management</h1>
<p class="page-subtitle">Manage all company accounts on the platform.</p>
</div>
</div>
<Show when={actionError()}>
<div class="error-box">{actionError()}</div>
</Show>
{/* Search */}
<div class="card" style="margin-bottom:16px;display:flex;gap:12px;flex-wrap:wrap;align-items:center;">
<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:7px 12px;font-size:14px;width:260px;outline:none;"
/>
</div>
<section class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Company Name</th>
<th>Industry</th>
<th>City</th>
<th>Email</th>
<th>Status</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={companies.loading}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!companies.loading && companies.error}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
</Show>
<Show when={!companies.loading && !companies.error && filtered().length === 0}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#94a3b8">No companies found.</td></tr>
</Show>
<Show when={!companies.loading && !companies.error && filtered().length > 0}>
<For each={filtered()}>
{(item) => {
const displayName = item.company_name || item.companyName || item.name || '—';
return (
<tr>
<td style="font-weight:600;color:#0f172a">{displayName}</td>
<td style="color:#475569">{item.industry || '—'}</td>
<td style="color:#475569">{item.city || '—'}</td>
<td style="color:#475569">{item.email || '—'}</td>
<td>
<StatusBadge status={item.status} />
</td>
<td>
<div class="table-actions">
<A class="btn" href={`/admin/company/${item.id}`}>View</A>
<button
class="btn danger"
disabled={deleting() === item.id}
onClick={() => handleDelete(item.id, displayName)}
>
{deleting() === item.id ? '...' : 'Delete'}
</button>
</div>
</td>
</tr>
);
}}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</AdminShell>
);
}

335
src/routes/admin/coupon.tsx Normal file
View file

@ -0,0 +1,335 @@
import { createResource, createSignal, Show, For } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
const ROLE_OPTIONS = [
'company',
'customer',
'job_seeker',
'photographer',
'video_editor',
'graphic_designer',
'social_media_manager',
'fitness_trainer',
'catering_services',
'makeup_artist',
'tutor',
'developer',
];
type Coupon = {
id: string;
code: string;
title: string;
type: 'PERCENT' | 'FIXED';
value: number;
min_order_amount: number;
used_count: number;
usage_limit?: number;
is_active: boolean;
role_keys: string[];
};
async function loadCoupons(): Promise<Coupon[]> {
try {
const res = await fetch(`${API}/api/admin/coupons`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.coupons || []);
} catch {
return [];
}
}
const defaultForm = () => ({
id: '',
code: '',
title: '',
type: 'PERCENT' as 'PERCENT' | 'FIXED',
value: 10,
min_order_amount: 0,
max_uses: '',
role_keys: ['company', 'customer'] as string[],
});
export default function CouponPage() {
const [coupons, { refetch }] = createResource(loadCoupons);
const [activeTab, setActiveTab] = createSignal<'list' | 'create'>('list');
const [form, setForm] = createSignal(defaultForm());
const [saving, setSaving] = createSignal(false);
const [toggling, setToggling] = createSignal('');
const [formError, setFormError] = createSignal('');
const resetForm = () => {
setForm(defaultForm());
setFormError('');
};
const startEdit = (coupon: Coupon) => {
setForm({
id: coupon.id,
code: coupon.code,
title: coupon.title || '',
type: coupon.type,
value: coupon.value,
min_order_amount: coupon.min_order_amount || 0,
max_uses: coupon.usage_limit != null ? String(coupon.usage_limit) : '',
role_keys: Array.isArray(coupon.role_keys) ? coupon.role_keys : [],
});
setActiveTab('create');
};
const toggleRole = (role: string) => {
const current = form().role_keys;
if (current.includes(role)) {
setForm({ ...form(), role_keys: current.filter((r) => r !== role) });
} else {
setForm({ ...form(), role_keys: [...current, role] });
}
};
const handleSave = async (e: Event) => {
e.preventDefault();
try {
setSaving(true);
setFormError('');
const f = form();
const body: Record<string, unknown> = {
code: f.code.toUpperCase(),
title: f.title,
type: f.type,
value: Number(f.value),
min_order_amount: Number(f.min_order_amount),
role_keys: f.role_keys,
};
if (f.max_uses) body.max_uses = Number(f.max_uses);
const url = f.id ? `${API}/api/admin/coupons/${f.id}` : `${API}/api/admin/coupons`;
const method = f.id ? 'PATCH' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error('Failed to save coupon');
resetForm();
refetch();
setActiveTab('list');
} catch (err: unknown) {
setFormError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setSaving(false);
}
};
const handleToggle = async (coupon: Coupon) => {
try {
setToggling(coupon.id);
const res = await fetch(`${API}/api/admin/coupons/${coupon.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !coupon.is_active }),
});
if (!res.ok) throw new Error('Failed to toggle');
refetch();
} catch {
// ignore
} finally {
setToggling('');
}
};
return (
<AdminShell>
<div class="page-actions">
<div>
<h1 class="page-title">Coupon Management</h1>
<p class="page-subtitle">Reusable coupon codes for package checkout</p>
</div>
</div>
{/* Tabs */}
<div style="display:flex;border-bottom:2px solid #e2e8f0;margin-bottom:24px;gap:0;overflow-x:auto;">
<button
type="button"
class={`admin-tab${activeTab() === 'list' ? ' active' : ''}`}
onClick={() => setActiveTab('list')}
>
Coupons
</button>
<button
type="button"
class={`admin-tab${activeTab() === 'create' ? ' active' : ''}`}
onClick={() => { resetForm(); setActiveTab('create'); }}
>
{form().id ? 'Edit Coupon' : 'Create Coupon'}
</button>
</div>
<Show when={activeTab() === 'list'}>
<section class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Code</th>
<th>Title</th>
<th>Type</th>
<th>Value</th>
<th>Max Uses</th>
<th>Status</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={coupons.loading}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!coupons.loading && coupons.error}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
</Show>
<Show when={!coupons.loading && !coupons.error && coupons()?.length === 0}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">No coupons found.</td></tr>
</Show>
<Show when={!coupons.loading && !coupons.error && (coupons()?.length ?? 0) > 0}>
<For each={coupons()}>
{(item) => (
<tr>
<td style="font-weight:600;color:#0f172a;font-family:monospace">{item.code}</td>
<td style="color:#475569">{item.title || '—'}</td>
<td style="color:#475569">{item.type}</td>
<td style="color:#475569">{item.type === 'PERCENT' ? `${item.value}%` : `${item.value}`}</td>
<td style="color:#475569">{item.usage_limit != null ? item.usage_limit : '—'}</td>
<td>
<span class={`status-chip ${item.is_active ? 'active' : ''}`}>
{item.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td>
<div class="table-actions">
<button class="btn" onClick={() => startEdit(item)}>Edit</button>
<button
class="btn"
disabled={toggling() === item.id}
onClick={() => handleToggle(item)}
>
{toggling() === item.id ? '...' : (item.is_active ? 'Disable' : 'Enable')}
</button>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</Show>
<Show when={activeTab() === 'create'}>
<section class="card" style="max-width:520px">
<h2 style="margin:0 0 20px;font-size:16px;font-weight:700">{form().id ? 'Edit Coupon' : 'Create Coupon'}</h2>
<Show when={formError()}>
<div class="error-box" style="margin-bottom:12px">{formError()}</div>
</Show>
<form onSubmit={handleSave} style="display:flex;flex-direction:column;gap:14px">
<div class="field">
<label>Code</label>
<input
type="text"
value={form().code}
onInput={(e) => setForm({ ...form(), code: e.currentTarget.value.toUpperCase() })}
required
placeholder="e.g. SAVE10"
style="text-transform:uppercase"
/>
</div>
<div class="field">
<label>Title</label>
<input
type="text"
value={form().title}
onInput={(e) => setForm({ ...form(), title: e.currentTarget.value })}
required
placeholder="e.g. 10% off for companies"
/>
</div>
<div class="field-grid-2">
<div class="field">
<label>Type</label>
<select
value={form().type}
onChange={(e) => setForm({ ...form(), type: e.currentTarget.value as 'PERCENT' | 'FIXED' })}
>
<option value="PERCENT">Percent (%)</option>
<option value="FIXED">Fixed ()</option>
</select>
</div>
<div class="field">
<label>Value</label>
<input
type="number"
value={form().value}
onInput={(e) => setForm({ ...form(), value: Number(e.currentTarget.value) })}
required
min="1"
/>
</div>
</div>
<div class="field-grid-2">
<div class="field">
<label>Min Order Amount ()</label>
<input
type="number"
value={form().min_order_amount}
onInput={(e) => setForm({ ...form(), min_order_amount: Number(e.currentTarget.value) })}
min="0"
placeholder="0"
/>
</div>
<div class="field">
<label>Max Uses (blank = unlimited)</label>
<input
type="number"
value={form().max_uses}
onInput={(e) => setForm({ ...form(), max_uses: e.currentTarget.value })}
min="1"
placeholder="Unlimited"
/>
</div>
</div>
<div>
<p style="font-size:13px;font-weight:600;margin:0 0 8px;color:#1e293b">Applicable Roles</p>
<div style="display:flex;flex-wrap:wrap;gap:8px">
<For each={ROLE_OPTIONS}>
{(role) => {
const active = () => form().role_keys.includes(role);
return (
<button
type="button"
onClick={() => toggleRole(role)}
style={`border-radius:999px;padding:4px 14px;font-size:13px;cursor:pointer;border:1px solid ${active() ? '#fdba74' : '#cbd5e1'};background:${active() ? '#fff7ed' : '#fff'};color:${active() ? '#c2410c' : '#475569'}`}
>
{role}
</button>
);
}}
</For>
</div>
</div>
<div class="actions">
<button class="btn navy" type="submit" disabled={saving()}>
{saving() ? 'Saving...' : (form().id ? 'Update Coupon' : 'Save Coupon')}
</button>
<Show when={form().id}>
<button type="button" class="btn" onClick={resetForm}>Cancel Edit</button>
</Show>
</div>
</form>
</section>
</Show>
</AdminShell>
);
}

405
src/routes/admin/credit.tsx Normal file
View file

@ -0,0 +1,405 @@
import { createSignal, createResource, Show, For } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
type LedgerEntry = {
id: string;
transactionType: 'ADD' | 'DEDUCT';
amount: number;
referenceId?: string;
expiresAt?: string;
createdAt?: string;
};
type ReconcileRow = {
userId: string;
expectedBalance: number;
actualBalance: number;
discrepancy: number;
};
export default function CreditPage() {
const [activeTab, setActiveTab] = createSignal<'ledger' | 'adjust' | 'reconcile'>('ledger');
// Balance & Ledger tab state
const [userId, setUserId] = createSignal('');
const [searchedUserId, setSearchedUserId] = createSignal('');
const [searchTrigger, setSearchTrigger] = createSignal(0);
const [balance, setBalance] = createSignal<number | null>(null);
const [ledger, setLedger] = createSignal<LedgerEntry[]>([]);
const [searchLoading, setSearchLoading] = createSignal(false);
const [searchError, setSearchError] = createSignal('');
// Reward/Deduct tab state
const [adjUserId, setAdjUserId] = createSignal('');
const [adjAmount, setAdjAmount] = createSignal(1);
const [adjType, setAdjType] = createSignal<'ADD' | 'DEDUCT'>('ADD');
const [adjReason, setAdjReason] = createSignal('');
const [adjRefId, setAdjRefId] = createSignal('');
const [adjLoading, setAdjLoading] = createSignal(false);
const [adjSuccess, setAdjSuccess] = createSignal('');
const [adjError, setAdjError] = createSignal('');
// Reconcile tab state
const [reconFrom, setReconFrom] = createSignal('');
const [reconTo, setReconTo] = createSignal('');
const [reconLoading, setReconLoading] = createSignal(false);
const [reconResults, setReconResults] = createSignal<ReconcileRow[] | null>(null);
const [reconError, setReconError] = createSignal('');
const handleSearch = async () => {
const uid = userId().trim();
if (!uid) return;
setSearchLoading(true);
setSearchError('');
setBalance(null);
setLedger([]);
setSearchedUserId(uid);
try {
const [balRes, ledRes] = await Promise.all([
fetch(`${API}/api/admin/credits/balance?userId=${encodeURIComponent(uid)}`),
fetch(`${API}/api/admin/credits/ledger?userId=${encodeURIComponent(uid)}`),
]);
if (!balRes.ok || !ledRes.ok) throw new Error('Failed to fetch');
const balData = await balRes.json();
const ledData = await ledRes.json();
setBalance(balData.balance ?? 0);
setLedger(Array.isArray(ledData.entries) ? ledData.entries : []);
} catch {
setSearchError('Failed to fetch TraceCoin data for this user ID.');
setBalance(null);
setLedger([]);
} finally {
setSearchLoading(false);
}
};
const handleAdjust = async (e: Event) => {
e.preventDefault();
setAdjLoading(true);
setAdjSuccess('');
setAdjError('');
try {
const res = await fetch(`${API}/api/admin/credits/adjust`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: adjUserId(),
amount: adjAmount(),
type: adjType(),
reason: adjReason(),
reference_id: adjRefId() || undefined,
}),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error((d as any).message || 'Failed to adjust credits');
}
setAdjSuccess('Credit adjusted successfully!');
setAdjUserId('');
setAdjAmount(1);
setAdjType('ADD');
setAdjReason('');
setAdjRefId('');
} catch (err: any) {
setAdjError(err.message || 'Failed to adjust credits');
} finally {
setAdjLoading(false);
}
};
const handleReconcile = async (e: Event) => {
e.preventDefault();
setReconLoading(true);
setReconError('');
setReconResults(null);
try {
const res = await fetch(
`${API}/api/admin/credits/reconcile?from=${encodeURIComponent(reconFrom())}&to=${encodeURIComponent(reconTo())}`
);
if (!res.ok) throw new Error('Failed to reconcile');
const data = await res.json();
setReconResults(Array.isArray(data.results) ? data.results : []);
} catch (err: any) {
setReconError(err.message || 'Failed to reconcile');
} finally {
setReconLoading(false);
}
};
const tabs: { key: 'ledger' | 'adjust' | 'reconcile'; label: string }[] = [
{ key: 'ledger', label: 'Balance & Ledger' },
{ key: 'adjust', label: 'Reward / Deduct' },
{ key: 'reconcile', label: 'Reconcile' },
];
return (
<AdminShell>
<div class="page-actions">
<div>
<h1 class="page-title">Credit Management</h1>
<p class="page-subtitle">Audit TraceCoin balances and adjust credits</p>
</div>
</div>
{/* Tabs */}
<div style="display:flex;border-bottom:2px solid #e2e8f0;margin-bottom:24px;gap:0;overflow-x:auto;">
<For each={tabs}>
{(tab) => (
<button
type="button"
class={`admin-tab${activeTab() === tab.key ? ' active' : ''}`}
onClick={() => setActiveTab(tab.key)}
>
{tab.label}
</button>
)}
</For>
</div>
{/* Balance & Ledger Tab */}
<Show when={activeTab() === 'ledger'}>
<div style="display:flex;flex-direction:column;gap:24px">
<section class="card">
<h2 style="margin:0 0 16px;font-size:16px;font-weight:700;color:#1e293b">Search Account Balance</h2>
<div style="display:flex;gap:10px">
<input
type="text"
placeholder="Enter User ID..."
value={userId()}
onInput={(e) => setUserId(e.currentTarget.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
style="flex:1;padding:8px 12px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
/>
<button
class="btn navy"
onClick={handleSearch}
disabled={searchLoading()}
>
{searchLoading() ? 'Searching...' : 'Search'}
</button>
</div>
<Show when={searchError()}>
<div class="error-box" style="margin-top:10px">{searchError()}</div>
</Show>
</section>
<Show when={balance() !== null}>
<div style="display:grid;grid-template-columns:1fr 2fr;gap:20px">
<div style="background:#2563eb;border-radius:12px;padding:24px;color:#fff">
<p style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;color:#bfdbfe;margin:0 0 8px">Current Balance</p>
<p style="font-size:36px;font-weight:900;margin:0">{balance()} TraceCoins</p>
<p style="font-size:12px;color:#bfdbfe;margin:8px 0 0">User: {searchedUserId()}</p>
</div>
<section class="card" style="padding:20px;overflow:hidden">
<h3 style="margin:0 0 16px;font-size:15px;font-weight:700;color:#0f172a">TraceCoin Ledger</h3>
<Show when={ledger().length === 0}>
<p style="text-align:center;padding:32px;color:#94a3b8;font-style:italic">No transactions found for this account.</p>
</Show>
<Show when={ledger().length > 0}>
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Type</th>
<th>Amount</th>
<th>Ref ID</th>
<th>Expires At</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<For each={ledger()}>
{(entry) => (
<tr>
<td>
<span
style={`display:inline-block;padding:2px 8px;border-radius:999px;font-size:10px;font-weight:700;text-transform:uppercase;background:${entry.transactionType === 'ADD' ? '#dcfce7' : '#fee2e2'};color:${entry.transactionType === 'ADD' ? '#15803d' : '#b91c1c'}`}
>
{entry.transactionType}
</span>
</td>
<td style={`font-weight:700;${entry.transactionType === 'ADD' ? 'color:#16a34a' : 'color:#dc2626'}`}>
{entry.transactionType === 'ADD' ? '+' : '-'}{entry.amount}
</td>
<td style="font-size:12px;color:#64748b;font-family:monospace">
{entry.referenceId ? entry.referenceId.substring(0, 18) : '—'}
</td>
<td style="font-size:12px;color:#94a3b8">
{entry.expiresAt ? new Date(entry.expiresAt).toLocaleDateString() : '—'}
</td>
<td style="font-size:12px;color:#94a3b8">
{entry.createdAt ? new Date(entry.createdAt).toLocaleString() : '—'}
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</Show>
</section>
</div>
</Show>
</div>
</Show>
{/* Reward / Deduct Tab */}
<Show when={activeTab() === 'adjust'}>
<section class="card" style="max-width:460px">
<h2 style="margin:0 0 6px;font-size:16px;font-weight:700;color:#1e293b">Reward or Adjust TraceCoins</h2>
<p style="margin:0 0 20px;font-size:13px;color:#64748b">
Use this to reward TraceCoins for a valid support case, process a refund, or correct a balance manually.
</p>
<Show when={adjSuccess()}>
<div style="background:#dcfce7;border:1px solid #86efac;border-radius:6px;padding:10px 14px;margin-bottom:14px;font-size:14px;color:#15803d;font-weight:600">
{adjSuccess()}
</div>
</Show>
<Show when={adjError()}>
<div class="error-box" style="margin-bottom:14px">{adjError()}</div>
</Show>
<form onSubmit={handleAdjust} style="display:flex;flex-direction:column;gap:14px">
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">User ID</label>
<input
type="text"
required
value={adjUserId()}
onInput={(e) => setAdjUserId(e.currentTarget.value)}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
/>
</div>
<div class="field-grid-2">
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Amount</label>
<input
type="number"
min="1"
required
value={adjAmount()}
onInput={(e) => setAdjAmount(Number(e.currentTarget.value))}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
/>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Type</label>
<select
value={adjType()}
onChange={(e) => setAdjType(e.currentTarget.value as 'ADD' | 'DEDUCT')}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
>
<option value="ADD">ADD</option>
<option value="DEDUCT">DEDUCT</option>
</select>
</div>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Reason</label>
<input
type="text"
required
value={adjReason()}
onInput={(e) => setAdjReason(e.currentTarget.value)}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
/>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Reference ID <span style="font-weight:400;color:#94a3b8">(optional)</span></label>
<input
type="text"
value={adjRefId()}
onInput={(e) => setAdjRefId(e.currentTarget.value)}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
/>
</div>
<div>
<button class="btn navy" type="submit" disabled={adjLoading()}>
{adjLoading() ? 'Adjusting...' : 'Apply Adjustment'}
</button>
</div>
</form>
</section>
</Show>
{/* Reconcile Tab */}
<Show when={activeTab() === 'reconcile'}>
<div style="display:flex;flex-direction:column;gap:20px">
<section class="card" style="max-width:460px">
<h2 style="margin:0 0 16px;font-size:16px;font-weight:700;color:#1e293b">Ledger Reconciliation</h2>
<Show when={reconError()}>
<div class="error-box" style="margin-bottom:14px">{reconError()}</div>
</Show>
<form onSubmit={handleReconcile} style="display:flex;flex-direction:column;gap:14px">
<div class="field-grid-2">
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Start Date</label>
<input
type="date"
required
value={reconFrom()}
onInput={(e) => setReconFrom(e.currentTarget.value)}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
/>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">End Date</label>
<input
type="date"
required
value={reconTo()}
onInput={(e) => setReconTo(e.currentTarget.value)}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
/>
</div>
</div>
<div>
<button class="btn navy" type="submit" disabled={reconLoading()}>
{reconLoading() ? 'Running...' : 'Run Reconciliation'}
</button>
</div>
</form>
</section>
<Show when={reconResults() !== null}>
<Show when={(reconResults()?.length ?? 0) === 0}>
<p style="color:#16a34a;font-weight:600;font-size:14px">No discrepancies found.</p>
</Show>
<Show when={(reconResults()?.length ?? 0) > 0}>
<section class="card" style="padding:0;overflow:hidden">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>User ID</th>
<th>Expected Balance</th>
<th>Actual Balance</th>
<th>Discrepancy</th>
</tr>
</thead>
<tbody>
<For each={reconResults()!}>
{(row) => (
<tr>
<td style="font-family:monospace;font-size:13px">{row.userId}</td>
<td>{row.expectedBalance}</td>
<td>{row.actualBalance}</td>
<td style={row.discrepancy !== 0 ? 'color:#dc2626;font-weight:700' : 'color:#16a34a'}>
{row.discrepancy}
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</section>
</Show>
</Show>
</div>
</Show>
</AdminShell>
);
}

View file

@ -0,0 +1,124 @@
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=customer`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.users || []);
} catch {
return [];
}
}
export default function CustomerPage() {
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="page-actions">
<div>
<h1 class="page-title">Customer Management</h1>
<p class="page-subtitle">Manage all customer accounts on the platform.</p>
</div>
</div>
<section class="card" style="padding: 0; overflow: hidden;">
<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="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Registered</th>
<th class="align-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 customer users found.</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length > 0}>
<For each={filtered()}>
{(item) => (
<tr>
<td style="font-weight:600;color:#0f172a">{item.name || item.full_name || '—'}</td>
<td style="color:#475569">{item.email}</td>
<td>
{item.status?.toUpperCase() === 'ACTIVE' && (
<span class="status-chip active">ACTIVE</span>
)}
{item.status?.toUpperCase() === 'INACTIVE' && (
<span class="status-chip">INACTIVE</span>
)}
{item.status?.toUpperCase() === 'PENDING' && (
<span class="status-chip" style="background:#fff7ed;color:#c2410c;border-color:#fed7aa;">PENDING</span>
)}
{!item.status && <span class="status-chip"></span>}
</td>
<td style="color:#475569">
{item.created_at ? new Date(item.created_at).toLocaleDateString() : '—'}
</td>
<td>
<div class="table-actions">
<A class="btn" href={`/admin/users/${item.id}`}>View</A>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</AdminShell>
);
}

View file

@ -0,0 +1,424 @@
import { A } from '@solidjs/router';
import { createResource, createSignal, createMemo, Show, For } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
type Department = {
id: string;
name?: string;
departmentName?: string;
description?: string;
is_archived?: boolean;
status?: string | number;
created_at?: string;
createdAt?: string;
};
async function loadDepartments(): Promise<Department[]> {
try {
const res = await fetch(`${API}/api/admin/departments`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.departments ?? []);
} catch {
return [];
}
}
function isArchived(item: Department): boolean {
if (item.is_archived !== undefined) return item.is_archived;
if (item.status !== undefined) {
const s = String(item.status).toUpperCase();
return s === 'ARCHIVED' || s === '2';
}
return false;
}
function deptLabel(item: Department): string {
return item.departmentName || item.name || '—';
}
function fmtDate(val?: string): string {
if (!val) return '—';
try {
return new Date(val).toLocaleDateString();
} catch {
return val;
}
}
export default function DepartmentPage() {
const [departments, { refetch }] = createResource(loadDepartments);
// tabs
const [tab, setTab] = createSignal<'active' | 'archived'>('active');
// create form
const [showCreate, setShowCreate] = createSignal(false);
const [createName, setCreateName] = createSignal('');
const [createDesc, setCreateDesc] = createSignal('');
const [creating, setCreating] = createSignal(false);
const [createError, setCreateError] = createSignal('');
// inline edit
const [editingId, setEditingId] = createSignal('');
const [editName, setEditName] = createSignal('');
const [editDesc, setEditDesc] = createSignal('');
const [saving, setSaving] = createSignal(false);
const [editError, setEditError] = createSignal('');
// row-level busy
const [busy, setBusy] = createSignal('');
const [actionError, setActionError] = createSignal('');
const filtered = createMemo(() => {
const all = departments() ?? [];
return tab() === 'archived'
? all.filter((d) => isArchived(d))
: all.filter((d) => !isArchived(d));
});
// ---------- CREATE ----------
const handleCreate = async (e: Event) => {
e.preventDefault();
if (!createName().trim()) return;
setCreating(true);
setCreateError('');
try {
const res = await fetch(`${API}/api/admin/departments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: createName().trim(),
description: createDesc().trim(),
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as any).message || 'Failed to create');
}
setCreateName('');
setCreateDesc('');
setShowCreate(false);
setTab('active');
refetch();
} catch (err: any) {
setCreateError(err.message || 'Failed to create department');
} finally {
setCreating(false);
}
};
// ---------- EDIT ----------
const startEdit = (item: Department) => {
setEditingId(item.id);
setEditName(deptLabel(item));
setEditDesc(item.description ?? '');
setEditError('');
};
const cancelEdit = () => {
setEditingId('');
setEditError('');
};
const handleUpdate = async (id: string) => {
if (!editName().trim()) return;
setSaving(true);
setEditError('');
try {
const res = await fetch(`${API}/api/admin/departments/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: editName().trim(),
description: editDesc().trim(),
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as any).message || 'Failed to update');
}
setEditingId('');
refetch();
} catch (err: any) {
setEditError(err.message || 'Failed to update department');
} finally {
setSaving(false);
}
};
// ---------- ARCHIVE / RESTORE ----------
const handleArchive = async (id: string) => {
setBusy(id);
setActionError('');
try {
const res = await fetch(`${API}/api/admin/departments/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_archived: true }),
});
if (!res.ok) throw new Error('Failed to archive');
refetch();
} catch (err: any) {
setActionError(err.message || 'Failed to archive department');
} finally {
setBusy('');
}
};
const handleRestore = async (id: string) => {
setBusy(id);
setActionError('');
try {
const res = await fetch(`${API}/api/admin/departments/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_archived: false }),
});
if (!res.ok) throw new Error('Failed to restore');
refetch();
} catch (err: any) {
setActionError(err.message || 'Failed to restore department');
} finally {
setBusy('');
}
};
// ---------- DELETE ----------
const handleDelete = async (id: string, name: string) => {
if (!confirm(`Delete department "${name}"?`)) return;
setBusy(id);
setActionError('');
try {
const res = await fetch(`${API}/api/admin/departments/${id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error('Failed to delete');
refetch();
} catch (err: any) {
setActionError(err.message || 'Failed to delete department');
} finally {
setBusy('');
}
};
return (
<AdminShell>
{/* Header */}
<div class="page-actions">
<div>
<h1 class="page-title">Departments</h1>
<p class="page-subtitle">Manage organization departments</p>
</div>
<button
class="btn navy"
onClick={() => {
setShowCreate((v) => !v);
setCreateError('');
}}
>
{showCreate() ? 'Cancel' : 'Add Department'}
</button>
</div>
{/* Create form */}
<Show when={showCreate()}>
<section class="card role-form-section">
<form onSubmit={handleCreate}>
<div class="field-grid-2">
<div class="field">
<label>Name *</label>
<input
type="text"
required
placeholder="e.g. Engineering"
value={createName()}
onInput={(e) => setCreateName(e.currentTarget.value)}
/>
</div>
<div class="field">
<label>Description</label>
<input
type="text"
placeholder="Optional description"
value={createDesc()}
onInput={(e) => setCreateDesc(e.currentTarget.value)}
/>
</div>
</div>
<Show when={createError()}>
<p class="error-box" style="margin-top:10px">{createError()}</p>
</Show>
<div class="actions" style="margin-top:16px">
<button
type="button"
class="btn"
onClick={() => {
setShowCreate(false);
setCreateError('');
}}
>
Cancel
</button>
<button type="submit" class="btn navy" disabled={creating()}>
{creating() ? 'Saving...' : 'Save'}
</button>
</div>
</form>
</section>
</Show>
{/* Tabs */}
<div style="display:flex;gap:24px;border-bottom:1px solid #e2e8f0;margin-bottom:16px">
<button
class={`admin-tab${tab() === 'active' ? ' active' : ''}`}
onClick={() => setTab('active')}
>
Active
</button>
<button
class={`admin-tab${tab() === 'archived' ? ' active' : ''}`}
onClick={() => setTab('archived')}
>
Archived
</button>
</div>
{/* Action error */}
<Show when={actionError()}>
<div class="error-box" style="margin-bottom:12px">{actionError()}</div>
</Show>
{/* Table */}
<section class="card" style="padding:0;overflow:hidden">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Created At</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={departments.loading}>
<tr>
<td colspan="4" style="text-align:center;padding:32px;color:#64748b">
Loading...
</td>
</tr>
</Show>
<Show when={!departments.loading && departments.error}>
<tr>
<td colspan="4" style="text-align:center;padding:32px;color:#b91c1c">
Failed to load. Is the backend running?
</td>
</tr>
</Show>
<Show when={!departments.loading && !departments.error && filtered().length === 0}>
<tr>
<td colspan="4" style="text-align:center;padding:32px;color:#94a3b8">
No departments found.
</td>
</tr>
</Show>
<Show when={!departments.loading && !departments.error && filtered().length > 0}>
<For each={filtered()}>
{(item) => (
<>
<tr>
<td style="font-weight:600;color:#0f172a">{deptLabel(item)}</td>
<td style="color:#475569">{item.description || '—'}</td>
<td style="color:#475569">{fmtDate(item.createdAt || item.created_at)}</td>
<td>
<div class="table-actions">
<button
class="btn"
onClick={() => startEdit(item)}
>
Edit
</button>
<Show when={tab() === 'active'}>
<button
class="btn"
disabled={busy() === item.id}
onClick={() => handleArchive(item.id)}
>
{busy() === item.id ? '...' : 'Archive'}
</button>
</Show>
<Show when={tab() === 'archived'}>
<button
class="btn"
disabled={busy() === item.id}
onClick={() => handleRestore(item.id)}
>
{busy() === item.id ? '...' : 'Restore'}
</button>
</Show>
<button
class="btn danger"
disabled={busy() === item.id}
onClick={() => handleDelete(item.id, deptLabel(item))}
>
{busy() === item.id ? '...' : 'Delete'}
</button>
</div>
</td>
</tr>
{/* Inline edit row */}
<Show when={editingId() === item.id}>
<tr>
<td colspan="4" style="background:#f8fafc;padding:16px">
<div class="field-grid-2" style="margin-bottom:10px">
<div class="field">
<label>Name *</label>
<input
type="text"
required
value={editName()}
onInput={(e) => setEditName(e.currentTarget.value)}
/>
</div>
<div class="field">
<label>Description</label>
<input
type="text"
value={editDesc()}
onInput={(e) => setEditDesc(e.currentTarget.value)}
/>
</div>
</div>
<Show when={editError()}>
<p class="error-box" style="margin-bottom:8px">{editError()}</p>
</Show>
<div class="actions">
<button class="btn" type="button" onClick={cancelEdit}>
Cancel
</button>
<button
class="btn navy"
type="button"
disabled={saving()}
onClick={() => handleUpdate(item.id)}
>
{saving() ? 'Saving...' : 'Save'}
</button>
</div>
</td>
</tr>
</Show>
</>
)}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</AdminShell>
);
}

View file

@ -0,0 +1,425 @@
import { A } from '@solidjs/router';
import { createResource, createSignal, createMemo, Show, For } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
type Department = {
id: string;
name?: string;
departmentName?: string;
};
type Designation = {
id: string;
name: string;
departmentId?: string;
departmentName?: string;
department?: string;
description?: string;
is_archived?: boolean;
status?: string;
};
async function loadDesignations(): Promise<Designation[]> {
try {
const res = await fetch(`${API}/api/admin/designations`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.designations ?? []);
} catch {
return [];
}
}
async function loadDepartments(): Promise<Department[]> {
try {
const res = await fetch(`${API}/api/admin/departments`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.departments ?? []);
} catch {
return [];
}
}
function deptDisplay(item: Designation): string {
return item.departmentName || item.department || '—';
}
function deptName(d: Department): string {
return d.departmentName || d.name || d.id;
}
function isArchived(item: Designation): boolean {
if (item.is_archived !== undefined) return item.is_archived;
if (item.status !== undefined) {
const s = String(item.status).toUpperCase();
return s === 'ARCHIVED' || s === '2';
}
return false;
}
export default function DesignationPage() {
const [designations, { refetch }] = createResource(loadDesignations);
const [departments] = createResource(loadDepartments);
// tabs
const [tab, setTab] = createSignal<'active' | 'archived'>('active');
// create form
const [showCreate, setShowCreate] = createSignal(false);
const [createName, setCreateName] = createSignal('');
const [createDeptId, setCreateDeptId] = createSignal('');
const [createDesc, setCreateDesc] = createSignal('');
const [creating, setCreating] = createSignal(false);
const [createError, setCreateError] = createSignal('');
// inline edit
const [editingId, setEditingId] = createSignal('');
const [editName, setEditName] = createSignal('');
const [editDeptId, setEditDeptId] = createSignal('');
const [editDesc, setEditDesc] = createSignal('');
const [saving, setSaving] = createSignal(false);
const [editError, setEditError] = createSignal('');
// row busy / errors
const [deleting, setDeleting] = createSignal('');
const [actionError, setActionError] = createSignal('');
const filtered = createMemo(() => {
const all = designations() ?? [];
return tab() === 'archived'
? all.filter((d) => isArchived(d))
: all.filter((d) => !isArchived(d));
});
// ---------- CREATE ----------
const handleCreate = async (e: Event) => {
e.preventDefault();
if (!createName().trim() || !createDeptId()) return;
setCreating(true);
setCreateError('');
try {
const res = await fetch(`${API}/api/admin/designations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: createName().trim(),
department_id: createDeptId(),
description: createDesc().trim(),
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as any).message || 'Failed to create');
}
setCreateName('');
setCreateDeptId('');
setCreateDesc('');
setShowCreate(false);
setTab('active');
refetch();
} catch (err: any) {
setCreateError(err.message || 'Failed to create designation');
} finally {
setCreating(false);
}
};
// ---------- INLINE EDIT ----------
const startEdit = (item: Designation) => {
setEditingId(item.id);
setEditName(item.name);
setEditDeptId(item.departmentId ?? '');
setEditDesc(item.description ?? '');
setEditError('');
};
const cancelEdit = () => {
setEditingId('');
setEditError('');
};
const handleUpdate = async (id: string) => {
if (!editName().trim()) return;
setSaving(true);
setEditError('');
try {
const res = await fetch(`${API}/api/admin/designations/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: editName().trim(),
department_id: editDeptId(),
description: editDesc().trim(),
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as any).message || 'Failed to update');
}
setEditingId('');
refetch();
} catch (err: any) {
setEditError(err.message || 'Failed to update designation');
} finally {
setSaving(false);
}
};
// ---------- DELETE ----------
const handleDelete = async (id: string, name: string) => {
if (!confirm(`Delete designation "${name}"?`)) return;
setDeleting(id);
setActionError('');
try {
const res = await fetch(`${API}/api/admin/designations/${id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error('Failed to delete');
refetch();
} catch (err: any) {
setActionError(err.message || 'Failed to delete designation');
} finally {
setDeleting('');
}
};
return (
<AdminShell>
{/* Header */}
<div class="page-actions">
<div>
<h1 class="page-title">Designations</h1>
<p class="page-subtitle">Manage job designations</p>
</div>
<button
class="btn navy"
onClick={() => {
setShowCreate((v) => !v);
setCreateError('');
// Pre-select first department when opening
const depts = departments() ?? [];
if (!createDeptId() && depts.length > 0) setCreateDeptId(depts[0].id);
}}
>
{showCreate() ? 'Cancel' : 'Add Designation'}
</button>
</div>
{/* Create form */}
<Show when={showCreate()}>
<section class="card role-form-section">
<form onSubmit={handleCreate}>
<div class="field-grid-2">
<div class="field">
<label>Name *</label>
<input
type="text"
required
placeholder="e.g. Senior Engineer"
value={createName()}
onInput={(e) => setCreateName(e.currentTarget.value)}
/>
</div>
<div class="field">
<label>Department *</label>
<select
required
value={createDeptId()}
onChange={(e) => setCreateDeptId(e.currentTarget.value)}
>
<option value="">Select a department...</option>
<Show when={departments.loading}>
<option disabled>Loading departments...</option>
</Show>
<For each={departments() ?? []}>
{(d) => <option value={d.id}>{deptName(d)}</option>}
</For>
</select>
</div>
<div class="field" style="grid-column:1/-1">
<label>Description</label>
<textarea
placeholder="Optional description"
rows="3"
value={createDesc()}
onInput={(e) => setCreateDesc(e.currentTarget.value)}
/>
</div>
</div>
<Show when={createError()}>
<p class="error-box" style="margin-top:10px">{createError()}</p>
</Show>
<div class="actions" style="margin-top:16px">
<button
type="button"
class="btn"
onClick={() => {
setShowCreate(false);
setCreateError('');
}}
>
Cancel
</button>
<button
type="submit"
class="btn navy"
disabled={creating() || (departments() ?? []).length === 0}
>
{creating() ? 'Saving...' : 'Save'}
</button>
</div>
</form>
</section>
</Show>
{/* Tabs */}
<div style="display:flex;gap:24px;border-bottom:1px solid #e2e8f0;margin-bottom:16px">
<button
class={`admin-tab${tab() === 'active' ? ' active' : ''}`}
onClick={() => setTab('active')}
>
Active
</button>
<button
class={`admin-tab${tab() === 'archived' ? ' active' : ''}`}
onClick={() => setTab('archived')}
>
Archived
</button>
</div>
{/* Action error */}
<Show when={actionError()}>
<div class="error-box" style="margin-bottom:12px">{actionError()}</div>
</Show>
{/* Table */}
<section class="card" style="padding:0;overflow:hidden">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Name</th>
<th>Department</th>
<th>Description</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={designations.loading}>
<tr>
<td colspan="4" style="text-align:center;padding:32px;color:#64748b">
Loading...
</td>
</tr>
</Show>
<Show when={!designations.loading && designations.error}>
<tr>
<td colspan="4" style="text-align:center;padding:32px;color:#b91c1c">
Failed to load. Is the backend running?
</td>
</tr>
</Show>
<Show when={!designations.loading && !designations.error && filtered().length === 0}>
<tr>
<td colspan="4" style="text-align:center;padding:32px;color:#94a3b8">
No designations found.
</td>
</tr>
</Show>
<Show when={!designations.loading && !designations.error && filtered().length > 0}>
<For each={filtered()}>
{(item) => (
<>
<tr>
<td style="font-weight:600;color:#0f172a">{item.name}</td>
<td style="color:#475569">{deptDisplay(item)}</td>
<td style="color:#475569">{item.description || '—'}</td>
<td>
<div class="table-actions">
<button
class="btn"
onClick={() => startEdit(item)}
>
Edit
</button>
<button
class="btn danger"
disabled={deleting() === item.id}
onClick={() => handleDelete(item.id, item.name)}
>
{deleting() === item.id ? '...' : 'Delete'}
</button>
</div>
</td>
</tr>
{/* Inline edit row */}
<Show when={editingId() === item.id}>
<tr>
<td colspan="4" style="background:#f8fafc;padding:16px">
<div class="field-grid-2" style="margin-bottom:10px">
<div class="field">
<label>Name *</label>
<input
type="text"
required
value={editName()}
onInput={(e) => setEditName(e.currentTarget.value)}
/>
</div>
<div class="field">
<label>Department</label>
<select
value={editDeptId()}
onChange={(e) => setEditDeptId(e.currentTarget.value)}
>
<option value="">Select a department...</option>
<For each={departments() ?? []}>
{(d) => (
<option value={d.id}>{deptName(d)}</option>
)}
</For>
</select>
</div>
<div class="field" style="grid-column:1/-1">
<label>Description</label>
<textarea
rows="3"
value={editDesc()}
onInput={(e) => setEditDesc(e.currentTarget.value)}
/>
</div>
</div>
<Show when={editError()}>
<p class="error-box" style="margin-bottom:8px">{editError()}</p>
</Show>
<div class="actions">
<button class="btn" type="button" onClick={cancelEdit}>
Cancel
</button>
<button
class="btn navy"
type="button"
disabled={saving()}
onClick={() => handleUpdate(item.id)}
>
{saving() ? 'Saving...' : 'Save'}
</button>
</div>
</td>
</tr>
</Show>
</>
)}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</AdminShell>
);
}

View file

@ -0,0 +1,124 @@
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 [];
}
}
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="page-actions">
<div>
<h1 class="page-title">Developers Management</h1>
<p class="page-subtitle">Manage all developer accounts on the platform.</p>
</div>
</div>
<section class="card" style="padding: 0; overflow: hidden;">
<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="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Registered</th>
<th class="align-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 developer users found.</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length > 0}>
<For each={filtered()}>
{(item) => (
<tr>
<td style="font-weight:600;color:#0f172a">{item.name || item.full_name || '—'}</td>
<td style="color:#475569">{item.email}</td>
<td>
{item.status?.toUpperCase() === 'ACTIVE' && (
<span class="status-chip active">ACTIVE</span>
)}
{item.status?.toUpperCase() === 'INACTIVE' && (
<span class="status-chip">INACTIVE</span>
)}
{item.status?.toUpperCase() === 'PENDING' && (
<span class="status-chip" style="background:#fff7ed;color:#c2410c;border-color:#fed7aa;">PENDING</span>
)}
{!item.status && <span class="status-chip"></span>}
</td>
<td style="color:#475569">
{item.created_at ? new Date(item.created_at).toLocaleDateString() : '—'}
</td>
<td>
<div class="table-actions">
<A class="btn" href={`/admin/users/${item.id}`}>View</A>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</AdminShell>
);
}

View file

@ -0,0 +1,339 @@
import { createResource, createSignal, Show, For } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
const ROLE_OPTIONS = [
'company',
'customer',
'job_seeker',
'photographer',
'video_editor',
'graphic_designer',
'social_media_manager',
'fitness_trainer',
'catering_services',
'makeup_artist',
'tutor',
'developer',
];
type Discount = {
id: string;
title: string;
scope: 'ROLE' | 'PACKAGE';
role_key?: string;
package_id?: string;
type: 'PERCENT' | 'FIXED';
value: number;
is_active: boolean;
};
type Package = {
id: string;
name: string;
role_key?: string;
};
async function loadDiscounts(): Promise<Discount[]> {
try {
const res = await fetch(`${API}/api/admin/discounts`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.discounts || []);
} catch {
return [];
}
}
async function loadPackages(): Promise<Package[]> {
try {
const res = await fetch(`${API}/api/admin/tracecoin-packages`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.packages || []);
} catch {
return [];
}
}
const defaultForm = () => ({
id: '',
title: '',
scope: 'ROLE' as 'ROLE' | 'PACKAGE',
role_key: 'company',
package_id: '',
type: 'PERCENT' as 'PERCENT' | 'FIXED',
value: 5,
});
export default function DiscountPage() {
const [discounts, { refetch: refetchDiscounts }] = createResource(loadDiscounts);
const [packages] = createResource(loadPackages);
const [activeTab, setActiveTab] = createSignal<'list' | 'create'>('list');
const [form, setForm] = createSignal(defaultForm());
const [saving, setSaving] = createSignal(false);
const [toggling, setToggling] = createSignal('');
const [formError, setFormError] = createSignal('');
const resetForm = () => {
setForm(defaultForm());
setFormError('');
};
const getTarget = (item: Discount) => {
if (item.scope === 'ROLE') return item.role_key || '—';
const pkgs = packages();
if (pkgs) {
const pkg = pkgs.find((p) => p.id === item.package_id);
if (pkg) return pkg.name;
}
return item.package_id || '—';
};
const handleSave = async (e: Event) => {
e.preventDefault();
try {
setSaving(true);
setFormError('');
const f = form();
const body: Record<string, unknown> = {
title: f.title,
scope: f.scope,
type: f.type,
value: Number(f.value),
};
if (f.scope === 'ROLE') {
body.role_key = f.role_key;
} else {
body.package_id = f.package_id;
}
const url = f.id ? `${API}/api/admin/discounts/${f.id}` : `${API}/api/admin/discounts`;
const method = f.id ? 'PATCH' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error('Failed to save discount');
resetForm();
refetchDiscounts();
setActiveTab('list');
} catch (err: unknown) {
setFormError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setSaving(false);
}
};
const handleToggle = async (discount: Discount) => {
try {
setToggling(discount.id);
const res = await fetch(`${API}/api/admin/discounts/${discount.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !discount.is_active }),
});
if (!res.ok) throw new Error('Failed to toggle');
refetchDiscounts();
} catch {
// ignore
} finally {
setToggling('');
}
};
return (
<AdminShell>
<div class="page-actions">
<div>
<h1 class="page-title">Discount Management</h1>
<p class="page-subtitle">Automatic discounts applied before coupons</p>
</div>
</div>
{/* Tabs */}
<div style="display:flex;border-bottom:2px solid #e2e8f0;margin-bottom:24px;gap:0;overflow-x:auto;">
<button
type="button"
class={`admin-tab${activeTab() === 'list' ? ' active' : ''}`}
onClick={() => setActiveTab('list')}
>
Discounts
</button>
<button
type="button"
class={`admin-tab${activeTab() === 'create' ? ' active' : ''}`}
onClick={() => { resetForm(); setActiveTab('create'); }}
>
{form().id ? 'Edit Discount' : 'Create Discount'}
</button>
</div>
<Show when={activeTab() === 'list'}>
<section class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Title</th>
<th>Scope</th>
<th>Target</th>
<th>Type</th>
<th>Value</th>
<th>Status</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={discounts.loading}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!discounts.loading && discounts.error}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
</Show>
<Show when={!discounts.loading && !discounts.error && discounts()?.length === 0}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">No discounts found.</td></tr>
</Show>
<Show when={!discounts.loading && !discounts.error && (discounts()?.length ?? 0) > 0}>
<For each={discounts()}>
{(item) => (
<tr>
<td style="font-weight:600;color:#0f172a">{item.title || '—'}</td>
<td style="color:#475569">{item.scope}</td>
<td style="color:#475569">{getTarget(item)}</td>
<td style="color:#475569">{item.type}</td>
<td style="color:#475569">{item.type === 'PERCENT' ? `${item.value}%` : `${item.value}`}</td>
<td>
<span class={`status-chip ${item.is_active ? 'active' : ''}`}>
{item.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td>
<div class="table-actions">
<button
class="btn"
disabled={toggling() === item.id}
onClick={() => handleToggle(item)}
>
{toggling() === item.id ? '...' : (item.is_active ? 'Disable' : 'Enable')}
</button>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</Show>
<Show when={activeTab() === 'create'}>
<section class="card" style="max-width:520px">
<h2 style="margin:0 0 20px;font-size:16px;font-weight:700">{form().id ? 'Edit Discount' : 'Create Discount'}</h2>
<Show when={formError()}>
<div class="error-box" style="margin-bottom:12px">{formError()}</div>
</Show>
<form onSubmit={handleSave} style="display:flex;flex-direction:column;gap:14px">
<div class="field">
<label>Title</label>
<input
type="text"
value={form().title}
onInput={(e) => setForm({ ...form(), title: e.currentTarget.value })}
required
placeholder="e.g. New user 10% off"
/>
</div>
<div class="field">
<label>Scope</label>
<div style="display:flex;gap:24px;margin-top:4px">
<label style="display:flex;align-items:center;gap:6px;font-size:14px;font-weight:400;cursor:pointer">
<input
type="radio"
name="scope"
value="ROLE"
checked={form().scope === 'ROLE'}
onChange={() => setForm({ ...form(), scope: 'ROLE' })}
/>
ROLE
</label>
<label style="display:flex;align-items:center;gap:6px;font-size:14px;font-weight:400;cursor:pointer">
<input
type="radio"
name="scope"
value="PACKAGE"
checked={form().scope === 'PACKAGE'}
onChange={() => setForm({ ...form(), scope: 'PACKAGE' })}
/>
PACKAGE
</label>
</div>
</div>
<div class="field">
<label>Target</label>
<Show when={form().scope === 'ROLE'}>
<select
value={form().role_key}
onChange={(e) => setForm({ ...form(), role_key: e.currentTarget.value })}
>
<For each={ROLE_OPTIONS}>
{(role) => <option value={role}>{role}</option>}
</For>
</select>
</Show>
<Show when={form().scope === 'PACKAGE'}>
<select
value={form().package_id}
onChange={(e) => setForm({ ...form(), package_id: e.currentTarget.value })}
>
<Show when={packages.loading}>
<option value="">Loading packages...</option>
</Show>
<Show when={!packages.loading}>
<For each={packages()}>
{(pkg) => <option value={pkg.id}>{pkg.name}</option>}
</For>
</Show>
</select>
</Show>
</div>
<div class="field-grid-2">
<div class="field">
<label>Type</label>
<select
value={form().type}
onChange={(e) => setForm({ ...form(), type: e.currentTarget.value as 'PERCENT' | 'FIXED' })}
>
<option value="PERCENT">Percent (%)</option>
<option value="FIXED">Fixed ()</option>
</select>
</div>
<div class="field">
<label>Value</label>
<input
type="number"
value={form().value}
onInput={(e) => setForm({ ...form(), value: Number(e.currentTarget.value) })}
required
min="1"
/>
</div>
</div>
<div class="actions">
<button class="btn navy" type="submit" disabled={saving()}>
{saving() ? 'Saving...' : (form().id ? 'Update Discount' : 'Save Discount')}
</button>
<Show when={form().id}>
<button type="button" class="btn" onClick={resetForm}>Cancel Edit</button>
</Show>
</div>
</form>
</section>
</Show>
</AdminShell>
);
}

View file

@ -0,0 +1,353 @@
import { A, useNavigate } from '@solidjs/router';
import { createResource, createSignal, Show, For } from 'solid-js';
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?: string | { name?: string };
role_name?: string;
status?: string;
is_active?: boolean;
};
async function loadEmployees(): Promise<Employee[]> {
try {
const res = await fetch(`${API}/api/admin/employees`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.employees ?? data.users ?? []);
} catch {
return [];
}
}
async function loadRoles(): Promise<Role[]> {
try {
const res = await fetch(`${API}/api/admin/roles?audience=INTERNAL`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.roles ?? []);
} catch {
return [];
}
}
function employeeName(e: Employee): string {
return e.name || e.full_name || '—';
}
function roleName(e: Employee): string {
if (!e.role) return e.role_name ?? '—';
if (typeof e.role === 'string') return e.role;
return e.role.name ?? '—';
}
function isActive(e: Employee): boolean {
if (e.is_active !== undefined) return e.is_active;
const s = String(e.status ?? '').toUpperCase();
return s === 'ACTIVE' || s === 'TRUE' || s === '1';
}
export default function EmployeesPage() {
const navigate = useNavigate();
const [employees, { refetch }] = createResource(loadEmployees);
const [roles] = createResource(loadRoles);
// tabs: list | create
const [view, setView] = createSignal<'list' | 'create'>('list');
// create form fields
const [formName, setFormName] = createSignal('');
const [formEmail, setFormEmail] = createSignal('');
const [formPassword, setFormPassword] = createSignal('');
const [formRoleId, setFormRoleId] = createSignal('');
const [creating, setCreating] = createSignal(false);
const [createError, setCreateError] = createSignal('');
const [createSuccess, setCreateSuccess] = createSignal('');
// row actions
const [deleting, setDeleting] = createSignal('');
const [toggling, setToggling] = createSignal('');
const [actionError, setActionError] = createSignal('');
const resetCreateForm = () => {
setFormName('');
setFormEmail('');
setFormPassword('');
setFormRoleId('');
setCreateError('');
setCreateSuccess('');
};
// ---------- CREATE ----------
const handleCreate = async (e: Event) => {
e.preventDefault();
if (!formName().trim() || !formEmail().trim() || !formPassword().trim()) return;
setCreating(true);
setCreateError('');
setCreateSuccess('');
try {
const res = await fetch(`${API}/api/admin/employees`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formName().trim(),
email: formEmail().trim(),
password: formPassword(),
role_id: formRoleId() || undefined,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as any).message || 'Failed to create');
}
setCreateSuccess('Internal user created successfully.');
resetCreateForm();
setView('list');
refetch();
} catch (err: any) {
setCreateError(err.message || 'Failed to create internal user');
} finally {
setCreating(false);
}
};
// ---------- TOGGLE STATUS ----------
const handleToggleStatus = async (id: string, current: boolean) => {
setToggling(id);
setActionError('');
try {
const res = await fetch(`${API}/api/admin/employees/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !current }),
});
if (!res.ok) throw new Error('Failed to update status');
refetch();
} catch (err: any) {
setActionError(err.message || 'Failed to update status');
} finally {
setToggling('');
}
};
// ---------- DELETE ----------
const handleDelete = async (id: string, name: string) => {
if (!confirm(`Delete internal user "${name}"?`)) return;
setDeleting(id);
setActionError('');
try {
const res = await fetch(`${API}/api/admin/employees/${id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error('Failed to delete');
refetch();
} catch (err: any) {
setActionError(err.message || 'Failed to delete internal user');
} finally {
setDeleting('');
}
};
return (
<AdminShell>
{/* Header */}
<div class="page-actions">
<div>
<h1 class="page-title">Internal User Management</h1>
</div>
</div>
{/* Tabs */}
<div style="display:flex;border-bottom:2px solid #e2e8f0;margin-bottom:24px;gap:0;overflow-x:auto;">
<button
class={`admin-tab${view() === 'list' ? ' active' : ''}`}
onClick={() => setView('list')}
>
View Internal Users
</button>
<button
class={`admin-tab${view() === 'create' ? ' active' : ''}`}
onClick={() => {
resetCreateForm();
setView('create');
}}
>
Add Internal User
</button>
</div>
{/* Success notice (shown briefly after create) */}
<Show when={createSuccess()}>
<div class="notice" style="margin-bottom:12px">{createSuccess()}</div>
</Show>
{/* ===== LIST VIEW ===== */}
<Show when={view() === 'list'}>
<Show when={actionError()}>
<div class="error-box" style="margin-bottom:12px">{actionError()}</div>
</Show>
<section class="card" style="padding:0;overflow:hidden">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={employees.loading}>
<tr>
<td colspan="5" style="text-align:center;padding:32px;color:#64748b">
Loading...
</td>
</tr>
</Show>
<Show when={!employees.loading && employees.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={!employees.loading && !employees.error && (employees()?.length ?? 0) === 0}>
<tr>
<td colspan="5" style="text-align:center;padding:32px;color:#94a3b8">
No internal users found.
</td>
</tr>
</Show>
<Show when={!employees.loading && !employees.error && (employees()?.length ?? 0) > 0}>
<For each={employees()}>
{(item) => (
<tr>
<td style="font-weight:600;color:#0f172a">{employeeName(item)}</td>
<td style="color:#475569">{item.email}</td>
<td style="color:#475569">{roleName(item)}</td>
<td>
<span class={`status-chip${isActive(item) ? ' active' : ''}`}>
{isActive(item) ? 'ACTIVE' : 'INACTIVE'}
</span>
</td>
<td>
<div class="table-actions">
<A class="btn" href={`/admin/employees/${item.id}/edit`}>
Edit
</A>
<button
class={isActive(item) ? 'btn danger' : 'btn navy'}
disabled={toggling() === item.id}
onClick={() => handleToggleStatus(item.id, isActive(item))}
>
{toggling() === item.id ? '...' : isActive(item) ? 'Deactivate' : 'Activate'}
</button>
<button
class="btn danger"
disabled={deleting() === item.id}
onClick={() => handleDelete(item.id, employeeName(item))}
>
{deleting() === item.id ? '...' : 'Delete'}
</button>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</Show>
{/* ===== CREATE VIEW ===== */}
<Show when={view() === 'create'}>
<section class="card role-form-section">
<form onSubmit={handleCreate}>
<div class="field-grid-2">
<div class="field">
<label>Full Name *</label>
<input
type="text"
required
placeholder="e.g. John Doe"
value={formName()}
onInput={(e) => setFormName(e.currentTarget.value)}
/>
</div>
<div class="field">
<label>Email *</label>
<input
type="email"
required
placeholder="e.g. john@company.com"
value={formEmail()}
onInput={(e) => setFormEmail(e.currentTarget.value)}
/>
</div>
<div class="field">
<label>Password *</label>
<input
type="password"
required
placeholder="Set a password"
value={formPassword()}
onInput={(e) => setFormPassword(e.currentTarget.value)}
/>
</div>
<div class="field">
<label>Role</label>
<select
value={formRoleId()}
onChange={(e) => setFormRoleId(e.currentTarget.value)}
>
<option value="">Select a role...</option>
<Show when={roles.loading}>
<option disabled>Loading roles...</option>
</Show>
<For each={roles() ?? []}>
{(r) => <option value={r.id}>{r.name}</option>}
</For>
</select>
</div>
</div>
<Show when={createError()}>
<p class="error-box" style="margin-top:10px">{createError()}</p>
</Show>
<div class="actions" style="margin-top:16px">
<button
type="button"
class="btn"
onClick={() => {
resetCreateForm();
setView('list');
}}
>
Cancel
</button>
<button type="submit" class="btn navy" disabled={creating()}>
{creating() ? 'Creating...' : 'Create Internal User'}
</button>
</div>
</form>
</section>
</Show>
</AdminShell>
);
}

View file

@ -0,0 +1,451 @@
import { createMemo, createSignal, For, onMount, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
// ---------- Types ----------
type SidebarItem = { key: string; label: string; moduleKey: string; visible: boolean; order: number };
type Module = { key: string; title: string; kind: 'list' | 'detail' | 'form'; summary: string; visible: boolean };
type Dashboard = { id: string; roleKey: string; title: string; description?: string; defaultRoute: string; status: string; version: number; sidebar: SidebarItem[]; modules: Module[] };
const makeId = (prefix: string) => `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
// ---------- Preview ----------
function renderModuleContent(module: Module | null) {
if (!module) return (
<div style="border:1px dashed #cbd5e1;border-radius:12px;padding:32px;text-align:center;font-size:13px;color:#94a3b8">
Select a sidebar item to preview the module.
</div>
);
if (module.kind === 'detail') return (
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div style="border:1px solid #e2e8f0;border-radius:12px;background:#fff;padding:16px">
<p style="margin:0 0 12px;font-size:11px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:#64748b">Primary Details</p>
{[['Full name', 'Sample User'], ['Role', module.title], ['Status', 'Active']].map(([label, val]) => (
<div style="margin-bottom:10px">
<p style="margin:0;font-size:11px;color:#64748b">{label}</p>
<p style="margin:2px 0 0;font-size:13px;font-weight:600;color:#0f172a">{val}</p>
</div>
))}
</div>
<div style="border:1px solid #e2e8f0;border-radius:12px;background:#fff;padding:16px">
<p style="margin:0 0 8px;font-size:11px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:#64748b">Summary</p>
<p style="margin:0;font-size:13px;color:#475569">{module.summary || 'This detail module shows the full profile or verification information.'}</p>
</div>
</div>
);
if (module.kind === 'form') return (
<div style="border:1px solid #e2e8f0;border-radius:12px;background:#fff;padding:20px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
{[['Subject', 'Enter subject', false], ['Category', 'Select category', true], ['Priority', 'Select priority', true]].map(([label, ph, isSelect]) => (
<div class="preview-field">
<label>{label}</label>
{isSelect ? <select><option>{ph}</option></select> : <input placeholder={ph as string} />}
</div>
))}
</div>
<div class="preview-field" style="margin-top:12px">
<label>Description</label>
<textarea rows={4} placeholder="Describe the request" style="width:100%;border:1px solid #cbd5e1;border-radius:12px;padding:10px 12px;font-size:13px;outline:none" />
</div>
<div style="display:flex;justify-content:flex-end;margin-top:12px">
<button class="btn navy" type="button">Submit</button>
</div>
</div>
);
// default: list
return (
<div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:16px">
{[['Open Items', '18'], ['Pending', '6'], ['Completed', '12']].map(([label, val]) => (
<div style="border:1px solid #e2e8f0;border-radius:12px;background:#fff;padding:16px">
<p style="margin:0;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.12em;color:#64748b">{label}</p>
<p style="margin:6px 0 0;font-size:22px;font-weight:700;color:#050026">{val}</p>
</div>
))}
</div>
<div style="border:1px solid #e2e8f0;border-radius:12px;overflow:hidden;background:#fff">
<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead style="background:#f8fafc">
<tr>
{['Item', 'Status', 'Updated'].map((h) => <th style="padding:10px 14px;text-align:left;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:#64748b">{h}</th>)}
</tr>
</thead>
<tbody>
{[['Request 001', 'Pending', '2h ago'], ['Request 002', 'In progress', 'Today'], ['Request 003', 'Closed', 'Yesterday']].map(([name, status, updated]) => (
<tr style="border-top:1px solid #e2e8f0">
<td style="padding:12px 14px;font-weight:600;color:#0f172a">{name}</td>
<td style="padding:12px 14px;color:#475569">{status}</td>
<td style="padding:12px 14px;color:#475569">{updated}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function ExternalDashboardPreview(props: { dashboard: Dashboard }) {
const visible = createMemo(() => [...props.dashboard.sidebar].filter((i) => i.visible).sort((a, b) => a.order - b.order));
const [selectedKey, setSelectedKey] = createSignal('');
const moduleMap = createMemo(() => new Map(props.dashboard.modules.map((m) => [m.key, m])));
const selectedModule = createMemo(() => {
const item = visible().find((i) => i.moduleKey === selectedKey());
return item ? moduleMap().get(item.moduleKey) || null : null;
});
const selectedLabel = createMemo(() => visible().find((i) => i.moduleKey === selectedKey())?.label || 'Module Preview');
return (
<div class="preview-shell">
<div class="preview-header">
<div>
<h3 style="margin:0;font-size:17px;font-weight:700;color:#050026">{props.dashboard.title}</h3>
<p style="margin:2px 0 0;font-size:13px;color:#64748b">{props.dashboard.roleKey}</p>
</div>
<div style="width:36px;height:36px;border-radius:999px;background:#fff1e8;display:flex;align-items:center;justify-content:center;color:#c2410c;font-weight:700;font-size:14px">A</div>
</div>
<div class="preview-layout">
<aside class="preview-sidebar">
{visible().map((item) => (
<button type="button" class={`preview-sidebar-item ${item.moduleKey === selectedKey() ? 'active' : ''}`} onClick={() => setSelectedKey(item.moduleKey)}>
<span style="width:16px;height:16px;border-radius:4px;background:#cbd5e1;flex-shrink:0;display:inline-block" />
{item.label}
</button>
))}
<Show when={visible().length === 0}>
<p style="font-size:12px;color:#94a3b8;padding:8px">No sidebar items added yet.</p>
</Show>
</aside>
<div class="preview-content">
<p style="margin:0;font-size:11px;font-weight:700;letter-spacing:.18em;text-transform:uppercase;color:#fd6216">Workspace Preview</p>
<h4 style="margin:4px 0 4px;font-size:22px;font-weight:700;color:#050026">{selectedLabel()}</h4>
<p style="margin:0 0 16px;font-size:13px;color:#64748b">{selectedModule()?.summary || props.dashboard.description || 'Preview the external dashboard experience here.'}</p>
{renderModuleContent(selectedModule())}
</div>
</div>
</div>
);
}
// ---------- Main Page ----------
export default function ExternalDashboardManagementPage() {
const [dashboards, setDashboards] = createSignal<Dashboard[]>([]);
const [selectedId, setSelectedId] = createSignal('');
const [activeTab, setActiveTab] = createSignal<'overview' | 'sidebar' | 'modules' | 'preview'>('overview');
const [loading, setLoading] = createSignal(true);
const [saving, setSaving] = createSignal(false);
const [creating, setCreating] = createSignal(false);
const [error, setError] = createSignal('');
onMount(loadDashboards);
async function loadDashboards() {
try {
setLoading(true);
setError('');
const res = await fetch(`${API}/api/admin/dashboard-config?audience=EXTERNAL`);
if (!res.ok) throw new Error('Failed to load external dashboards');
const data = await res.json();
const rows = (Array.isArray(data) ? data : (data.dashboards || [])).map((item: any) => ({
id: item.id,
roleKey: item.config_json?.roleKey || item.role_key || '',
title: item.config_json?.title || 'Untitled Dashboard',
description: item.config_json?.description || '',
defaultRoute: item.config_json?.defaultRoute || '/workspace',
status: item.is_active ? 'published' : 'draft',
version: item.config_json?.version || 1,
sidebar: item.config_json?.sidebar || [],
modules: item.config_json?.modules || [],
}));
setDashboards(rows);
} catch (err: any) {
setError(err.message || 'Failed to load dashboards');
} finally {
setLoading(false);
}
}
const selected = () => dashboards().find((d) => d.id === selectedId()) || null;
const update = (patch: Partial<Dashboard>) =>
setDashboards((prev) => prev.map((d) => d.id === selectedId() ? { ...d, ...patch } : d));
const addSidebarItem = () => {
const d = selected()!;
const nextIdx = d.sidebar.length + 1;
const fallbackKey = `module_${nextIdx}`;
const moduleKey = d.modules[nextIdx - 1]?.key || d.modules[0]?.key || fallbackKey;
const hasModule = d.modules.some((m) => m.key === moduleKey);
const nextModules = hasModule ? d.modules : [...d.modules, { key: moduleKey, title: `New Module ${d.modules.length + 1}`, kind: 'list' as const, summary: 'Describe what this module does.', visible: true }];
update({ modules: nextModules, sidebar: [...d.sidebar, { key: makeId('sb'), label: 'New Sidebar Item', moduleKey, visible: true, order: nextIdx }] });
};
const removeSidebarItem = (key: string) =>
update({ sidebar: selected()!.sidebar.filter((i) => i.key !== key).map((i, idx) => ({ ...i, order: idx + 1 })) });
const addModule = () => {
const d = selected()!;
const nextIdx = d.modules.length + 1;
update({ modules: [...d.modules, { key: makeId('mod'), title: `New Module ${nextIdx}`, kind: 'list', summary: 'Describe what this module does.', visible: true }] });
};
const removeModule = (key: string) =>
update({ modules: selected()!.modules.filter((m) => m.key !== key), sidebar: selected()!.sidebar.filter((i) => i.moduleKey !== key) });
const createDashboard = async () => {
try {
setCreating(true);
setError('');
let newId = makeId('local');
try {
const res = await fetch(`${API}/api/admin/dashboard-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ audience: 'EXTERNAL', config_json: { roleKey: '', title: 'New External Dashboard', defaultRoute: '/workspace', version: 1, sidebar: [], modules: [] } }),
});
if (res.ok) {
const data = await res.json();
newId = data.id;
}
// If POST fails, fall through to local draft mode silently
} catch { /* backend unavailable — use local draft */ }
const nd: Dashboard = { id: newId, roleKey: '', title: 'New External Dashboard', description: '', defaultRoute: '/workspace', status: 'draft', version: 1, sidebar: [], modules: [] };
setDashboards((prev) => [nd, ...prev]);
setSelectedId(newId);
setActiveTab('overview');
} finally {
setCreating(false);
}
};
const saveSelected = async () => {
const d = selected();
if (!d) return;
try {
setSaving(true);
setError('');
const res = await fetch(`${API}/api/admin/dashboard-config/${d.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config_json: { roleKey: d.roleKey, title: d.title, description: d.description, defaultRoute: d.defaultRoute, version: d.version, sidebar: d.sidebar, modules: d.modules } }),
});
if (!res.ok) throw new Error('Failed to save dashboard');
} catch (err: any) {
setError(err.message || 'Failed to save dashboard');
} finally {
setSaving(false);
}
};
return (
<AdminShell>
{/* ---------- List View ---------- */}
<Show when={!selected()}>
<div class="page-actions">
<div>
<h1 class="page-title">External Dashboard Management</h1>
<p class="page-subtitle">Build and manage external role dashboards sidebar labels, modules, and default routes.</p>
</div>
<button class="btn navy" onClick={createDashboard} disabled={creating()}>
{creating() ? 'Creating...' : 'Create External Dashboard'}
</button>
</div>
<Show when={error()}><div class="error-box">{error()}</div></Show>
<section class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Role</th>
<th>Dashboard</th>
<th>Status</th>
<th>Version</th>
<th>Modules</th>
<th class="align-right">Action</th>
</tr>
</thead>
<tbody>
<Show when={loading()}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#64748b">Loading external dashboards...</td></tr>
</Show>
<Show when={!loading() && dashboards().length === 0}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#94a3b8">No external dashboards found. Create the first one.</td></tr>
</Show>
<For each={dashboards()}>
{(d) => (
<tr>
<td style="font-weight:600;color:#0f172a">{d.roleKey || 'No role key'}</td>
<td style="color:#475569">{d.title}</td>
<td><span class={`status-chip ${d.status === 'published' ? 'active' : ''}`}>{d.status}</span></td>
<td style="color:#64748b">v{d.version}</td>
<td style="color:#64748b">{d.modules.length} modules</td>
<td>
<div class="table-actions">
<button class="btn" onClick={() => { setSelectedId(d.id); setActiveTab('overview'); }}>View Builder</button>
</div>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</section>
</Show>
{/* ---------- Builder View ---------- */}
<Show when={selected()}>
<div class="builder-header">
<div>
<h2>External Dashboard Builder</h2>
<p>Edit sidebar labels, visibility, module titles, and default route for this role dashboard.</p>
</div>
<div class="builder-header-actions">
<button class="btn" onClick={() => setSelectedId('')}>Back to List</button>
<button class="btn navy" onClick={saveSelected} disabled={saving()}>
{saving() ? 'Saving...' : 'Save Dashboard'}
</button>
</div>
</div>
<Show when={error()}><div class="error-box">{error()}</div></Show>
{/* Tab bar */}
<div class="builder-tab-bar">
{(['overview', 'sidebar', 'modules', 'preview'] as const).map((t) => (
<button type="button" class={`builder-tab-btn ${activeTab() === t ? 'active' : ''}`} onClick={() => setActiveTab(t)}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
{/* Overview */}
<Show when={activeTab() === 'overview'}>
<div class="card">
<div class="field">
<label>Role Key</label>
<input value={selected()!.roleKey} onInput={(e) => update({ roleKey: e.currentTarget.value.toUpperCase().replace(/\s+/g, '_') })} placeholder="e.g. PHOTOGRAPHER" />
</div>
<div class="field">
<label>Dashboard Title</label>
<input value={selected()!.title} onInput={(e) => update({ title: e.currentTarget.value })} />
</div>
<div class="field">
<label>Description</label>
<textarea rows={3} value={selected()!.description || ''} onInput={(e) => update({ description: e.currentTarget.value })} />
</div>
<div class="field">
<label>Default Route</label>
<input value={selected()!.defaultRoute} onInput={(e) => update({ defaultRoute: e.currentTarget.value })} placeholder="/workspace" />
</div>
</div>
</Show>
{/* Sidebar */}
<Show when={activeTab() === 'sidebar'}>
<div class="builder-section">
<div class="sub-card-header">
<h4>Sidebar Items</h4>
<button class="btn orange" onClick={addSidebarItem}>Add Sidebar Item</button>
</div>
<For each={selected()!.sidebar}>
{(item, idx) => (
<div class="builder-item" style="grid-template-columns:1fr 1fr 80px auto auto">
<div>
<input
value={item.label}
placeholder="Sidebar label"
onInput={(e) => update({ sidebar: selected()!.sidebar.map((i) => i.key === item.key ? { ...i, label: e.currentTarget.value } : i) })}
style="width:100%;margin-bottom:6px"
/>
<input
value={item.moduleKey}
placeholder="Module key"
onInput={(e) => update({ sidebar: selected()!.sidebar.map((i) => i.key === item.key ? { ...i, moduleKey: e.currentTarget.value } : i) })}
style="width:100%"
/>
</div>
<input
type="number"
value={item.order}
onInput={(e) => update({ sidebar: selected()!.sidebar.map((i) => i.key === item.key ? { ...i, order: Number(e.currentTarget.value) || idx() + 1 } : i) })}
/>
<label class="checkbox-label" style="justify-content:center">
<input
type="checkbox"
checked={item.visible}
onChange={(e) => update({ sidebar: selected()!.sidebar.map((i) => i.key === item.key ? { ...i, visible: e.currentTarget.checked } : i) })}
/>
Show
</label>
<button class="btn danger" onClick={() => removeSidebarItem(item.key)}>Remove</button>
</div>
)}
</For>
<Show when={selected()!.sidebar.length === 0}>
<p class="notice">No sidebar items yet. Add the first item above.</p>
</Show>
</div>
</Show>
{/* Modules */}
<Show when={activeTab() === 'modules'}>
<div class="builder-section">
<div class="sub-card-header">
<h4>Modules</h4>
<button class="btn orange" onClick={addModule}>Add Module</button>
</div>
<For each={selected()!.modules}>
{(module) => (
<div class="nested-card">
<div class="field">
<label>Module Key</label>
<input
value={module.key}
onInput={(e) => {
const newKey = e.currentTarget.value;
update({
modules: selected()!.modules.map((m) => m.key === module.key ? { ...m, key: newKey } : m),
sidebar: selected()!.sidebar.map((i) => i.moduleKey === module.key ? { ...i, moduleKey: newKey } : i),
});
}}
placeholder="module_key"
/>
</div>
<div class="field">
<label>Title</label>
<input value={module.title} onInput={(e) => update({ modules: selected()!.modules.map((m) => m.key === module.key ? { ...m, title: e.currentTarget.value } : m) })} />
</div>
<div class="field">
<label>Kind</label>
<select value={module.kind} onChange={(e) => update({ modules: selected()!.modules.map((m) => m.key === module.key ? { ...m, kind: e.currentTarget.value as Module['kind'] } : m) })}>
<option value="list">list</option>
<option value="detail">detail</option>
<option value="form">form</option>
</select>
</div>
<div class="field">
<label>Summary</label>
<textarea rows={2} value={module.summary} onInput={(e) => update({ modules: selected()!.modules.map((m) => m.key === module.key ? { ...m, summary: e.currentTarget.value } : m) })} />
</div>
<div style="display:flex;justify-content:flex-end">
<button class="btn danger" onClick={() => removeModule(module.key)}>Remove Module</button>
</div>
</div>
)}
</For>
<Show when={selected()!.modules.length === 0}>
<p class="notice">No modules yet. Add the first module above.</p>
</Show>
</div>
</Show>
{/* Preview */}
<Show when={activeTab() === 'preview'}>
<p class="notice" style="margin-bottom:12px">Preview the actual external workspace layout navigate sidebar and modules before saving.</p>
<ExternalDashboardPreview dashboard={selected()!} />
</Show>
</Show>
</AdminShell>
);
}

View file

@ -0,0 +1,124 @@
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 [];
}
}
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="page-actions">
<div>
<h1 class="page-title">Fitness Trainer Management</h1>
<p class="page-subtitle">Manage all fitness trainer accounts on the platform.</p>
</div>
</div>
<section class="card" style="padding: 0; overflow: hidden;">
<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="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Registered</th>
<th class="align-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>
<td style="font-weight:600;color:#0f172a">{item.name || item.full_name || '—'}</td>
<td style="color:#475569">{item.email}</td>
<td>
{item.status?.toUpperCase() === 'ACTIVE' && (
<span class="status-chip active">ACTIVE</span>
)}
{item.status?.toUpperCase() === 'INACTIVE' && (
<span class="status-chip">INACTIVE</span>
)}
{item.status?.toUpperCase() === 'PENDING' && (
<span class="status-chip" style="background:#fff7ed;color:#c2410c;border-color:#fed7aa;">PENDING</span>
)}
{!item.status && <span class="status-chip"></span>}
</td>
<td style="color:#475569">
{item.created_at ? new Date(item.created_at).toLocaleDateString() : '—'}
</td>
<td>
<div class="table-actions">
<A class="btn" href={`/admin/users/${item.id}`}>View</A>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</AdminShell>
);
}

View file

@ -0,0 +1,124 @@
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 [];
}
}
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="page-actions">
<div>
<h1 class="page-title">Graphic Designer Management</h1>
<p class="page-subtitle">Manage all graphic designer accounts on the platform.</p>
</div>
</div>
<section class="card" style="padding: 0; overflow: hidden;">
<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="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Registered</th>
<th class="align-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>
<td style="font-weight:600;color:#0f172a">{item.name || item.full_name || '—'}</td>
<td style="color:#475569">{item.email}</td>
<td>
{item.status?.toUpperCase() === 'ACTIVE' && (
<span class="status-chip active">ACTIVE</span>
)}
{item.status?.toUpperCase() === 'INACTIVE' && (
<span class="status-chip">INACTIVE</span>
)}
{item.status?.toUpperCase() === 'PENDING' && (
<span class="status-chip" style="background:#fff7ed;color:#c2410c;border-color:#fed7aa;">PENDING</span>
)}
{!item.status && <span class="status-chip"></span>}
</td>
<td style="color:#475569">
{item.created_at ? new Date(item.created_at).toLocaleDateString() : '—'}
</td>
<td>
<div class="table-actions">
<A class="btn" href={`/admin/users/${item.id}`}>View</A>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</AdminShell>
);
}

View file

@ -0,0 +1,556 @@
import { createMemo, createSignal, For, onMount, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
// ---------- Types ----------
type SidebarItem = { key: string; label: string; visible: boolean; order: number };
type Field = { id: string; label: string; type: 'text' | 'number' | 'select' | 'date'; required: boolean; placeholder?: string };
type Tab = { id: string; title: string; fields: Field[] };
type Widget = { id: string; title: string; metric: string; description?: string };
type Section = { id: string; title: string; tabs: Tab[]; widgets: Widget[] };
type Dashboard = { id: string; roleId: string; roleName: string; title: string; description?: string; status: string; version: number; sidebar: SidebarItem[]; sections: Section[] };
type InternalRole = { id: string; name: string };
const FIELD_TYPES: Field['type'][] = ['text', 'number', 'select', 'date'];
const makeId = (prefix: string) => `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
// ---------- Preview ----------
function PreviewSection(props: { section: Section }) {
const [activeTabId, setActiveTabId] = createSignal(props.section.tabs[0]?.id || '');
const activeTab = createMemo(() => props.section.tabs.find((t) => t.id === activeTabId()) || props.section.tabs[0] || null);
return (
<div class="preview-section">
<h5 style="margin:0 0 4px;font-size:17px;font-weight:700;color:#050026">{props.section.title}</h5>
<p style="margin:0;font-size:13px;color:#64748b">Preview tabs, fields, and widgets.</p>
<Show when={props.section.tabs.length > 0}>
<div class="preview-tabs">
{props.section.tabs.map((tab) => (
<button type="button" class={`preview-tab-btn ${activeTabId() === tab.id ? 'active' : ''}`} onClick={() => setActiveTabId(tab.id)}>
{tab.title}
</button>
))}
</div>
</Show>
<Show when={activeTab()}>
<Show when={activeTab()!.fields.length > 0}>
<div class="preview-fields-grid">
{activeTab()!.fields.map((field) => (
<div class="preview-field">
<label>{field.label}{field.required ? <span style="color:#fd6216"> *</span> : null}</label>
{field.type === 'select'
? <select><option>{field.placeholder || `Select ${field.label}`}</option><option>Option A</option><option>Option B</option></select>
: <input type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'} placeholder={field.placeholder || `Enter ${field.label}`} />}
</div>
))}
</div>
</Show>
<Show when={activeTab()!.fields.length === 0}>
<div style="margin-top:16px;border:1px dashed #cbd5e1;border-radius:12px;padding:24px;text-align:center;font-size:13px;color:#94a3b8">Add fields to this tab to preview.</div>
</Show>
</Show>
<Show when={props.section.widgets.length > 0}>
<div class="preview-widget-grid">
{props.section.widgets.map((w) => (
<div class="preview-widget">
<div class="w-label">{w.title}</div>
<div class="w-metric">{w.metric}</div>
<div class="w-desc">{w.description || 'Widget description'}</div>
</div>
))}
</div>
</Show>
</div>
);
}
function DashboardPreview(props: { dashboard: Dashboard }) {
const visibleSidebar = createMemo(() =>
[...props.dashboard.sidebar].filter((i) => i.visible).sort((a, b) => a.order - b.order)
);
const [activeSidebarKey, setActiveSidebarKey] = createSignal('');
const selectedLabel = createMemo(() => visibleSidebar().find((i) => i.key === activeSidebarKey())?.label || 'Overview');
return (
<div class="preview-shell">
<div class="preview-header">
<div>
<h3 style="margin:0;font-size:17px;font-weight:700;color:#050026">{props.dashboard.title}</h3>
<p style="margin:2px 0 0;font-size:13px;color:#64748b">{props.dashboard.roleName}</p>
</div>
<div style="display:flex;align-items:center;gap:12px">
<div style="width:36px;height:36px;border-radius:999px;background:#fff1e8;display:flex;align-items:center;justify-content:center;color:#c2410c;font-weight:700;font-size:14px">A</div>
</div>
</div>
<div class="preview-layout">
<aside class="preview-sidebar">
{visibleSidebar().map((item) => (
<button
type="button"
class={`preview-sidebar-item ${activeSidebarKey() === item.key ? 'active' : ''}`}
onClick={() => setActiveSidebarKey(item.key)}
>
<span style="width:16px;height:16px;border-radius:4px;background:#cbd5e1;flex-shrink:0;display:inline-block" />
{item.label}
</button>
))}
<Show when={visibleSidebar().length === 0}>
<p style="font-size:12px;color:#94a3b8;padding:8px">No sidebar items added yet.</p>
</Show>
</aside>
<div class="preview-content">
<p style="margin:0;font-size:11px;font-weight:700;letter-spacing:.18em;text-transform:uppercase;color:#fd6216">Dashboard Preview</p>
<h4 style="margin:4px 0 4px;font-size:22px;font-weight:700;color:#050026">{selectedLabel()}</h4>
<p style="margin:0 0 16px;font-size:13px;color:#64748b">{props.dashboard.description || 'Preview of the internal dashboard layout.'}</p>
<For each={props.dashboard.sections}>
{(section) => <PreviewSection section={section} />}
</For>
<Show when={props.dashboard.sections.length === 0}>
<div style="border:1px dashed #cbd5e1;border-radius:12px;padding:32px;text-align:center;font-size:13px;color:#94a3b8">Add sections, tabs, fields, and widgets to preview here.</div>
</Show>
</div>
</div>
</div>
);
}
// ---------- Main Page ----------
export default function InternalDashboardManagementPage() {
const [dashboards, setDashboards] = createSignal<Dashboard[]>([]);
const [roles, setRoles] = createSignal<InternalRole[]>([]);
const [selectedId, setSelectedId] = createSignal('');
const [activeTab, setActiveTab] = createSignal<'overview' | 'sidebar' | 'sections' | 'preview'>('overview');
const [loading, setLoading] = createSignal(true);
const [saving, setSaving] = createSignal(false);
const [creating, setCreating] = createSignal(false);
const [error, setError] = createSignal('');
onMount(async () => {
await Promise.all([loadDashboards(), loadRoles()]);
});
const loadDashboards = async () => {
try {
setLoading(true);
setError('');
const res = await fetch(`${API}/api/admin/dashboard-config?audience=INTERNAL`);
if (!res.ok) throw new Error('Failed to load internal dashboards');
const data = await res.json();
const rows = (Array.isArray(data) ? data : (data.dashboards || [])).map((item: any) => ({
id: item.id,
roleId: item.role_id || '',
roleName: item.config_json?.roleName || '',
title: item.config_json?.title || 'Untitled Dashboard',
description: item.config_json?.description || '',
status: item.is_active ? 'published' : 'draft',
version: item.config_json?.version || 1,
sidebar: item.config_json?.sidebar || [],
sections: item.config_json?.sections || [],
}));
setDashboards(rows);
} catch (err: any) {
setError(err.message || 'Failed to load dashboards');
} finally {
setLoading(false);
}
};
const loadRoles = async () => {
try {
const res = await fetch(`${API}/api/admin/roles?audience=INTERNAL`);
if (!res.ok) return;
const data = await res.json();
setRoles((Array.isArray(data) ? data : (data.roles || [])).map((r: any) => ({ id: r.id, name: r.name })));
} catch { setRoles([]); }
};
const selected = () => dashboards().find((d) => d.id === selectedId()) || null;
const update = (patch: Partial<Dashboard>) =>
setDashboards((prev) => prev.map((d) => d.id === selectedId() ? { ...d, ...patch } : d));
const updateSection = (sectionId: string, patch: Partial<Section>) =>
update({ sections: selected()!.sections.map((s) => s.id === sectionId ? { ...s, ...patch } : s) });
const updateTab = (sectionId: string, tabId: string, patch: Partial<Tab>) => {
const section = selected()!.sections.find((s) => s.id === sectionId)!;
updateSection(sectionId, { tabs: section.tabs.map((t) => t.id === tabId ? { ...t, ...patch } : t) });
};
const updateField = (sId: string, tId: string, fId: string, patch: Partial<Field>) => {
const tab = selected()!.sections.find((s) => s.id === sId)!.tabs.find((t) => t.id === tId)!;
updateTab(sId, tId, { fields: tab.fields.map((f) => f.id === fId ? { ...f, ...patch } : f) });
};
const updateWidget = (sId: string, wId: string, patch: Partial<Widget>) => {
const section = selected()!.sections.find((s) => s.id === sId)!;
updateSection(sId, { widgets: section.widgets.map((w) => w.id === wId ? { ...w, ...patch } : w) });
};
const addSidebarItem = () => {
const items = selected()!.sidebar;
update({ sidebar: [...items, { key: makeId('sb'), label: 'New Sidebar Item', visible: true, order: items.length + 1 }] });
};
const removeSidebarItem = (key: string) =>
update({ sidebar: selected()!.sidebar.filter((i) => i.key !== key).map((i, idx) => ({ ...i, order: idx + 1 })) });
const addSection = () =>
update({ sections: [...selected()!.sections, { id: makeId('sec'), title: 'New Section', tabs: [], widgets: [] }] });
const removeSection = (id: string) =>
update({ sections: selected()!.sections.filter((s) => s.id !== id) });
const addTab = (sId: string) => {
const section = selected()!.sections.find((s) => s.id === sId)!;
updateSection(sId, { tabs: [...section.tabs, { id: makeId('tab'), title: 'New Tab', fields: [] }] });
};
const removeTab = (sId: string, tId: string) => {
const section = selected()!.sections.find((s) => s.id === sId)!;
updateSection(sId, { tabs: section.tabs.filter((t) => t.id !== tId) });
};
const addField = (sId: string, tId: string) => {
const tab = selected()!.sections.find((s) => s.id === sId)!.tabs.find((t) => t.id === tId)!;
updateTab(sId, tId, { fields: [...tab.fields, { id: makeId('fld'), label: 'New Field', type: 'text', required: false, placeholder: 'Enter value' }] });
};
const removeField = (sId: string, tId: string, fId: string) => {
const tab = selected()!.sections.find((s) => s.id === sId)!.tabs.find((t) => t.id === tId)!;
updateTab(sId, tId, { fields: tab.fields.filter((f) => f.id !== fId) });
};
const addWidget = (sId: string) => {
const section = selected()!.sections.find((s) => s.id === sId)!;
updateSection(sId, { widgets: [...section.widgets, { id: makeId('wgt'), title: 'New Widget', metric: '0', description: 'Describe this widget.' }] });
};
const removeWidget = (sId: string, wId: string) => {
const section = selected()!.sections.find((s) => s.id === sId)!;
updateSection(sId, { widgets: section.widgets.filter((w) => w.id !== wId) });
};
const createDashboard = async () => {
try {
setCreating(true);
setError('');
let newId = makeId('local');
try {
const res = await fetch(`${API}/api/admin/dashboard-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ audience: 'INTERNAL', config_json: { title: 'New Internal Dashboard', version: 1, sidebar: [], sections: [] } }),
});
if (res.ok) {
const data = await res.json();
newId = data.id;
}
// If POST fails, fall through to local draft mode silently
} catch { /* backend unavailable — use local draft */ }
const nd: Dashboard = { id: newId, roleId: '', roleName: '', title: 'New Internal Dashboard', status: 'draft', version: 1, sidebar: [], sections: [] };
setDashboards((prev) => [nd, ...prev]);
setSelectedId(newId);
setActiveTab('overview');
} finally {
setCreating(false);
}
};
const saveSelected = async () => {
const d = selected();
if (!d) return;
try {
setSaving(true);
setError('');
const res = await fetch(`${API}/api/admin/dashboard-config/${d.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role_id: d.roleId || null, config_json: { title: d.title, description: d.description, roleName: d.roleName, version: d.version, sidebar: d.sidebar, sections: d.sections } }),
});
if (!res.ok) throw new Error('Failed to save dashboard');
} catch (err: any) {
setError(err.message || 'Failed to save dashboard');
} finally {
setSaving(false);
}
};
// ---------- List view ----------
return (
<AdminShell>
<Show when={!selected()}>
<div class="page-actions">
<div>
<h1 class="page-title">Internal Dashboard Management</h1>
<p class="page-subtitle">Build and manage internal role dashboards sidebar, sections, tabs, fields, and widgets.</p>
</div>
<button class="btn navy" onClick={createDashboard} disabled={creating()}>
{creating() ? 'Creating...' : 'Create Internal Dashboard'}
</button>
</div>
<Show when={error()}><div class="error-box">{error()}</div></Show>
<section class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Role</th>
<th>Dashboard</th>
<th>Status</th>
<th>Version</th>
<th class="align-right">Action</th>
</tr>
</thead>
<tbody>
<Show when={loading()}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#64748b">Loading internal dashboards...</td></tr>
</Show>
<Show when={!loading() && dashboards().length === 0}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#94a3b8">No internal dashboards found. Create the first one.</td></tr>
</Show>
<For each={dashboards()}>
{(d) => (
<tr>
<td style="color:#475569">{d.roleName || 'Not linked'}</td>
<td style="font-weight:600;color:#0f172a">{d.title}</td>
<td><span class={`status-chip ${d.status === 'published' ? 'active' : ''}`}>{d.status}</span></td>
<td style="color:#64748b">v{d.version}</td>
<td>
<div class="table-actions">
<button class="btn" onClick={() => { setSelectedId(d.id); setActiveTab('overview'); }}>View Builder</button>
</div>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</section>
</Show>
{/* ---------- Builder view ---------- */}
<Show when={selected()}>
<div class="builder-header">
<div>
<h2>Internal Dashboard Builder</h2>
<p>Manage sidebar, sections, tabs, fields, and widgets from one place.</p>
</div>
<div class="builder-header-actions">
<button class="btn" onClick={() => setSelectedId('')}>Back to List</button>
<button class="btn navy" onClick={saveSelected} disabled={saving()}>
{saving() ? 'Saving...' : 'Save Dashboard'}
</button>
</div>
</div>
<Show when={error()}><div class="error-box">{error()}</div></Show>
{/* Tab bar */}
<div class="builder-tab-bar">
{(['overview', 'sidebar', 'sections', 'preview'] as const).map((t) => (
<button
type="button"
class={`builder-tab-btn ${activeTab() === t ? 'active' : ''}`}
onClick={() => setActiveTab(t)}
>
{t === 'sections' ? 'Sections, Tabs & Fields' : t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
{/* Overview */}
<Show when={activeTab() === 'overview'}>
<div class="field-grid-2">
<div class="field">
<label>Internal Role</label>
<select
value={selected()!.roleId}
onChange={(e) => {
const role = roles().find((r) => r.id === e.currentTarget.value);
update({ roleId: e.currentTarget.value, roleName: role?.name || '', title: role ? `${role.name} Dashboard` : selected()!.title });
}}
>
<option value="">Select internal role</option>
{roles().map((r) => <option value={r.id}>{r.name}</option>)}
</select>
</div>
<div class="field">
<label>Dashboard Title</label>
<input value={selected()!.title} onInput={(e) => update({ title: e.currentTarget.value })} />
</div>
<div class="field">
<label>Description</label>
<input value={selected()!.description || ''} onInput={(e) => update({ description: e.currentTarget.value })} />
</div>
<div class="info-box">
<p style="margin:0 0 4px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:#64748b">Linked Role</p>
<p style="margin:0;font-weight:600;color:#0f172a">{selected()!.roleName || 'No role selected yet'}</p>
<p style="margin:2px 0 0;font-size:12px;color:#64748b">{selected()!.roleId || 'Select an internal role to bind this dashboard.'}</p>
</div>
</div>
</Show>
{/* Sidebar */}
<Show when={activeTab() === 'sidebar'}>
<div class="builder-section">
<div class="sub-card-header">
<h4>Internal Sidebar</h4>
<button class="btn orange" onClick={addSidebarItem}>Add Sidebar Item</button>
</div>
<For each={selected()!.sidebar}>
{(item, idx) => (
<div class="builder-item builder-item-row-4">
<input
value={item.label}
placeholder="Sidebar label"
onInput={(e) => update({ sidebar: selected()!.sidebar.map((i) => i.key === item.key ? { ...i, label: e.currentTarget.value } : i) })}
/>
<input
type="number"
value={item.order}
onInput={(e) => update({ sidebar: selected()!.sidebar.map((i) => i.key === item.key ? { ...i, order: Number(e.currentTarget.value) || idx() + 1 } : i) })}
style="width:80px"
/>
<label class="checkbox-label" style="justify-content:center">
<input
type="checkbox"
checked={item.visible}
onChange={(e) => update({ sidebar: selected()!.sidebar.map((i) => i.key === item.key ? { ...i, visible: e.currentTarget.checked } : i) })}
/>
Show
</label>
<button class="btn danger" onClick={() => removeSidebarItem(item.key)}>Remove</button>
</div>
)}
</For>
<Show when={selected()!.sidebar.length === 0}>
<p class="notice">No sidebar items yet. Add the first item.</p>
</Show>
</div>
</Show>
{/* Sections, Tabs & Fields */}
<Show when={activeTab() === 'sections'}>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<h3 style="margin:0;font-size:15px;font-weight:700">Sections</h3>
<button class="btn orange" onClick={addSection}>Add Section</button>
</div>
<For each={selected()!.sections}>
{(section) => (
<div class="builder-section">
<div class="builder-section-header">
<input
value={section.title}
onInput={(e) => updateSection(section.id, { title: e.currentTarget.value })}
placeholder="Section title"
/>
<button class="btn danger" onClick={() => removeSection(section.id)}>Remove</button>
</div>
{/* Tabs */}
<div class="sub-card">
<div class="sub-card-header">
<h4>Tabs and Fields</h4>
<button class="btn orange" onClick={() => addTab(section.id)}>Add Tab</button>
</div>
<For each={section.tabs}>
{(tab) => (
<div class="nested-card">
<div class="nested-card-header">
<input
value={tab.title}
onInput={(e) => updateTab(section.id, tab.id, { title: e.currentTarget.value })}
placeholder="Tab title"
/>
<button class="btn danger" onClick={() => removeTab(section.id, tab.id)}>Remove</button>
</div>
<div style="display:flex;justify-content:flex-end;margin-bottom:8px">
<button class="btn orange" onClick={() => addField(section.id, tab.id)}>Add Field</button>
</div>
<For each={tab.fields}>
{(field) => (
<div class="field-row">
<div>
<input
value={field.label}
onInput={(e) => updateField(section.id, tab.id, field.id, { label: e.currentTarget.value })}
placeholder="Field label"
style="width:100%;margin-bottom:4px"
/>
<input
value={field.placeholder || ''}
onInput={(e) => updateField(section.id, tab.id, field.id, { placeholder: e.currentTarget.value })}
placeholder="Placeholder text"
style="width:100%"
/>
</div>
<select
value={field.type}
onChange={(e) => updateField(section.id, tab.id, field.id, { type: e.currentTarget.value as Field['type'] })}
>
{FIELD_TYPES.map((t) => <option value={t}>{t}</option>)}
</select>
<label class="checkbox-label" style="justify-content:center">
<input
type="checkbox"
checked={field.required}
onChange={(e) => updateField(section.id, tab.id, field.id, { required: e.currentTarget.checked })}
/>
Required
</label>
<button class="btn danger" onClick={() => removeField(section.id, tab.id, field.id)}>Remove</button>
</div>
)}
</For>
<Show when={tab.fields.length === 0}>
<p class="notice" style="text-align:center">No fields in this tab yet.</p>
</Show>
</div>
)}
</For>
<Show when={section.tabs.length === 0}>
<p class="notice">No tabs yet. Add a tab above.</p>
</Show>
</div>
{/* Widgets */}
<div class="sub-card" style="margin-top:8px">
<div class="sub-card-header">
<h4>Widgets</h4>
<button class="btn orange" onClick={() => addWidget(section.id)}>Add Widget</button>
</div>
<For each={section.widgets}>
{(widget) => (
<div class="widget-item">
<input value={widget.title} onInput={(e) => updateWidget(section.id, widget.id, { title: e.currentTarget.value })} placeholder="Widget title" />
<input value={widget.metric} onInput={(e) => updateWidget(section.id, widget.id, { metric: e.currentTarget.value })} placeholder="Metric value e.g. 42" />
<textarea rows={2} value={widget.description || ''} onInput={(e) => updateWidget(section.id, widget.id, { description: e.currentTarget.value })} placeholder="Widget description" />
<div style="display:flex;justify-content:flex-end">
<button class="btn danger" style="font-size:12px;padding:5px 10px" onClick={() => removeWidget(section.id, widget.id)}>Remove Widget</button>
</div>
</div>
)}
</For>
<Show when={section.widgets.length === 0}>
<p class="notice">No widgets yet.</p>
</Show>
</div>
</div>
)}
</For>
<Show when={selected()!.sections.length === 0}>
<div style="text-align:center;padding:32px;border:1px dashed #cbd5e1;border-radius:12px;color:#94a3b8;font-size:13px">No sections yet. Add the first section above.</div>
</Show>
</Show>
{/* Preview */}
<Show when={activeTab() === 'preview'}>
<p class="notice" style="margin-bottom:12px">Preview uses the actual internal dashboard layout so you can navigate the sidebar, tabs, fields, and widgets before saving.</p>
<DashboardPreview dashboard={selected()!} />
</Show>
</Show>
</AdminShell>
);
}

View file

@ -0,0 +1,118 @@
import { createResource, createSignal, createMemo, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
async function loadInvoices(): Promise<any[]> {
try {
const res = await fetch(`${API}/api/admin/invoices`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.invoices || []);
} catch {
return [];
}
}
export default function InvoicePage() {
const [invoices] = createResource(loadInvoices);
const [search, setSearch] = createSignal('');
const filteredInvoices = createMemo(() => {
const q = search().toLowerCase();
const all = invoices() ?? [];
if (!q) return all;
return all.filter((inv) =>
(inv.invoice_number || inv.id || '').toLowerCase().includes(q) ||
(inv.user_id || '').toLowerCase().includes(q) ||
(inv.package_name || '').toLowerCase().includes(q) ||
(inv.status || '').toLowerCase().includes(q)
);
});
return (
<AdminShell>
<div class="page-actions">
<div>
<h1 class="page-title">Invoice Management</h1>
<p class="page-subtitle">View and download all platform invoices.</p>
</div>
</div>
<div style="margin-bottom:16px">
<input
type="text"
placeholder="Search invoices by number, user, package, or status..."
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
style="padding:8px 12px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;min-width:320px"
/>
</div>
<section class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Invoice #</th>
<th>User</th>
<th>Package</th>
<th>Total ()</th>
<th>Tax ()</th>
<th>Status</th>
<th>Date</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={invoices.loading}>
<tr><td colspan="8" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!invoices.loading && invoices.error}>
<tr><td colspan="8" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
</Show>
<Show when={!invoices.loading && !invoices.error && filteredInvoices().length === 0}>
<tr><td colspan="8" style="text-align:center;padding:32px;color:#94a3b8">No records found.</td></tr>
</Show>
<Show when={!invoices.loading && !invoices.error && filteredInvoices().length > 0}>
{filteredInvoices().map((item) => (
<tr>
<td style="font-weight:600;color:#0f172a;font-family:monospace">{item.invoice_number || item.id}</td>
<td style="color:#475569">{item.user_id || '—'}</td>
<td style="color:#475569">{item.package_name || '—'}</td>
<td style="color:#475569">{item.total != null ? (item.total / 100).toFixed(2) : '—'}</td>
<td style="color:#475569">{item.tax != null ? (item.tax / 100).toFixed(2) : '—'}</td>
<td>
<span class={`status-chip ${item.status === 'PAID' || item.status === 'ISSUED' ? 'active' : ''}`}>
{item.status || '—'}
</span>
</td>
<td style="color:#475569">{item.created_at ? new Date(item.created_at).toLocaleString() : '—'}</td>
<td>
<div class="table-actions">
<Show when={item.download_url || item.pdf_url}>
<a
class="btn"
href={item.download_url || item.pdf_url}
target="_blank"
rel="noopener noreferrer"
download
>
Download
</a>
</Show>
<Show when={!item.download_url && !item.pdf_url}>
<span style="color:#94a3b8;font-size:12px"></span>
</Show>
</div>
</td>
</tr>
))}
</Show>
</tbody>
</table>
</div>
</section>
</AdminShell>
);
}

157
src/routes/admin/jobs.tsx Normal file
View file

@ -0,0 +1,157 @@
import { createResource, createSignal, createMemo, Show, For } from 'solid-js';
import { A } from '@solidjs/router';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
type Job = {
id: string;
title: string;
description?: string;
required_skills?: string[];
experience_level?: string;
job_type?: string;
status: string;
client_name?: string;
company_name?: string;
location?: string;
hourly_rate_min?: number;
hourly_rate_max?: number;
created_at?: string;
};
async function loadJobs(): Promise<Job[]> {
try {
const res = await fetch(`${API}/api/jobs?limit=100`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.jobs || []);
} catch {
return [];
}
}
const STATUS_OPTIONS = ['All', 'DRAFT', 'ACTIVE', 'PENDING_APPROVAL', 'CLOSED', 'EXPIRED'];
function statusChipClass(status: string): string {
if (status === 'ACTIVE') return 'status-chip active';
if (status === 'PENDING_APPROVAL') return 'status-chip pending';
if (status === 'DRAFT') return 'status-chip draft';
if (status === 'CLOSED' || status === 'EXPIRED') return 'status-chip danger';
return 'status-chip';
}
export default function JobsPage() {
const [jobs] = createResource(loadJobs);
const [search, setSearch] = createSignal('');
const [statusFilter, setStatusFilter] = createSignal('All');
const filtered = createMemo(() => {
const all = jobs() ?? [];
const q = search().toLowerCase();
const st = statusFilter();
return all.filter((job) => {
const matchesSearch =
!q ||
job.title?.toLowerCase().includes(q) ||
job.client_name?.toLowerCase().includes(q) ||
job.company_name?.toLowerCase().includes(q);
const matchesStatus = st === 'All' || job.status === st;
return matchesSearch && matchesStatus;
});
});
return (
<AdminShell>
<div class="page-actions">
<div>
<h1 class="page-title">Jobs Management</h1>
<p class="page-subtitle">Review live company job postings</p>
</div>
</div>
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap">
<input
type="text"
placeholder="Search by title or company..."
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
style="padding:8px 12px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;min-width:260px"
/>
<select
value={statusFilter()}
onChange={(e) => setStatusFilter(e.currentTarget.value)}
style="padding:8px 12px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
>
<For each={STATUS_OPTIONS}>{(opt) => <option value={opt}>{opt === 'All' ? 'All Statuses' : opt}</option>}</For>
</select>
</div>
<section class="card" style="padding:0;overflow:hidden">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Title</th>
<th>Skills</th>
<th>Company / Client</th>
<th>Rate</th>
<th>Location</th>
<th>Status</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={jobs.loading}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!jobs.loading && jobs.error}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
</Show>
<Show when={!jobs.loading && !jobs.error && filtered().length === 0}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">No jobs found.</td></tr>
</Show>
<Show when={!jobs.loading && !jobs.error && filtered().length > 0}>
<For each={filtered()}>
{(job) => (
<tr>
<td>
<div style="font-weight:600;color:#0f172a">{job.title || '—'}</div>
<Show when={job.description}>
<div style="font-size:12px;color:#64748b;margin-top:2px;max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
{job.description}
</div>
</Show>
</td>
<td style="color:#475569">
{job.required_skills?.join(', ') || job.experience_level || '—'}
</td>
<td style="color:#475569">{job.client_name || job.company_name || '—'}</td>
<td style="color:#475569">
{job.hourly_rate_min != null
? `${job.hourly_rate_min}–₹${job.hourly_rate_max ?? job.hourly_rate_min}/hr`
: '—'}
</td>
<td style="color:#475569">{job.location || '—'}</td>
<td>
<span class={statusChipClass(job.status)}>{job.status || '—'}</span>
</td>
<td>
<div class="table-actions">
<A class="btn" href={`/admin/jobs/${job.id}`}>View</A>
<Show when={job.status === 'PENDING_APPROVAL'}>
<A class="btn" href="/admin/approval">Review</A>
</Show>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</AdminShell>
);
}

544
src/routes/admin/kb.tsx Normal file
View file

@ -0,0 +1,544 @@
import { createResource, createSignal, createMemo, Show, For } from 'solid-js';
import { A } from '@solidjs/router';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
type KbCategory = {
id: string;
name: string;
slug: string;
description?: string;
article_count?: number;
updated_at?: string;
};
type KbArticle = {
id: string;
title: string;
slug?: string;
category_id?: string;
category?: string;
status: string;
updated_at?: string;
};
async function loadCategories(): Promise<KbCategory[]> {
try {
const res = await fetch(`${API}/api/admin/kb/categories`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.categories || []);
} catch {
return [];
}
}
async function loadArticles(): Promise<KbArticle[]> {
try {
const res = await fetch(`${API}/api/admin/kb/articles`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.articles || []);
} catch {
return [];
}
}
export default function KbPage() {
const [tab, setTab] = createSignal<'categories' | 'articles' | 'create-article'>('categories');
// Categories resource
const [categories, { refetch: refetchCategories }] = createResource(loadCategories);
// Articles resource
const [articles, { refetch: refetchArticles }] = createResource(loadArticles);
// --- Categories tab state ---
const [showCatForm, setShowCatForm] = createSignal(false);
const [catName, setCatName] = createSignal('');
const [catSlug, setCatSlug] = createSignal('');
const [catDesc, setCatDesc] = createSignal('');
const [catSaving, setCatSaving] = createSignal(false);
const [catError, setCatError] = createSignal('');
const [editingCatId, setEditingCatId] = createSignal('');
const [editCatName, setEditCatName] = createSignal('');
const [editCatSlug, setEditCatSlug] = createSignal('');
const [editCatSaving, setEditCatSaving] = createSignal(false);
const [editCatError, setEditCatError] = createSignal('');
const [deletingCatId, setDeletingCatId] = createSignal('');
const [actionError, setActionError] = createSignal('');
// --- Articles tab state ---
const [articleSearch, setArticleSearch] = createSignal('');
const [deletingArticleId, setDeletingArticleId] = createSignal('');
const [articleActionError, setArticleActionError] = createSignal('');
// --- Create Article tab state ---
const [artTitle, setArtTitle] = createSignal('');
const [artSlug, setArtSlug] = createSignal('');
const [artCategoryId, setArtCategoryId] = createSignal('');
const [artContent, setArtContent] = createSignal('');
const [artStatus, setArtStatus] = createSignal('DRAFT');
const [artSaving, setArtSaving] = createSignal(false);
const [artError, setArtError] = createSignal('');
// Filtered articles
const filteredArticles = createMemo(() => {
const all = articles() ?? [];
const q = articleSearch().toLowerCase();
if (!q) return all;
return all.filter((a) => a.title?.toLowerCase().includes(q));
});
// Categories actions
const handleAddCategory = async (e: Event) => {
e.preventDefault();
try {
setCatSaving(true);
setCatError('');
const res = await fetch(`${API}/api/admin/kb/categories`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: catName(), slug: catSlug(), description: catDesc() }),
});
if (!res.ok) throw new Error('Failed to create category');
setCatName('');
setCatSlug('');
setCatDesc('');
setShowCatForm(false);
refetchCategories();
} catch (err: any) {
setCatError(err.message || 'Failed to create');
} finally {
setCatSaving(false);
}
};
const startEditCat = (cat: KbCategory) => {
setEditingCatId(cat.id);
setEditCatName(cat.name);
setEditCatSlug(cat.slug);
setEditCatError('');
};
const cancelEditCat = () => {
setEditingCatId('');
setEditCatError('');
};
const saveEditCat = async (id: string) => {
try {
setEditCatSaving(true);
setEditCatError('');
const res = await fetch(`${API}/api/admin/kb/categories/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: editCatName(), slug: editCatSlug() }),
});
if (!res.ok) throw new Error('Failed to save');
setEditingCatId('');
refetchCategories();
} catch (err: any) {
setEditCatError(err.message || 'Failed to save');
} finally {
setEditCatSaving(false);
}
};
const deleteCategory = async (id: string, name: string) => {
if (!confirm(`Delete category "${name}"?`)) return;
try {
setDeletingCatId(id);
setActionError('');
const res = await fetch(`${API}/api/admin/kb/categories/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete');
refetchCategories();
} catch (err: any) {
setActionError(err.message || 'Failed to delete');
} finally {
setDeletingCatId('');
}
};
// Articles actions
const deleteArticle = async (id: string, title: string) => {
if (!confirm(`Delete article "${title}"?`)) return;
try {
setDeletingArticleId(id);
setArticleActionError('');
const res = await fetch(`${API}/api/admin/kb/articles/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete');
refetchArticles();
} catch (err: any) {
setArticleActionError(err.message || 'Failed to delete');
} finally {
setDeletingArticleId('');
}
};
// Create article
const handleCreateArticle = async (e: Event) => {
e.preventDefault();
try {
setArtSaving(true);
setArtError('');
const body: Record<string, any> = {
title: artTitle(),
slug: artSlug(),
content: artContent(),
status: artStatus(),
};
if (artCategoryId()) body.category_id = artCategoryId();
const res = await fetch(`${API}/api/admin/kb/articles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error('Failed to create article');
setArtTitle('');
setArtSlug('');
setArtCategoryId('');
setArtContent('');
setArtStatus('DRAFT');
setTab('articles');
refetchArticles();
} catch (err: any) {
setArtError(err.message || 'Failed to create');
} finally {
setArtSaving(false);
}
};
return (
<AdminShell>
<div class="page-actions">
<div>
<h1 class="page-title">Knowledge Base</h1>
<p class="page-subtitle">Manage help articles and categories</p>
</div>
</div>
{/* Tabs */}
<div style="display:flex;border-bottom:2px solid #e2e8f0;margin-bottom:24px;gap:0;overflow-x:auto;">
<button
type="button"
class={`admin-tab${tab() === 'categories' ? ' active' : ''}`}
onClick={() => setTab('categories')}
>
Categories
</button>
<button
type="button"
class={`admin-tab${tab() === 'articles' ? ' active' : ''}`}
onClick={() => setTab('articles')}
>
Articles
</button>
<button
type="button"
class={`admin-tab${tab() === 'create-article' ? ' active' : ''}`}
onClick={() => setTab('create-article')}
>
Create Article
</button>
</div>
{/* Categories Tab */}
<Show when={tab() === 'categories'}>
<div class="page-actions" style="margin-bottom:16px">
<div />
<button class="btn navy" onClick={() => setShowCatForm(!showCatForm())}>
{showCatForm() ? 'Cancel' : 'Add Category'}
</button>
</div>
<Show when={actionError()}>
<div class="error-box" style="margin-bottom:12px">{actionError()}</div>
</Show>
<Show when={showCatForm()}>
<section class="card" style="margin-bottom:16px;max-width:480px">
<h2 style="margin:0 0 16px;font-size:15px;font-weight:700">New Category</h2>
<Show when={catError()}>
<div class="error-box" style="margin-bottom:10px">{catError()}</div>
</Show>
<form onSubmit={handleAddCategory} style="display:flex;flex-direction:column;gap:12px">
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Name</label>
<input
type="text"
value={catName()}
onInput={(e) => setCatName(e.currentTarget.value)}
required
placeholder="e.g. Getting Started"
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
/>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Slug</label>
<input
type="text"
value={catSlug()}
onInput={(e) => setCatSlug(e.currentTarget.value)}
required
placeholder="e.g. getting-started"
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
/>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Description</label>
<textarea
value={catDesc()}
onInput={(e) => setCatDesc(e.currentTarget.value)}
rows="3"
placeholder="Brief description..."
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;resize:vertical"
/>
</div>
<div>
<button class="btn navy" type="submit" disabled={catSaving()}>
{catSaving() ? 'Saving...' : 'Create Category'}
</button>
</div>
</form>
</section>
</Show>
<section class="card" style="padding:0;overflow:hidden">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Article Count</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={categories.loading}>
<tr><td colspan="4" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!categories.loading && categories.error}>
<tr><td colspan="4" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
</Show>
<Show when={!categories.loading && !categories.error && (categories()?.length ?? 0) === 0}>
<tr><td colspan="4" style="text-align:center;padding:32px;color:#94a3b8">No categories found.</td></tr>
</Show>
<Show when={!categories.loading && !categories.error && (categories()?.length ?? 0) > 0}>
<For each={categories()}>
{(cat) => (
<>
<tr>
<td style="font-weight:600;color:#0f172a">{cat.name}</td>
<td style="color:#475569;font-family:monospace;font-size:13px">{cat.slug}</td>
<td style="color:#475569">{cat.article_count ?? '—'}</td>
<td>
<div class="table-actions">
<button class="btn" onClick={() => startEditCat(cat)}>Edit</button>
<button
class="btn danger"
disabled={deletingCatId() === cat.id}
onClick={() => deleteCategory(cat.id, cat.name)}
>
{deletingCatId() === cat.id ? '...' : 'Delete'}
</button>
</div>
</td>
</tr>
<Show when={editingCatId() === cat.id}>
<tr>
<td colspan="4" style="background:#f8fafc;padding:14px">
<Show when={editCatError()}>
<div class="error-box" style="margin-bottom:8px">{editCatError()}</div>
</Show>
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end">
<div class="field">
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">Name</label>
<input
type="text"
value={editCatName()}
onInput={(e) => setEditCatName(e.currentTarget.value)}
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:180px"
/>
</div>
<div class="field">
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">Slug</label>
<input
type="text"
value={editCatSlug()}
onInput={(e) => setEditCatSlug(e.currentTarget.value)}
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:180px"
/>
</div>
<div style="display:flex;gap:8px">
<button class="btn navy" disabled={editCatSaving()} onClick={() => saveEditCat(cat.id)}>
{editCatSaving() ? 'Saving...' : 'Save'}
</button>
<button class="btn" onClick={cancelEditCat}>Cancel</button>
</div>
</div>
</td>
</tr>
</Show>
</>
)}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</Show>
{/* Articles Tab */}
<Show when={tab() === 'articles'}>
<Show when={articleActionError()}>
<div class="error-box" style="margin-bottom:12px">{articleActionError()}</div>
</Show>
<div style="margin-bottom:16px">
<input
type="text"
placeholder="Search articles by title..."
value={articleSearch()}
onInput={(e) => setArticleSearch(e.currentTarget.value)}
style="padding:8px 12px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;min-width:280px"
/>
</div>
<section class="card" style="padding:0;overflow:hidden">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Title</th>
<th>Category</th>
<th>Status</th>
<th>Updated At</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={articles.loading}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!articles.loading && articles.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={!articles.loading && !articles.error && filteredArticles().length === 0}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#94a3b8">No articles found.</td></tr>
</Show>
<Show when={!articles.loading && !articles.error && filteredArticles().length > 0}>
<For each={filteredArticles()}>
{(article) => (
<tr>
<td style="font-weight:600;color:#0f172a">{article.title}</td>
<td style="color:#475569">{article.category || '—'}</td>
<td>
<span class={`status-chip ${article.status === 'PUBLISHED' ? 'active' : 'draft'}`}>
{article.status || '—'}
</span>
</td>
<td style="color:#475569">
{article.updated_at ? new Date(article.updated_at).toLocaleString() : '—'}
</td>
<td>
<div class="table-actions">
<A class="btn" href={`/admin/kb/articles/${article.id}/edit`}>Edit</A>
<button
class="btn danger"
disabled={deletingArticleId() === article.id}
onClick={() => deleteArticle(article.id, article.title)}
>
{deletingArticleId() === article.id ? '...' : 'Delete'}
</button>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</Show>
{/* Create Article Tab */}
<Show when={tab() === 'create-article'}>
<section class="card" style="max-width:640px">
<h2 style="margin:0 0 20px;font-size:16px;font-weight:700">New Article</h2>
<Show when={artError()}>
<div class="error-box" style="margin-bottom:14px">{artError()}</div>
</Show>
<form onSubmit={handleCreateArticle} style="display:flex;flex-direction:column;gap:14px">
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Title</label>
<input
type="text"
value={artTitle()}
onInput={(e) => setArtTitle(e.currentTarget.value)}
required
placeholder="e.g. How to reset your password"
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
/>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Slug</label>
<input
type="text"
value={artSlug()}
onInput={(e) => setArtSlug(e.currentTarget.value)}
placeholder="e.g. how-to-reset-password"
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
/>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Category</label>
<select
value={artCategoryId()}
onChange={(e) => setArtCategoryId(e.currentTarget.value)}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
>
<option value=""> Select category </option>
<Show when={!categories.loading}>
<For each={categories() ?? []}>
{(cat) => <option value={cat.id}>{cat.name}</option>}
</For>
</Show>
</select>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Content</label>
<textarea
value={artContent()}
onInput={(e) => setArtContent(e.currentTarget.value)}
required
rows="12"
placeholder="Write article content here..."
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;resize:vertical"
/>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Status</label>
<select
value={artStatus()}
onChange={(e) => setArtStatus(e.currentTarget.value)}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
>
<option value="DRAFT">DRAFT</option>
<option value="PUBLISHED">PUBLISHED</option>
</select>
</div>
<div>
<button class="btn navy" type="submit" disabled={artSaving()}>
{artSaving() ? 'Creating...' : 'Create Article'}
</button>
</div>
</form>
</section>
</Show>
</AdminShell>
);
}

155
src/routes/admin/leads.tsx Normal file
View file

@ -0,0 +1,155 @@
import { A } from '@solidjs/router';
import { createMemo, createResource, createSignal, For, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
const ROLE_OPTIONS = [
'company', 'job_seeker', 'customer', 'photographer', 'video_editor',
'graphic_designer', 'social_media_manager', 'fitness_trainer',
'catering_services', 'makeup_artist', 'tutor', 'developer',
];
async function loadLeads(): Promise<any[]> {
try {
const res = await fetch(`${API}/api/admin/leads`);
if (res.ok) {
const data = await res.json();
return Array.isArray(data) ? data : (data.leads || []);
}
const res2 = await fetch(`${API}/api/leads?limit=100`);
if (!res2.ok) throw new Error('Failed to load');
const data2 = await res2.json();
return Array.isArray(data2) ? data2 : (data2.leads || []);
} catch {
return [];
}
}
export default function LeadsPage() {
const [leads] = createResource(loadLeads);
const [search, setSearch] = createSignal('');
const [statusFilter, setStatusFilter] = createSignal('');
const [roleFilter, setRoleFilter] = createSignal('');
const filtered = createMemo(() => {
const list = leads() ?? [];
const q = search().toLowerCase();
const sf = statusFilter().toUpperCase();
const rf = roleFilter().toLowerCase();
return list.filter((item) => {
const title = (item.title || '').toLowerCase();
const loc = (item.location || '').toLowerCase();
const matchQ = !q || title.includes(q) || loc.includes(q);
const matchS = !sf || (item.status || '').toUpperCase() === sf;
const matchR = !rf || (item.profession || item.role || '').toLowerCase() === rf;
return matchQ && matchS && matchR;
});
});
return (
<AdminShell>
<div class="page-actions">
<div>
<h1 class="page-title">Leads Management</h1>
<p class="page-subtitle">View all requirements and lead requests from customers.</p>
</div>
</div>
{/* Filters */}
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:16px;align-items:center;">
<input
type="text"
placeholder="Search by title or location..."
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
style="border:1px solid #cbd5e1;border-radius:8px;padding:8px 12px;font-size:14px;outline:none;min-width:220px;flex:1;max-width:320px"
/>
<select
value={statusFilter()}
onChange={(e) => setStatusFilter(e.currentTarget.value)}
style="border:1px solid #cbd5e1;border-radius:8px;padding:8px 12px;font-size:14px;background:#fff;outline:none;"
>
<option value="">All Statuses</option>
<option value="OPEN">Open</option>
<option value="ACTIVE">Active</option>
<option value="PENDING">Pending</option>
<option value="CLOSED">Closed</option>
<option value="CANCELLED">Cancelled</option>
</select>
<select
value={roleFilter()}
onChange={(e) => setRoleFilter(e.currentTarget.value)}
style="border:1px solid #cbd5e1;border-radius:8px;padding:8px 12px;font-size:14px;background:#fff;outline:none;"
>
<option value="">All Roles</option>
<For each={ROLE_OPTIONS}>
{(r) => <option value={r}>{r.replace(/_/g, ' ')}</option>}
</For>
</select>
<Show when={search() || statusFilter() || roleFilter()}>
<span style="font-size:13px;color:#64748b">{filtered().length} result{filtered().length !== 1 ? 's' : ''}</span>
</Show>
</div>
<section class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Title</th>
<th>Role</th>
<th>Budget</th>
<th>Location</th>
<th>Status</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={leads.loading}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#64748b">Loading leads...</td></tr>
</Show>
<Show when={!leads.loading && leads.error}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
</Show>
<Show when={!leads.loading && !leads.error && filtered().length === 0}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#94a3b8">No leads found.</td></tr>
</Show>
<Show when={!leads.loading && !leads.error && filtered().length > 0}>
<For each={filtered()}>
{(item) => (
<tr>
<td>
<div style="font-weight:600;color:#0f172a">{item.title || '—'}</div>
<Show when={item.description}>
<div style="font-size:12px;color:#64748b;margin-top:2px">
{String(item.description).slice(0, 60)}{String(item.description).length > 60 ? '…' : ''}
</div>
</Show>
</td>
<td style="color:#475569">{item.profession || item.role || '—'}</td>
<td style="color:#475569">
{item.budget_range || (item.budget_min != null ? `${item.budget_min}–₹${item.budget_max}` : '—')}
</td>
<td style="color:#475569">{item.location || '—'}</td>
<td>
<span class={`status-chip ${(item.status === 'ACTIVE' || item.status === 'OPEN') ? 'active' : ''}`}>
{item.status || '—'}
</span>
</td>
<td>
<div class="table-actions">
<A class="btn" href={`/admin/leads/${item.id}`}>View</A>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</AdminShell>
);
}

118
src/routes/admin/ledger.tsx Normal file
View file

@ -0,0 +1,118 @@
import { createResource, Show, For } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
type LedgerEntry = {
id: string;
entry_type?: string;
type?: string;
order_id?: string;
invoice_id?: string;
user_id?: string;
amount?: number;
note?: string;
created_at?: string;
};
async function loadLedger(): Promise<LedgerEntry[]> {
try {
const res = await fetch(`${API}/api/admin/ledger`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.entries || data.ledger || []);
} catch {
return [];
}
}
function typeBadgeStyle(entryType: string): string {
switch ((entryType || '').toUpperCase()) {
case 'TRACECOIN_PURCHASE':
return 'background:#dbeafe;color:#1d4ed8;border-color:#bfdbfe';
case 'PAYMENT':
return 'background:#dcfce7;color:#166534;border-color:#bbf7d0';
case 'DISCOUNT':
return 'background:#fff7ed;color:#c2410c;border-color:#fed7aa';
case 'COUPON':
return 'background:#f3e8ff;color:#7e22ce;border-color:#e9d5ff';
case 'INVOICE':
default:
return 'background:#f1f5f9;color:#475569;border-color:#e2e8f0';
}
}
function formatAmount(entry: LedgerEntry): string {
const t = (entry.entry_type || entry.type || '').toUpperCase();
if (t === 'TRACECOIN_PURCHASE') {
return entry.amount != null ? `${entry.amount} TC` : '—';
}
if (entry.amount == null) return '—';
return `${(entry.amount / 100).toFixed(2)}`;
}
export default function LedgerPage() {
const [entries] = createResource(loadLedger);
return (
<AdminShell>
<div class="page-actions">
<div>
<h1 class="page-title">Ledger Management</h1>
<p class="page-subtitle">Platform financial ledger</p>
</div>
</div>
<section class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Type</th>
<th>Order ID</th>
<th>Invoice ID</th>
<th>User ID</th>
<th>Amount</th>
<th>Note</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<Show when={entries.loading}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!entries.loading && entries.error}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
</Show>
<Show when={!entries.loading && !entries.error && entries()?.length === 0}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">No ledger entries found.</td></tr>
</Show>
<Show when={!entries.loading && !entries.error && (entries()?.length ?? 0) > 0}>
<For each={entries()}>
{(item) => {
const entryType = item.entry_type || item.type || '—';
return (
<tr>
<td>
<span style={`${typeBadgeStyle(entryType)};padding:2px 10px;border-radius:999px;font-size:12px;font-weight:600;border:1px solid;display:inline-block`}>
{entryType}
</span>
</td>
<td style="color:#475569;font-size:12px;font-family:monospace">{item.order_id || '—'}</td>
<td style="color:#475569;font-size:12px;font-family:monospace">{item.invoice_id || '—'}</td>
<td style="color:#475569;font-size:12px;font-family:monospace">{item.user_id || '—'}</td>
<td style="font-weight:600;color:#0f172a">{formatAmount(item)}</td>
<td style="color:#475569">{item.note || '—'}</td>
<td style="color:#475569">{item.created_at ? new Date(item.created_at).toLocaleString() : '—'}</td>
</tr>
);
}}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</AdminShell>
);
}

View file

@ -0,0 +1,124 @@
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 [];
}
}
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="page-actions">
<div>
<h1 class="page-title">Makeup Artist Management</h1>
<p class="page-subtitle">Manage all makeup artist accounts on the platform.</p>
</div>
</div>
<section class="card" style="padding: 0; overflow: hidden;">
<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="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Registered</th>
<th class="align-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 makeup artist users found.</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length > 0}>
<For each={filtered()}>
{(item) => (
<tr>
<td style="font-weight:600;color:#0f172a">{item.name || item.full_name || '—'}</td>
<td style="color:#475569">{item.email}</td>
<td>
{item.status?.toUpperCase() === 'ACTIVE' && (
<span class="status-chip active">ACTIVE</span>
)}
{item.status?.toUpperCase() === 'INACTIVE' && (
<span class="status-chip">INACTIVE</span>
)}
{item.status?.toUpperCase() === 'PENDING' && (
<span class="status-chip" style="background:#fff7ed;color:#c2410c;border-color:#fed7aa;">PENDING</span>
)}
{!item.status && <span class="status-chip"></span>}
</td>
<td style="color:#475569">
{item.created_at ? new Date(item.created_at).toLocaleDateString() : '—'}
</td>
<td>
<div class="table-actions">
<A class="btn" href={`/admin/users/${item.id}`}>View</A>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</AdminShell>
);
}

View file

@ -0,0 +1,187 @@
import { createSignal, createMemo, Show, For } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
type NotificationRow = {
id: string;
title: string;
message: string;
read_at: string | null;
event_type: string;
created_at: string;
};
function eventTypeBadgeStyle(eventType: string): string {
if (eventType.includes('APPROVED')) return 'background:#dcfce7;color:#15803d';
if (eventType.includes('REJECTED')) return 'background:#fee2e2;color:#b91c1c';
if (eventType.includes('CHANGES_REQUESTED')) return 'background:#ffedd5;color:#c2410c';
return 'background:#f1f5f9;color:#475569';
}
const BADGE_STYLE = 'display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600';
export default function NotificationsPage() {
const [activeTab, setActiveTab] = createSignal<'all' | 'unread'>('all');
const [rows, setRows] = createSignal<NotificationRow[]>([]);
const [cursor, setCursor] = createSignal<string | null>(null);
const [loading, setLoading] = createSignal(false);
const [markingAllRead, setMarkingAllRead] = createSignal(false);
const [markingId, setMarkingId] = createSignal('');
const unreadCount = createMemo(() => rows().filter((r) => r.read_at === null).length);
const unreadRows = createMemo(() => rows().filter((r) => r.read_at === null));
const visibleRows = createMemo(() => (activeTab() === 'unread' ? unreadRows() : rows()));
const load = async (nextCursor?: string | null, reset?: boolean) => {
setLoading(true);
try {
const params = new URLSearchParams();
if (nextCursor) params.set('cursor', nextCursor);
const res = await fetch(`${API}/api/me/notifications${params.toString() ? `?${params}` : ''}`);
if (!res.ok) throw new Error('Failed to load notifications');
const data = await res.json();
const list: NotificationRow[] = Array.isArray(data.notifications) ? data.notifications : Array.isArray(data) ? data : [];
setRows((prev) => (reset ? list : [...prev, ...list]));
setCursor(data.nextCursor || null);
} catch {
if (reset) setRows([]);
} finally {
setLoading(false);
}
};
// Initial load
load(null, true);
const refetch = () => load(null, true);
const handleMarkRead = async (id: string) => {
setMarkingId(id);
try {
await fetch(`${API}/api/me/notifications/${id}/read`, { method: 'POST' });
setRows((prev) => prev.map((r) => (r.id === id ? { ...r, read_at: new Date().toISOString() } : r)));
} finally {
setMarkingId('');
}
};
const handleMarkAllRead = async () => {
setMarkingAllRead(true);
try {
await fetch(`${API}/api/me/notifications/read-all`, { method: 'POST' });
refetch();
} finally {
setMarkingAllRead(false);
}
};
const truncate = (str: string, max: number) =>
str && str.length > max ? str.substring(0, max) + '…' : (str || '');
return (
<AdminShell>
<div class="page-actions">
<div>
<h1 class="page-title">Notifications</h1>
<p class="page-subtitle">Approval outcomes and action-required updates</p>
</div>
<button
class="btn"
onClick={handleMarkAllRead}
disabled={markingAllRead()}
>
{markingAllRead() ? 'Marking...' : 'Mark All Read'}
</button>
</div>
{/* Tabs */}
<div style="display:flex;border-bottom:2px solid #e2e8f0;margin-bottom:24px;gap:0;overflow-x:auto;">
<button
type="button"
class={`admin-tab${activeTab() === 'all' ? ' active' : ''}`}
onClick={() => setActiveTab('all')}
>
All
</button>
<button
type="button"
class={`admin-tab${activeTab() === 'unread' ? ' active' : ''}`}
onClick={() => setActiveTab('unread')}
>
Unread ({unreadCount()})
</button>
</div>
<section class="card" style="padding:0;overflow:hidden">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Title</th>
<th>Message</th>
<th>Event Type</th>
<th>Created At</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={loading() && rows().length === 0}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!loading() && visibleRows().length === 0}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#94a3b8">No notifications.</td></tr>
</Show>
<For each={visibleRows()}>
{(item) => (
<tr style={item.read_at === null ? 'background:#eff6ff' : ''}>
<td style="font-weight:600;color:#0f172a;min-width:160px">{item.title}</td>
<td style="font-size:13px;color:#475569;max-width:320px">{truncate(item.message, 80)}</td>
<td>
<span style={`${BADGE_STYLE};${eventTypeBadgeStyle(item.event_type)}`}>
{item.event_type}
</span>
</td>
<td style="font-size:12px;color:#64748b;white-space:nowrap">
{item.created_at ? new Date(item.created_at).toLocaleString() : '—'}
</td>
<td>
<div class="table-actions">
<Show when={item.read_at === null}>
<button
class="btn"
disabled={markingId() === item.id}
onClick={() => handleMarkRead(item.id)}
>
{markingId() === item.id ? '...' : 'Mark Read'}
</button>
</Show>
</div>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</section>
<Show when={loading() && rows().length > 0}>
<p style="text-align:center;padding:12px;font-size:13px;color:#64748b">Loading...</p>
</Show>
<Show when={cursor() && !loading()}>
<div style="margin-top:12px">
<button
class="btn"
onClick={() => load(cursor(), false)}
>
Load More
</button>
</div>
</Show>
</AdminShell>
);
}

View file

@ -1,127 +1,302 @@
import { useParams } from '@solidjs/router';
import { createEffect, createResource, createSignal, Show } from 'solid-js';
import { useNavigate, useParams } from '@solidjs/router';
import { createEffect, createMemo, createResource, createSignal, For, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import { getRuntimeConfig, saveRuntimeConfig } from '~/lib/runtime/storage';
import type { RuntimeOnboardingConfig, RuntimeOnboardingStep } from '~/lib/runtime/types';
function stringifySteps(steps: RuntimeOnboardingStep[]): string {
return JSON.stringify(steps, null, 2);
const API = '/api/gateway';
const EXTERNAL_ROLES = ['company', 'job_seeker', 'customer', 'photographer', 'video_editor', 'graphic_designer', 'social_media_manager', 'fitness_trainer', 'catering_services', 'makeup_artist', 'tutor', 'developer'];
const FIELD_TYPES = ['text', 'textarea', 'number', 'email', 'tel', 'date', 'select', 'url', 'file', 'checkbox'] as const;
type FieldType = typeof FIELD_TYPES[number];
type OnboardingField = {
id: string;
label: string;
type: FieldType;
required: boolean;
placeholder?: string;
helperText?: string;
options?: { label: string; value: string }[];
};
type OnboardingStep = {
id: string;
title: string;
description?: string;
fields: OnboardingField[];
};
const makeId = (prefix: string) => `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
async function loadSchema(schemaId: string) {
try {
const res = await fetch(`${API}/api/admin/onboarding-config/${schemaId}`);
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
export default function EditOnboardingPage() {
export default function EditOnboardingFlowPage() {
const params = useParams();
const [data] = createResource(() => {
if (!params.schemaId) return null;
return getRuntimeConfig<RuntimeOnboardingConfig>('onboarding', params.schemaId);
});
const navigate = useNavigate();
const [data] = createResource(() => params.schemaId, loadSchema);
const [config, setConfig] = createSignal<RuntimeOnboardingConfig | null>(null);
const [stepsJson, setStepsJson] = createSignal('[]');
const [statusMessage, setStatusMessage] = createSignal('');
const [stepsError, setStepsError] = createSignal('');
const [isSaving, setIsSaving] = createSignal(false);
const [title, setTitle] = createSignal('');
const [roleKey, setRoleKey] = createSignal('company');
const [description, setDescription] = createSignal('');
const [finalMessage, setFinalMessage] = createSignal('Your onboarding has been submitted for review. We will notify you once it is approved.');
const [steps, setSteps] = createSignal<OnboardingStep[]>([]);
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal('');
const [loaded, setLoaded] = createSignal(false);
// Sync resource to local signals
// Pre-populate from loaded schema
createEffect(() => {
const item = data();
if (item?.payload) {
setConfig(JSON.parse(JSON.stringify(item.payload)));
setStepsJson(stringifySteps(item.payload.steps));
}
const d = data();
if (!d || loaded()) return;
const schema = d.schema_json || d;
setTitle(schema.title || '');
setRoleKey(schema.roleKey || 'company');
setDescription(schema.description || '');
setFinalMessage(schema.finalSubmissionMessage || 'Your onboarding has been submitted for review. We will notify you once it is approved.');
// Ensure each step and field has an id
const rawSteps: OnboardingStep[] = (schema.steps || []).map((s: any) => ({
id: s.id || makeId('step'),
title: s.title || '',
description: s.description || '',
fields: (s.fields || []).map((f: any) => ({
id: f.id || makeId('fld'),
label: f.label || '',
type: f.type || 'text',
required: f.required || false,
placeholder: f.placeholder || '',
helperText: f.helperText || '',
options: f.options || [],
})),
}));
setSteps(rawSteps);
setLoaded(true);
});
const syncSteps = (raw: string) => {
setStepsJson(raw);
const current = config();
if (!current) return;
const totalFields = createMemo(() => steps().reduce((sum, s) => sum + s.fields.length, 0));
try {
const parsed = JSON.parse(raw) as RuntimeOnboardingStep[];
if (!Array.isArray(parsed)) {
setStepsError('Steps JSON must be an array.');
return;
}
setConfig({ ...current, steps: parsed });
setStepsError('');
} catch {
setStepsError('Invalid JSON. Fix syntax before saving.');
}
const updateStep = (stepId: string, patch: Partial<OnboardingStep>) =>
setSteps((prev) => prev.map((s) => s.id === stepId ? { ...s, ...patch } : s));
const addStep = () => setSteps((prev) => [...prev, { id: makeId('step'), title: `Step ${prev.length + 1}`, description: '', fields: [] }]);
const removeStep = (stepId: string) => setSteps((prev) => prev.filter((s) => s.id !== stepId));
const addField = (stepId: string) => {
const step = steps().find((s) => s.id === stepId)!;
updateStep(stepId, {
fields: [...step.fields, { id: makeId('fld'), label: 'New Field', type: 'text', required: false, placeholder: '' }],
});
};
const persist = async (status: 'draft' | 'published') => {
const payload = config();
if (!payload) return;
if (!payload.schemaId.trim()) {
setStatusMessage('Schema ID is required before saving.');
return;
}
if (stepsError()) {
setStatusMessage('Fix steps JSON errors before saving.');
return;
}
if (payload.steps.length === 0) {
setStatusMessage('Add at least one onboarding step before saving.');
return;
}
const removeField = (stepId: string, fieldId: string) => {
const step = steps().find((s) => s.id === stepId)!;
updateStep(stepId, { fields: step.fields.filter((f) => f.id !== fieldId) });
};
setIsSaving(true);
setStatusMessage('Saving to backend...');
const updateField = (stepId: string, fieldId: string, patch: Partial<OnboardingField>) => {
const step = steps().find((s) => s.id === stepId)!;
updateStep(stepId, { fields: step.fields.map((f) => f.id === fieldId ? { ...f, ...patch } : f) });
};
const handleSubmit = async (status: 'draft' | 'published') => {
if (!title().trim()) { setError('Flow title is required'); return; }
if (steps().length === 0) { setError('Add at least one step'); return; }
try {
await saveRuntimeConfig('onboarding', payload.schemaId, payload, status);
setStatusMessage(status === 'draft' ? 'Draft saved successfully.' : 'Onboarding schema published successfully.');
} catch (err) {
setStatusMessage(`Error: ${err instanceof Error ? err.message : String(err)}`);
setSaving(true);
setError('');
const payload = {
title: title().trim(),
roleKey: roleKey(),
description: description().trim(),
finalSubmissionMessage: finalMessage(),
version: (data()?.schema_json?.version || data()?.version || 1),
steps: steps(),
is_active: status === 'published',
};
const res = await fetch(`${API}/api/admin/onboarding-config/${params.schemaId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schema_json: payload, is_active: status === 'published' }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message || 'Failed to update onboarding flow');
}
navigate('/admin/onboarding-schemas');
} catch (err: any) {
setError(err.message || 'Failed to update onboarding flow');
} finally {
setIsSaving(false);
setSaving(false);
}
};
return (
<AdminShell>
<h1 class="page-title">Edit Onboarding Flow</h1>
<p class="page-subtitle">All onboarding fields, validations, upload rules, and select behaviors are runtime schema-driven.</p>
<div class="page-actions">
<div>
<h1 class="page-title">Edit Onboarding Flow</h1>
<p class="page-subtitle">Update the onboarding flow details, steps, and fields.</p>
</div>
<a class="btn" href="/admin/onboarding-schemas">Back to Onboarding</a>
</div>
<Show when={data.loading}>
<section class="card"><p class="notice">Loading onboarding schema from database...</p></section>
<div class="card"><p class="notice">Loading onboarding flow...</p></div>
</Show>
<Show when={!data.loading && !config()}>
<section class="card"><p class="notice">Onboarding schema "{params.schemaId}" not found in database.</p></section>
<Show when={data.error}>
<div class="error-box">Failed to load onboarding flow. Check that the backend is running.</div>
</Show>
<Show when={!data.loading && config()}>
<div class="grid">
<section class="card">
<h2>Onboarding Builder</h2>
<Show when={error()}>
<div class="error-box">{error()}</div>
</Show>
<Show when={!data.loading && loaded()}>
{/* Info stats */}
<div class="onboarding-info-grid">
<div class="onboarding-stat">
<div class="stat-label">Role</div>
<div class="stat-value" style="font-size:14px;margin-top:6px">{roleKey().replace(/_/g, ' ').toUpperCase()}</div>
</div>
<div class="onboarding-stat">
<div class="stat-label">Steps</div>
<div class="stat-value">{steps().length}</div>
</div>
<div class="onboarding-stat">
<div class="stat-label">Total Fields</div>
<div class="stat-value">{totalFields()}</div>
</div>
</div>
{/* Flow metadata */}
<div class="role-form-section">
<h3>Flow Details</h3>
<p>Update the flow title, target role, and final submission message.</p>
<div class="field-grid-2">
<div class="field">
<label>Schema ID</label>
<input value={config()!.schemaId} onInput={(e) => setConfig({ ...config()!, schemaId: e.currentTarget.value })} />
<label>Flow Title <span style="color:#ef4444">*</span></label>
<input value={title()} onInput={(e) => setTitle(e.currentTarget.value)} placeholder="e.g. Photographer Onboarding" />
</div>
<div class="field">
<label>Role Key</label>
<input value={config()!.roleKey} onInput={(e) => setConfig({ ...config()!, roleKey: e.currentTarget.value.toUpperCase() })} />
<label>Target Role</label>
<select value={roleKey()} onChange={(e) => setRoleKey(e.currentTarget.value)}>
{EXTERNAL_ROLES.map((r) => <option value={r}>{r.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())}</option>)}
</select>
</div>
<div class="field">
<label>Version</label>
<input type="number" value={config()!.version} onInput={(e) => setConfig({ ...config()!, version: Number(e.currentTarget.value || 1) })} />
<label>Description</label>
<input value={description()} onInput={(e) => setDescription(e.currentTarget.value)} placeholder="Short description of this onboarding flow" />
</div>
<div class="field">
<label>Steps JSON (full runtime schema)</label>
<textarea class="json-input" rows={18} value={stepsJson()} onInput={(e) => syncSteps(e.currentTarget.value)} />
{stepsError() && <p class="error-note">{stepsError()}</p>}
<label>Final Submission Message</label>
<textarea rows={2} value={finalMessage()} onInput={(e) => setFinalMessage(e.currentTarget.value)} />
</div>
<div class="actions">
<button class="btn" onClick={() => persist('draft')} disabled={isSaving()}>
{isSaving() ? 'Saving...' : 'Save Draft'}
</button>
<button class="btn primary" onClick={() => persist('published')} disabled={isSaving()}>
{isSaving() ? 'Publishing...' : 'Publish'}
</button>
</div>
</div>
{/* Steps */}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<h2 style="margin:0;font-size:18px;font-weight:700">Steps & Fields</h2>
<button class="btn orange" onClick={addStep}>Add Step</button>
</div>
<For each={steps()}>
{(step, idx) => (
<div class="step-builder">
<div class="step-header">
<div class="step-num">{idx() + 1}</div>
<input
class="step-title-input"
value={step.title}
onInput={(e) => updateStep(step.id, { title: e.currentTarget.value })}
placeholder={`Step ${idx() + 1} title`}
/>
<input
value={step.description || ''}
onInput={(e) => updateStep(step.id, { description: e.currentTarget.value })}
placeholder="Step description (optional)"
style="border:1px solid #cbd5e1;border-radius:12px;padding:8px 12px;font-size:13px;outline:none;flex:1"
/>
<button class="btn danger" style="font-size:12px;padding:6px 10px" onClick={() => removeStep(step.id)}>Remove Step</button>
</div>
{/* Fields */}
<div style="margin-top:4px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<p style="margin:0;font-size:13px;font-weight:600;color:#334155">{step.fields.length} field{step.fields.length !== 1 ? 's' : ''}</p>
<button class="btn orange" style="font-size:12px;padding:5px 10px" onClick={() => addField(step.id)}>Add Field</button>
</div>
<For each={step.fields}>
{(field) => (
<div class="field-row">
<div style="display:flex;flex-direction:column;gap:4px">
<input
value={field.label}
onInput={(e) => updateField(step.id, field.id, { label: e.currentTarget.value })}
placeholder="Field label"
style="width:100%"
/>
<input
value={field.placeholder || ''}
onInput={(e) => updateField(step.id, field.id, { placeholder: e.currentTarget.value })}
placeholder="Placeholder text"
style="width:100%;font-size:12px"
/>
</div>
<select
class="field-type-select"
value={field.type}
onChange={(e) => updateField(step.id, field.id, { type: e.currentTarget.value as FieldType })}
>
{FIELD_TYPES.map((t) => <option value={t}>{t}</option>)}
</select>
<label class="checkbox-label" style="justify-content:center">
<input
type="checkbox"
checked={field.required}
onChange={(e) => updateField(step.id, field.id, { required: e.currentTarget.checked })}
/>
Required
</label>
<button class="btn danger" style="font-size:12px;padding:5px 10px" onClick={() => removeField(step.id, field.id)}></button>
</div>
)}
</For>
<Show when={step.fields.length === 0}>
<div style="border:1px dashed #cbd5e1;border-radius:10px;padding:16px;text-align:center;font-size:13px;color:#94a3b8">
No fields in this step. Click "Add Field" to add the first field.
</div>
</Show>
</div>
</div>
{statusMessage() && <p class="inline-note">{statusMessage()}</p>}
</section>
<section class="card">
<h2>Runtime Config Preview</h2>
<pre class="json">{JSON.stringify(config(), null, 2)}</pre>
</section>
)}
</For>
<Show when={steps().length === 0}>
<div style="border:1px dashed #cbd5e1;border-radius:12px;padding:32px;text-align:center;font-size:13px;color:#94a3b8">
No steps yet. Click "Add Step" above to get started.
</div>
</Show>
{/* Save actions */}
<div class="actions" style="justify-content:flex-end;margin-top:16px">
<button class="btn" onClick={() => handleSubmit('draft')} disabled={saving()}>
{saving() ? 'Saving...' : 'Save as Draft'}
</button>
<button class="btn primary" onClick={() => handleSubmit('published')} disabled={saving()}>
{saving() ? 'Publishing...' : 'Publish Flow'}
</button>
</div>
</Show>
</AdminShell>

View file

@ -1,61 +1,129 @@
import { A } from '@solidjs/router';
import { createResource, Show } from 'solid-js';
import { A, useNavigate } from '@solidjs/router';
import { createResource, createSignal, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import { deleteRuntimeConfig, listRuntimeConfigs, type RuntimeListItem } from '~/lib/runtime/storage';
import type { RuntimeOnboardingConfig } from '~/lib/runtime/types';
export default function ManageOnboardingPage() {
const [items, { refetch }] = createResource(() => listRuntimeConfigs<RuntimeOnboardingConfig>('onboarding'));
const API = '/api/gateway';
const onDelete = async (key: string) => {
await deleteRuntimeConfig('onboarding', key);
refetch();
type OnboardingSchema = {
id: string;
title: string;
roleKey: string;
stepCount: number;
version: number;
status: string;
};
async function loadSchemas(): Promise<OnboardingSchema[]> {
try {
const res = await fetch(`${API}/api/admin/onboarding-config`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
const rows = Array.isArray(data) ? data : (data.schemas || data.configs || []);
return rows.map((item: any) => ({
id: item.id,
title: item.schema_json?.title || item.title || item.schema_id || 'Untitled Flow',
roleKey: item.schema_json?.roleKey || item.role_key || '',
stepCount: (item.schema_json?.steps || item.steps || []).length,
version: item.schema_json?.version || item.version || 1,
status: item.is_active ? 'PUBLISHED' : 'DRAFT',
}));
} catch {
return [];
}
}
export default function OnboardingSchemasPage() {
const navigate = useNavigate();
const [schemas, { refetch }] = createResource(loadSchemas);
const [deleteError, setDeleteError] = createSignal('');
const [deleting, setDeleting] = createSignal('');
const handleDelete = async (id: string, title: string) => {
if (!confirm(`Delete onboarding flow "${title}"?`)) return;
setDeleting(id);
setDeleteError('');
try {
const res = await fetch(`${API}/api/admin/onboarding-config/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete');
refetch();
} catch (err: any) {
setDeleteError(err.message || 'Failed to delete onboarding flow');
} finally {
setDeleting('');
}
};
return (
<AdminShell>
<h1 class="page-title">Onboarding Management</h1>
<p class="page-subtitle">Manage runtime onboarding schemas used by external roles.</p>
<section class="card">
<div class="list-header">
<h2>Runtime Onboarding Schemas</h2>
<A class="btn primary" href="/admin/onboarding-schemas/new">Create Onboarding Flow</A>
<div class="page-actions">
<div>
<h1 class="page-title">Onboarding Management</h1>
<p class="page-subtitle">Manage onboarding flows, role assignments, and step groups for external users.</p>
</div>
<Show when={items.loading}>
<p class="notice">Loading onboarding schemas from database...</p>
</Show>
<Show when={!items.loading && items() && items()?.length === 0}>
<p class="notice">No runtime onboarding schemas found yet.</p>
</Show>
<Show when={!items.loading && items() && items()!.length > 0}>
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Schema</th>
<th>Status</th>
<th>Updated</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
{items()!.map((item) => (
<A class="btn navy" href="/admin/onboarding-schemas/new">Create Onboarding Flow</A>
</div>
<Show when={deleteError()}>
<div class="error-box">{deleteError()}</div>
</Show>
<section class="card" style="padding: 0; overflow: hidden;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid #e2e8f0">
<h2 style="margin:0;font-size:17px;font-weight:700">Onboarding Flows</h2>
<Show when={!schemas.loading}>
<span style="font-size:13px;color:#64748b">{schemas()?.length || 0} flows</span>
</Show>
</div>
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Flow</th>
<th>Role</th>
<th>Steps</th>
<th>Version</th>
<th>Status</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={schemas.loading}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#64748b">Loading onboarding flows...</td></tr>
</Show>
<Show when={!schemas.loading && schemas.error}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#b91c1c">Failed to load onboarding schemas. Is the backend running?</td></tr>
</Show>
<Show when={!schemas.loading && !schemas.error && schemas()?.length === 0}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#94a3b8">No onboarding flows created yet.</td></tr>
</Show>
<Show when={!schemas.loading && !schemas.error && (schemas()?.length ?? 0) > 0}>
{schemas()!.map((schema) => (
<tr>
<td>{item.key}</td>
<td><span class={`status-chip ${item.status === 'published' ? 'active' : ''}`}>{item.status}</span></td>
<td>{new Date(item.updatedAt).toLocaleDateString()}</td>
<td style="font-weight:600;color:#0f172a">{schema.title}</td>
<td style="color:#475569">{schema.roleKey || '—'}</td>
<td style="color:#475569">{schema.stepCount}</td>
<td style="color:#475569">v{schema.version}</td>
<td>
<span class={`status-chip ${schema.status === 'PUBLISHED' ? 'active' : ''}`}>{schema.status}</span>
</td>
<td>
<div class="table-actions">
<A class="btn" href={`/admin/onboarding-schemas/${encodeURIComponent(item.key)}`}>Edit</A>
<button class="btn" onClick={() => onDelete(item.key)}>Delete</button>
<button class="btn" onClick={() => navigate(`/admin/onboarding-schemas/${schema.id}`)}>Open</button>
<button
class="btn danger"
disabled={deleting() === schema.id}
onClick={() => handleDelete(schema.id, schema.title)}
>
{deleting() === schema.id ? '...' : 'Delete'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Show>
</Show>
</tbody>
</table>
</div>
</section>
</AdminShell>
);

View file

@ -1,127 +1,256 @@
import { createSignal } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { createMemo, createSignal, For, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import { saveRuntimeConfig } from '~/lib/runtime/storage';
import type { RuntimeOnboardingConfig, RuntimeOnboardingStep } from '~/lib/runtime/types';
function buildDefaultConfig(): RuntimeOnboardingConfig {
return {
schemaId: 'photographer_onboarding_v1',
roleKey: 'PHOTOGRAPHER',
version: 1,
steps: [
{
id: 'step_1_service',
title: 'Select Service Category',
fields: [
{
id: 'profession',
label: 'Service Category',
type: 'select',
required: true,
options: [
{ label: 'Photographer', value: 'Photographer' },
{ label: 'Makeup Artist', value: 'Makeup Artist' },
],
},
],
},
],
const API = '/api/gateway';
const EXTERNAL_ROLES = ['company', 'job_seeker', 'customer', 'photographer', 'video_editor', 'graphic_designer', 'social_media_manager', 'fitness_trainer', 'catering_services', 'makeup_artist', 'tutor', 'developer'];
const FIELD_TYPES = ['text', 'textarea', 'number', 'email', 'tel', 'date', 'select', 'url', 'file', 'checkbox'] as const;
type FieldType = typeof FIELD_TYPES[number];
type OnboardingField = {
id: string;
label: string;
type: FieldType;
required: boolean;
placeholder?: string;
helperText?: string;
options?: { label: string; value: string }[];
};
type OnboardingStep = {
id: string;
title: string;
description?: string;
fields: OnboardingField[];
};
const makeId = (prefix: string) => `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
function makeDefaultStep(idx: number): OnboardingStep {
return { id: makeId('step'), title: `Step ${idx + 1}`, description: '', fields: [] };
}
export default function CreateOnboardingFlowPage() {
const navigate = useNavigate();
const [title, setTitle] = createSignal('');
const [roleKey, setRoleKey] = createSignal('company');
const [description, setDescription] = createSignal('');
const [finalMessage, setFinalMessage] = createSignal('Your onboarding has been submitted for review. We will notify you once it is approved.');
const [steps, setSteps] = createSignal<OnboardingStep[]>([makeDefaultStep(0), makeDefaultStep(1)]);
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal('');
const totalFields = createMemo(() => steps().reduce((sum, s) => sum + s.fields.length, 0));
const updateStep = (stepId: string, patch: Partial<OnboardingStep>) =>
setSteps((prev) => prev.map((s) => s.id === stepId ? { ...s, ...patch } : s));
const addStep = () => setSteps((prev) => [...prev, makeDefaultStep(prev.length)]);
const removeStep = (stepId: string) => setSteps((prev) => prev.filter((s) => s.id !== stepId));
const addField = (stepId: string) => {
const step = steps().find((s) => s.id === stepId)!;
updateStep(stepId, {
fields: [...step.fields, { id: makeId('fld'), label: 'New Field', type: 'text', required: false, placeholder: '' }],
});
};
}
function stringifySteps(steps: RuntimeOnboardingStep[]): string {
return JSON.stringify(steps, null, 2);
}
const removeField = (stepId: string, fieldId: string) => {
const step = steps().find((s) => s.id === stepId)!;
updateStep(stepId, { fields: step.fields.filter((f) => f.id !== fieldId) });
};
export default function CreateOnboardingSchemaPage() {
const [config, setConfig] = createSignal<RuntimeOnboardingConfig>(buildDefaultConfig());
const [stepsJson, setStepsJson] = createSignal(stringifySteps(buildDefaultConfig().steps));
const [statusMessage, setStatusMessage] = createSignal('');
const [stepsError, setStepsError] = createSignal('');
const updateField = (stepId: string, fieldId: string, patch: Partial<OnboardingField>) => {
const step = steps().find((s) => s.id === stepId)!;
updateStep(stepId, { fields: step.fields.map((f) => f.id === fieldId ? { ...f, ...patch } : f) });
};
const syncSteps = (raw: string) => {
setStepsJson(raw);
const handleSubmit = async (status: 'draft' | 'published') => {
if (!title().trim()) { setError('Flow title is required'); return; }
if (steps().length === 0) { setError('Add at least one step'); return; }
try {
const parsed = JSON.parse(raw) as RuntimeOnboardingStep[];
if (!Array.isArray(parsed)) {
setStepsError('Steps JSON must be an array.');
return;
setSaving(true);
setError('');
const payload = {
title: title().trim(),
roleKey: roleKey(),
description: description().trim(),
finalSubmissionMessage: finalMessage(),
version: 1,
steps: steps(),
is_active: status === 'published',
};
const res = await fetch(`${API}/api/admin/onboarding-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schema_json: payload, is_active: status === 'published' }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message || 'Failed to create onboarding flow');
}
setConfig({ ...config(), steps: parsed });
setStepsError('');
} catch {
setStepsError('Invalid JSON. Fix syntax before saving.');
}
};
const [isSaving, setIsSaving] = createSignal(false);
const persist = async (status: 'draft' | 'published') => {
const payload = config();
if (!payload.schemaId.trim()) {
setStatusMessage('Schema ID is required before saving.');
return;
}
if (stepsError()) {
setStatusMessage('Fix steps JSON errors before saving.');
return;
}
if (payload.steps.length === 0) {
setStatusMessage('Add at least one onboarding step before saving.');
return;
}
setIsSaving(true);
setStatusMessage('Saving to backend...');
try {
await saveRuntimeConfig('onboarding', payload.schemaId, payload, status);
setStatusMessage(status === 'draft' ? 'Draft saved successfully.' : 'Onboarding schema published successfully.');
} catch (err) {
setStatusMessage(`Error: ${err instanceof Error ? err.message : String(err)}`);
navigate('/admin/onboarding-schemas');
} catch (err: any) {
setError(err.message || 'Failed to create onboarding flow');
} finally {
setIsSaving(false);
setSaving(false);
}
};
return (
<AdminShell>
<h1 class="page-title">Create Onboarding Flow</h1>
<p class="page-subtitle">All onboarding fields, validations, upload rules, and select behaviors are runtime schema-driven.</p>
<div class="grid">
<section class="card">
<h2>Onboarding Builder</h2>
<div class="page-actions">
<div>
<h1 class="page-title">Create Onboarding Flow</h1>
<p class="page-subtitle">Build one onboarding flow at a time. Start with the role, add steps, then configure fields for each step.</p>
</div>
<a class="btn" href="/admin/onboarding-schemas">Back to Onboarding</a>
</div>
<Show when={error()}>
<div class="error-box">{error()}</div>
</Show>
{/* Info stats */}
<div class="onboarding-info-grid">
<div class="onboarding-stat">
<div class="stat-label">Role</div>
<div class="stat-value" style="font-size:14px;margin-top:6px">{roleKey().replace(/_/g, ' ').toUpperCase()}</div>
</div>
<div class="onboarding-stat">
<div class="stat-label">Steps</div>
<div class="stat-value">{steps().length}</div>
</div>
<div class="onboarding-stat">
<div class="stat-label">Total Fields</div>
<div class="stat-value">{totalFields()}</div>
</div>
</div>
{/* Flow metadata */}
<div class="role-form-section">
<h3>Flow Details</h3>
<p>Configure the flow title, target role, and final submission message.</p>
<div class="field-grid-2">
<div class="field">
<label>Schema ID</label>
<input value={config().schemaId} onInput={(e) => setConfig({ ...config(), schemaId: e.currentTarget.value })} />
<label>Flow Title <span style="color:#ef4444">*</span></label>
<input value={title()} onInput={(e) => setTitle(e.currentTarget.value)} placeholder="e.g. Photographer Onboarding" />
</div>
<div class="field">
<label>Role Key</label>
<input value={config().roleKey} onInput={(e) => setConfig({ ...config(), roleKey: e.currentTarget.value.toUpperCase() })} />
<label>Target Role</label>
<select value={roleKey()} onChange={(e) => setRoleKey(e.currentTarget.value)}>
{EXTERNAL_ROLES.map((r) => <option value={r}>{r.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())}</option>)}
</select>
</div>
<div class="field">
<label>Version</label>
<input type="number" value={config().version} onInput={(e) => setConfig({ ...config(), version: Number(e.currentTarget.value || 1) })} />
<label>Description</label>
<input value={description()} onInput={(e) => setDescription(e.currentTarget.value)} placeholder="Short description of this onboarding flow" />
</div>
<div class="field">
<label>Steps JSON (full runtime schema)</label>
<textarea class="json-input" rows={18} value={stepsJson()} onInput={(e) => syncSteps(e.currentTarget.value)} />
{stepsError() && <p class="error-note">{stepsError()}</p>}
<label>Final Submission Message</label>
<textarea rows={2} value={finalMessage()} onInput={(e) => setFinalMessage(e.currentTarget.value)} />
</div>
<div class="actions">
<button class="btn" onClick={() => persist('draft')} disabled={isSaving()}>
{isSaving() ? 'Saving...' : 'Save Draft'}
</button>
<button class="btn primary" onClick={() => persist('published')} disabled={isSaving()}>
{isSaving() ? 'Publishing...' : 'Publish'}
</button>
</div>
</div>
{/* Steps */}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<h2 style="margin:0;font-size:18px;font-weight:700">Steps & Fields</h2>
<button class="btn orange" onClick={addStep}>Add Step</button>
</div>
<For each={steps()}>
{(step, idx) => (
<div class="step-builder">
<div class="step-header">
<div class="step-num">{idx() + 1}</div>
<input
class="step-title-input"
value={step.title}
onInput={(e) => updateStep(step.id, { title: e.currentTarget.value })}
placeholder={`Step ${idx() + 1} title`}
/>
<input
value={step.description || ''}
onInput={(e) => updateStep(step.id, { description: e.currentTarget.value })}
placeholder="Step description (optional)"
style="border:1px solid #cbd5e1;border-radius:12px;padding:8px 12px;font-size:13px;outline:none;flex:1"
/>
<button class="btn danger" style="font-size:12px;padding:6px 10px" onClick={() => removeStep(step.id)}>Remove Step</button>
</div>
{/* Fields */}
<div style="margin-top:4px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<p style="margin:0;font-size:13px;font-weight:600;color:#334155">{step.fields.length} field{step.fields.length !== 1 ? 's' : ''}</p>
<button class="btn orange" style="font-size:12px;padding:5px 10px" onClick={() => addField(step.id)}>Add Field</button>
</div>
<For each={step.fields}>
{(field) => (
<div class="field-row">
<div style="display:flex;flex-direction:column;gap:4px">
<input
value={field.label}
onInput={(e) => updateField(step.id, field.id, { label: e.currentTarget.value })}
placeholder="Field label"
style="width:100%"
/>
<input
value={field.placeholder || ''}
onInput={(e) => updateField(step.id, field.id, { placeholder: e.currentTarget.value })}
placeholder="Placeholder text"
style="width:100%;font-size:12px"
/>
</div>
<select
class="field-type-select"
value={field.type}
onChange={(e) => updateField(step.id, field.id, { type: e.currentTarget.value as FieldType })}
>
{FIELD_TYPES.map((t) => <option value={t}>{t}</option>)}
</select>
<label class="checkbox-label" style="justify-content:center">
<input
type="checkbox"
checked={field.required}
onChange={(e) => updateField(step.id, field.id, { required: e.currentTarget.checked })}
/>
Required
</label>
<button class="btn danger" style="font-size:12px;padding:5px 10px" onClick={() => removeField(step.id, field.id)}></button>
</div>
)}
</For>
<Show when={step.fields.length === 0}>
<div style="border:1px dashed #cbd5e1;border-radius:10px;padding:16px;text-align:center;font-size:13px;color:#94a3b8">
No fields in this step. Click "Add Field" to add the first field.
</div>
</Show>
</div>
</div>
{statusMessage() && <p class="inline-note">{statusMessage()}</p>}
</section>
<section class="card">
<h2>Runtime Config Preview</h2>
<pre class="json">{JSON.stringify(config(), null, 2)}</pre>
</section>
)}
</For>
<Show when={steps().length === 0}>
<div style="border:1px dashed #cbd5e1;border-radius:12px;padding:32px;text-align:center;font-size:13px;color:#94a3b8">
No steps yet. Click "Add Step" above to get started.
</div>
</Show>
{/* Save actions */}
<div class="actions" style="justify-content:flex-end;margin-top:16px">
<button class="btn" onClick={() => handleSubmit('draft')} disabled={saving()}>
{saving() ? 'Saving...' : 'Save as Draft'}
</button>
<button class="btn primary" onClick={() => handleSubmit('published')} disabled={saving()}>
{saving() ? 'Publishing...' : 'Publish Flow'}
</button>
</div>
</AdminShell>
);

144
src/routes/admin/order.tsx Normal file
View file

@ -0,0 +1,144 @@
import { createResource, createSignal, createMemo, Show, For } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
type Order = {
id: string;
order_number?: string;
user_name?: string;
user_email?: string;
package_name?: string;
tracecoin_amount?: number;
coupon_code?: string;
total?: number;
amount?: number;
status: string;
created_at?: string;
};
async function loadOrders(): Promise<Order[]> {
try {
const res = await fetch(`${API}/api/admin/orders`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.orders || []);
} catch {
return [];
}
}
function statusClass(status: string): string {
const s = (status || '').toUpperCase();
if (s === 'PAID' || s === 'COMPLETED') return 'active';
if (s === 'PENDING') return 'pending';
if (s === 'FAILED') return 'failed';
return '';
}
function statusStyle(status: string): string {
const s = (status || '').toUpperCase();
if (s === 'PAID' || s === 'COMPLETED') return 'background:#dcfce7;color:#166534;border-color:#bbf7d0';
if (s === 'PENDING') return 'background:#fff7ed;color:#c2410c;border-color:#fed7aa';
if (s === 'FAILED') return 'background:#fee2e2;color:#991b1b;border-color:#fecaca';
if (s === 'REFUNDED') return 'background:#f1f5f9;color:#475569;border-color:#e2e8f0';
return '';
}
export default function OrderPage() {
const [orders] = createResource(loadOrders);
const [search, setSearch] = createSignal('');
const filtered = createMemo(() => {
const q = search().toLowerCase().trim();
const all = orders() ?? [];
if (!q) return all;
return all.filter((o) => {
const orderNum = (o.order_number || o.id || '').toLowerCase();
const email = (o.user_email || '').toLowerCase();
const name = (o.user_name || '').toLowerCase();
const status = (o.status || '').toLowerCase();
return orderNum.includes(q) || email.includes(q) || name.includes(q) || status.includes(q);
});
});
const formatAmount = (order: Order) => {
const raw = order.total ?? order.amount;
if (raw == null) return '—';
return `${(raw / 100).toFixed(2)}`;
};
return (
<AdminShell>
<div class="page-actions">
<div>
<h1 class="page-title">Order Management</h1>
<p class="page-subtitle">TraceCoin package purchase orders</p>
</div>
</div>
<div style="margin-bottom:16px">
<input
type="text"
placeholder="Search by order number, user email, or status..."
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
style="width:100%;max-width:420px;padding:8px 12px;border:1px solid #e2e8f0;border-radius:8px;font-size:14px"
/>
</div>
<section class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Order #</th>
<th>User</th>
<th>Package</th>
<th>TraceCoins</th>
<th>Coupon</th>
<th>Total</th>
<th>Status</th>
<th>Created At</th>
</tr>
</thead>
<tbody>
<Show when={orders.loading}>
<tr><td colspan="8" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!orders.loading && orders.error}>
<tr><td colspan="8" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
</Show>
<Show when={!orders.loading && !orders.error && filtered().length === 0}>
<tr><td colspan="8" style="text-align:center;padding:32px;color:#94a3b8">No orders found.</td></tr>
</Show>
<Show when={!orders.loading && !orders.error && filtered().length > 0}>
<For each={filtered()}>
{(item) => (
<tr>
<td style="font-weight:600;color:#0f172a;font-family:monospace">{item.order_number || item.id}</td>
<td style="color:#475569">{item.user_name || item.user_email || '—'}</td>
<td style="color:#475569">{item.package_name || '—'}</td>
<td style="color:#475569">{item.tracecoin_amount ?? '—'}</td>
<td style="color:#475569">{item.coupon_code || '—'}</td>
<td style="color:#475569">{formatAmount(item)}</td>
<td>
<span
class="status-chip"
style={`${statusStyle(item.status)};padding:2px 10px;border-radius:999px;font-size:12px;font-weight:600;border:1px solid`}
>
{item.status || '—'}
</span>
</td>
<td style="color:#475569">{item.created_at ? new Date(item.created_at).toLocaleString() : '—'}</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</AdminShell>
);
}

View file

@ -0,0 +1,124 @@
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 [];
}
}
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="page-actions">
<div>
<h1 class="page-title">Photographer Management</h1>
<p class="page-subtitle">Manage all photographer accounts on the platform.</p>
</div>
</div>
<section class="card" style="padding: 0; overflow: hidden;">
<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="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Registered</th>
<th class="align-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 photographer users found.</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length > 0}>
<For each={filtered()}>
{(item) => (
<tr>
<td style="font-weight:600;color:#0f172a">{item.name || item.full_name || '—'}</td>
<td style="color:#475569">{item.email}</td>
<td>
{item.status?.toUpperCase() === 'ACTIVE' && (
<span class="status-chip active">ACTIVE</span>
)}
{item.status?.toUpperCase() === 'INACTIVE' && (
<span class="status-chip">INACTIVE</span>
)}
{item.status?.toUpperCase() === 'PENDING' && (
<span class="status-chip" style="background:#fff7ed;color:#c2410c;border-color:#fed7aa;">PENDING</span>
)}
{!item.status && <span class="status-chip"></span>}
</td>
<td style="color:#475569">
{item.created_at ? new Date(item.created_at).toLocaleDateString() : '—'}
</td>
<td>
<div class="table-actions">
<A class="btn" href={`/admin/users/${item.id}`}>View</A>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</AdminShell>
);
}

View file

@ -0,0 +1,361 @@
import { createResource, createSignal, Show, For } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
type Package = {
id: string;
name: string;
role: string;
tracecoin_amount: number;
price_inr: number;
bonus_percentage?: number;
is_active: boolean;
};
const ROLES = [
'company',
'customer',
'job_seeker',
'photographer',
'video_editor',
'graphic_designer',
'social_media_manager',
'fitness_trainer',
'catering_services',
'makeup_artist',
'tutor',
'developer',
];
async function loadPackages(): Promise<Package[]> {
try {
const res = await fetch(`${API}/api/admin/tracecoin-packages`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.packages || []);
} catch {
return [];
}
}
export default function PricingPage() {
const [packages, { refetch }] = createResource(loadPackages);
const [view, setView] = createSignal<'packages' | 'create'>('packages');
// Inline edit state
const [editingId, setEditingId] = createSignal('');
const [editName, setEditName] = createSignal('');
const [editTracecoins, setEditTracecoins] = createSignal('');
const [editPrice, setEditPrice] = createSignal('');
const [editSaving, setEditSaving] = createSignal(false);
const [editError, setEditError] = createSignal('');
// Toggle active state
const [togglingId, setTogglingId] = createSignal('');
// Create form state
const [cName, setCName] = createSignal('');
const [cRole, setCRole] = createSignal(ROLES[0]);
const [cTracecoins, setCTracecoins] = createSignal('');
const [cPrice, setCPrice] = createSignal('');
const [cBonus, setCBonus] = createSignal('');
const [cSaving, setCsaving] = createSignal(false);
const [cError, setCError] = createSignal('');
const startEdit = (pkg: Package) => {
setEditingId(pkg.id);
setEditName(pkg.name);
setEditTracecoins(String(pkg.tracecoin_amount));
setEditPrice(String(pkg.price_inr));
setEditError('');
};
const cancelEdit = () => {
setEditingId('');
setEditError('');
};
const saveEdit = async (id: string) => {
try {
setEditSaving(true);
setEditError('');
const res = await fetch(`${API}/api/admin/tracecoin-packages/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: editName(),
tracecoin_amount: Number(editTracecoins()),
price_inr: Number(editPrice()),
}),
});
if (!res.ok) throw new Error('Failed to save');
setEditingId('');
refetch();
} catch (err: any) {
setEditError(err.message || 'Failed to save');
} finally {
setEditSaving(false);
}
};
const toggleActive = async (pkg: Package) => {
try {
setTogglingId(pkg.id);
const res = await fetch(`${API}/api/admin/tracecoin-packages/${pkg.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !pkg.is_active }),
});
if (!res.ok) throw new Error('Failed to update');
refetch();
} catch {
// ignore
} finally {
setTogglingId('');
}
};
const handleCreate = async (e: Event) => {
e.preventDefault();
try {
setCsaving(true);
setCError('');
const body: Record<string, any> = {
name: cName(),
role: cRole(),
tracecoin_amount: Number(cTracecoins()),
price_inr: Number(cPrice()),
};
if (cBonus()) body.bonus_percentage = Number(cBonus());
const res = await fetch(`${API}/api/admin/tracecoin-packages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error('Failed to create package');
setCName('');
setCRole(ROLES[0]);
setCTracecoins('');
setCPrice('');
setCBonus('');
setView('packages');
refetch();
} catch (err: any) {
setCError(err.message || 'Failed to create');
} finally {
setCsaving(false);
}
};
return (
<AdminShell>
<div class="page-actions">
<div>
<h1 class="page-title">Pricing Management</h1>
<p class="page-subtitle">Create and manage TraceCoin packages</p>
</div>
</div>
{/* Tabs */}
<div style="display:flex;border-bottom:2px solid #e2e8f0;margin-bottom:24px;gap:0;overflow-x:auto;">
<button
type="button"
class={`admin-tab${view() === 'packages' ? ' active' : ''}`}
onClick={() => setView('packages')}
>
Packages
</button>
<button
type="button"
class={`admin-tab${view() === 'create' ? ' active' : ''}`}
onClick={() => setView('create')}
>
Create Package
</button>
</div>
{/* Packages list tab */}
<Show when={view() === 'packages'}>
<section class="card" style="padding:0;overflow:hidden">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Name</th>
<th>Role</th>
<th>TraceCoins</th>
<th>Price ()</th>
<th>Bonus (%)</th>
<th>Status</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={packages.loading}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!packages.loading && packages.error}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
</Show>
<Show when={!packages.loading && !packages.error && (packages()?.length ?? 0) === 0}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">No packages found.</td></tr>
</Show>
<Show when={!packages.loading && !packages.error && (packages()?.length ?? 0) > 0}>
<For each={packages()}>
{(pkg) => (
<>
<tr>
<td style="font-weight:600;color:#0f172a">{pkg.name}</td>
<td style="color:#475569">{pkg.role}</td>
<td style="color:#475569">{pkg.tracecoin_amount}</td>
<td style="color:#475569">{(pkg.price_inr / 100).toFixed(2)}</td>
<td style="color:#475569">{pkg.bonus_percentage != null ? `${pkg.bonus_percentage}%` : '—'}</td>
<td>
<span class={`status-chip ${pkg.is_active ? 'active' : ''}`}>
{pkg.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td>
<div class="table-actions">
<button class="btn" onClick={() => startEdit(pkg)}>Edit</button>
<button
class={pkg.is_active ? 'btn danger' : 'btn navy'}
disabled={togglingId() === pkg.id}
onClick={() => toggleActive(pkg)}
>
{togglingId() === pkg.id ? '...' : pkg.is_active ? 'Disable' : 'Enable'}
</button>
</div>
</td>
</tr>
<Show when={editingId() === pkg.id}>
<tr>
<td colspan="7" style="background:#f8fafc;padding:16px">
<Show when={editError()}>
<div class="error-box" style="margin-bottom:10px">{editError()}</div>
</Show>
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end">
<div class="field">
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">Name</label>
<input
type="text"
value={editName()}
onInput={(e) => setEditName(e.currentTarget.value)}
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:180px"
/>
</div>
<div class="field">
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">TraceCoins</label>
<input
type="number"
value={editTracecoins()}
onInput={(e) => setEditTracecoins(e.currentTarget.value)}
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:110px"
/>
</div>
<div class="field">
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">Price (paise)</label>
<input
type="number"
value={editPrice()}
onInput={(e) => setEditPrice(e.currentTarget.value)}
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:110px"
/>
</div>
<div style="display:flex;gap:8px">
<button class="btn navy" disabled={editSaving()} onClick={() => saveEdit(pkg.id)}>
{editSaving() ? 'Saving...' : 'Save'}
</button>
<button class="btn" onClick={cancelEdit}>Cancel</button>
</div>
</div>
</td>
</tr>
</Show>
</>
)}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</Show>
{/* Create Package tab */}
<Show when={view() === 'create'}>
<section class="card" style="max-width:480px">
<h2 style="margin:0 0 20px;font-size:16px;font-weight:700">New Package</h2>
<Show when={cError()}>
<div class="error-box" style="margin-bottom:14px">{cError()}</div>
</Show>
<form onSubmit={handleCreate} style="display:flex;flex-direction:column;gap:14px">
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Name</label>
<input
type="text"
value={cName()}
onInput={(e) => setCName(e.currentTarget.value)}
required
placeholder="e.g. Starter Pack"
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
/>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Role</label>
<select
value={cRole()}
onChange={(e) => setCRole(e.currentTarget.value)}
required
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
>
<For each={ROLES}>{(r) => <option value={r}>{r}</option>}</For>
</select>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">TraceCoins</label>
<input
type="number"
value={cTracecoins()}
onInput={(e) => setCTracecoins(e.currentTarget.value)}
required
min="1"
placeholder="e.g. 100"
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
/>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Price INR (paise, e.g. 49900 = 499)</label>
<input
type="number"
value={cPrice()}
onInput={(e) => setCPrice(e.currentTarget.value)}
required
min="1"
placeholder="e.g. 49900"
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
/>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Bonus Percentage (optional, e.g. 10 = 10% bonus coins)</label>
<input
type="number"
value={cBonus()}
onInput={(e) => setCBonus(e.currentTarget.value)}
min="0"
placeholder="e.g. 10"
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
/>
</div>
<div>
<button class="btn navy" type="submit" disabled={cSaving()}>
{cSaving() ? 'Creating...' : 'Create Package'}
</button>
</div>
</form>
</section>
</Show>
</AdminShell>
);
}

120
src/routes/admin/report.tsx Normal file
View file

@ -0,0 +1,120 @@
import { createSignal, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
type UserReport = {
total_users?: number;
new_users?: number;
active_users?: number;
};
type RevenueReport = {
total_revenue?: number;
total_orders?: number;
total_tracecoins_sold?: number;
};
export default function ReportPage() {
const [from, setFrom] = createSignal('');
const [to, setTo] = createSignal('');
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal('');
const [userReport, setUserReport] = createSignal<UserReport | null>(null);
const [revenueReport, setRevenueReport] = createSignal<RevenueReport | null>(null);
const handleLoad = async (e: Event) => {
e.preventDefault();
if (!from() || !to()) return;
try {
setLoading(true);
setError('');
const [usersRes, revenueRes] = await Promise.all([
fetch(`${API}/api/admin/reports/users?from=${from()}&to=${to()}`),
fetch(`${API}/api/admin/reports/revenue?from=${from()}&to=${to()}`),
]);
if (!usersRes.ok || !revenueRes.ok) throw new Error('Failed to load report data');
const [usersData, revenueData] = await Promise.all([usersRes.json(), revenueRes.json()]);
setUserReport(usersData);
setRevenueReport(revenueData);
} catch (err: any) {
setError(err.message || 'Failed to load reports');
} finally {
setLoading(false);
}
};
return (
<AdminShell>
<div class="page-actions">
<div>
<h1 class="page-title">Report Management</h1>
<p class="page-subtitle">View platform analytics and generate reports.</p>
</div>
</div>
<section class="card" style="margin-bottom:16px">
<h2 style="margin:0 0 16px;font-size:16px;font-weight:700">Date Range</h2>
<form onSubmit={handleLoad} style="display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap">
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">From</label>
<input
type="date"
value={from()}
onInput={(e) => setFrom(e.currentTarget.value)}
required
style="padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
/>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">To</label>
<input
type="date"
value={to()}
onInput={(e) => setTo(e.currentTarget.value)}
required
style="padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
/>
</div>
<button class="btn navy" type="submit" disabled={loading()}>
{loading() ? 'Loading...' : 'Load Report'}
</button>
</form>
<Show when={error()}>
<div class="error-box" style="margin-top:12px">{error()}</div>
</Show>
</section>
<Show when={userReport() || revenueReport()}>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;margin-bottom:16px">
<div class="card" style="text-align:center">
<p style="margin:0 0 8px;font-size:13px;color:#64748b;font-weight:600">Total Users</p>
<p style="margin:0;font-size:28px;font-weight:700;color:#0f172a">{userReport()?.total_users ?? '—'}</p>
</div>
<div class="card" style="text-align:center">
<p style="margin:0 0 8px;font-size:13px;color:#64748b;font-weight:600">New Users</p>
<p style="margin:0;font-size:28px;font-weight:700;color:#0f172a">{userReport()?.new_users ?? '—'}</p>
</div>
<div class="card" style="text-align:center">
<p style="margin:0 0 8px;font-size:13px;color:#64748b;font-weight:600">Active Users</p>
<p style="margin:0;font-size:28px;font-weight:700;color:#0f172a">{userReport()?.active_users ?? '—'}</p>
</div>
<div class="card" style="text-align:center">
<p style="margin:0 0 8px;font-size:13px;color:#64748b;font-weight:600">Total Revenue</p>
<p style="margin:0;font-size:28px;font-weight:700;color:#0f172a">
{revenueReport()?.total_revenue != null ? `${(revenueReport()!.total_revenue! / 100).toFixed(2)}` : ''}
</p>
</div>
<div class="card" style="text-align:center">
<p style="margin:0 0 8px;font-size:13px;color:#64748b;font-weight:600">Total Orders</p>
<p style="margin:0;font-size:28px;font-weight:700;color:#0f172a">{revenueReport()?.total_orders ?? '—'}</p>
</div>
<div class="card" style="text-align:center">
<p style="margin:0 0 8px;font-size:13px;color:#64748b;font-weight:600">TraceCoins Sold</p>
<p style="margin:0;font-size:28px;font-weight:700;color:#0f172a">{revenueReport()?.total_tracecoins_sold ?? '—'}</p>
</div>
</div>
</Show>
</AdminShell>
);
}

318
src/routes/admin/review.tsx Normal file
View file

@ -0,0 +1,318 @@
import { createResource, createSignal, createMemo, Show, For } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
type Review = {
id: string;
reviewer_name?: string;
reviewer_id?: string;
subject_type?: string;
rating?: number;
title?: string;
status: 'PUBLISHED' | 'HIDDEN' | string;
};
async function loadReviews(): Promise<Review[]> {
try {
const res = await fetch(`${API}/api/admin/reviews`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.reviews || []);
} catch {
return [];
}
}
function typeBadgeStyle(subjectType: string): string {
switch ((subjectType || '').toUpperCase()) {
case 'PLATFORM':
return 'background:#dbeafe;color:#1d4ed8;border-color:#bfdbfe';
case 'PACKAGE':
return 'background:#dcfce7;color:#166534;border-color:#bbf7d0';
case 'SUPPORT':
return 'background:#fff7ed;color:#c2410c;border-color:#fed7aa';
default:
return 'background:#f1f5f9;color:#475569;border-color:#e2e8f0';
}
}
const defaultForm = () => ({
subject_type: 'PLATFORM',
subject_id: 'general',
reviewer_name: '',
rating: 5,
title: '',
comment: '',
});
export default function ReviewPage() {
const [reviews, { refetch }] = createResource(loadReviews);
const [activeTab, setActiveTab] = createSignal<'list' | 'create'>('list');
const [search, setSearch] = createSignal('');
const [form, setForm] = createSignal(defaultForm());
const [saving, setSaving] = createSignal(false);
const [toggling, setToggling] = createSignal('');
const [formError, setFormError] = createSignal('');
const filteredReviews = createMemo(() => {
const q = search().toLowerCase();
const all = reviews() ?? [];
if (!q) return all;
return all.filter((r) =>
(r.reviewer_name || r.reviewer_id || '').toLowerCase().includes(q) ||
(r.title || '').toLowerCase().includes(q) ||
(r.subject_type || '').toLowerCase().includes(q) ||
(r.status || '').toLowerCase().includes(q)
);
});
const resetForm = () => {
setForm(defaultForm());
setFormError('');
};
const handleSave = async (e: Event) => {
e.preventDefault();
try {
setSaving(true);
setFormError('');
const f = form();
const res = await fetch(`${API}/api/admin/reviews`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subject_type: f.subject_type,
subject_id: f.subject_id,
reviewer_name: f.reviewer_name,
rating: Number(f.rating),
title: f.title,
comment: f.comment,
}),
});
if (!res.ok) throw new Error('Failed to create review');
resetForm();
refetch();
setActiveTab('list');
} catch (err: unknown) {
setFormError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setSaving(false);
}
};
const handleUpdateStatus = async (review: Review, status: 'PUBLISHED' | 'HIDDEN') => {
try {
setToggling(review.id);
const res = await fetch(`${API}/api/admin/reviews/${review.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
});
if (!res.ok) throw new Error('Failed to update');
refetch();
} catch {
// ignore
} finally {
setToggling('');
}
};
return (
<AdminShell>
<div class="page-actions">
<div>
<h1 class="page-title">Review Management</h1>
<p class="page-subtitle">Moderate platform reviews</p>
</div>
</div>
{/* Tabs */}
<div style="display:flex;border-bottom:2px solid #e2e8f0;margin-bottom:24px;gap:0;overflow-x:auto;">
<button
type="button"
class={`admin-tab${activeTab() === 'list' ? ' active' : ''}`}
onClick={() => setActiveTab('list')}
>
Reviews
</button>
<button
type="button"
class={`admin-tab${activeTab() === 'create' ? ' active' : ''}`}
onClick={() => { resetForm(); setActiveTab('create'); }}
>
Create Review
</button>
</div>
<Show when={activeTab() === 'list'}>
<div style="margin-bottom:16px">
<input
type="text"
placeholder="Search by reviewer, title, type, or status..."
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
style="padding:8px 12px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;min-width:320px"
/>
</div>
<section class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Reviewer</th>
<th>Type</th>
<th>Rating</th>
<th>Title</th>
<th>Status</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={reviews.loading}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!reviews.loading && reviews.error}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#b91c1c">Failed to load. Is the backend running?</td></tr>
</Show>
<Show when={!reviews.loading && !reviews.error && filteredReviews().length === 0}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#94a3b8">No reviews found.</td></tr>
</Show>
<Show when={!reviews.loading && !reviews.error && filteredReviews().length > 0}>
<For each={filteredReviews()}>
{(item) => {
const subjectType = item.subject_type || '—';
const isPublished = item.status === 'PUBLISHED';
return (
<tr>
<td style="font-weight:600;color:#0f172a">{item.reviewer_name || item.reviewer_id || '—'}</td>
<td>
<span style={`${typeBadgeStyle(subjectType)};padding:2px 10px;border-radius:999px;font-size:12px;font-weight:600;border:1px solid;display:inline-block`}>
{subjectType}
</span>
</td>
<td style="color:#475569">
{item.rating != null ? `${item.rating}` : '—'}
</td>
<td style="color:#475569">{item.title || '—'}</td>
<td>
<span class={`status-chip ${isPublished ? 'active' : ''}`}
style={isPublished ? '' : 'background:#f1f5f9;color:#475569;border-color:#e2e8f0'}
>
{isPublished ? 'Published' : 'Hidden'}
</span>
</td>
<td>
<div class="table-actions">
<Show when={isPublished}>
<button
class="btn"
disabled={toggling() === item.id}
onClick={() => handleUpdateStatus(item, 'HIDDEN')}
>
{toggling() === item.id ? '...' : 'Hide'}
</button>
</Show>
<Show when={!isPublished}>
<button
class="btn navy"
disabled={toggling() === item.id}
onClick={() => handleUpdateStatus(item, 'PUBLISHED')}
>
{toggling() === item.id ? '...' : 'Publish'}
</button>
</Show>
</div>
</td>
</tr>
);
}}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</Show>
<Show when={activeTab() === 'create'}>
<section class="card" style="max-width:520px">
<h2 style="margin:0 0 20px;font-size:16px;font-weight:700">Create Review</h2>
<Show when={formError()}>
<div class="error-box" style="margin-bottom:12px">{formError()}</div>
</Show>
<form onSubmit={handleSave} style="display:flex;flex-direction:column;gap:14px">
<div class="field">
<label>Subject Type</label>
<select
value={form().subject_type}
onChange={(e) => setForm({ ...form(), subject_type: e.currentTarget.value })}
>
<option value="PLATFORM">PLATFORM</option>
<option value="PACKAGE">PACKAGE</option>
<option value="SUPPORT">SUPPORT</option>
</select>
</div>
<div class="field">
<label>Subject ID</label>
<input
type="text"
value={form().subject_id}
onInput={(e) => setForm({ ...form(), subject_id: e.currentTarget.value })}
placeholder="e.g. general or package UUID"
/>
</div>
<div class="field">
<label>Reviewer Name</label>
<input
type="text"
value={form().reviewer_name}
onInput={(e) => setForm({ ...form(), reviewer_name: e.currentTarget.value })}
required
placeholder="Full name"
/>
</div>
<div class="field">
<label>Rating</label>
<select
value={form().rating}
onChange={(e) => setForm({ ...form(), rating: Number(e.currentTarget.value) })}
>
<option value={5}>5 Excellent</option>
<option value={4}>4 Good</option>
<option value={3}>3 Average</option>
<option value={2}>2 Poor</option>
<option value={1}>1 Terrible</option>
</select>
</div>
<div class="field">
<label>Title</label>
<input
type="text"
value={form().title}
onInput={(e) => setForm({ ...form(), title: e.currentTarget.value })}
required
placeholder="Review title"
/>
</div>
<div class="field">
<label>Comment</label>
<textarea
value={form().comment}
onInput={(e) => setForm({ ...form(), comment: e.currentTarget.value })}
rows={4}
placeholder="Detailed review..."
style="resize:vertical"
/>
</div>
<div class="actions">
<button class="btn navy" type="submit" disabled={saving()}>
{saving() ? 'Saving...' : 'Save Review'}
</button>
</div>
</form>
</section>
</Show>
</AdminShell>
);
}

View file

@ -0,0 +1,234 @@
import { useNavigate, useParams } from '@solidjs/router';
import { createEffect, createMemo, createResource, createSignal, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
type Permission = { id: string; module: string; action: string };
type Role = { id: string; name: string; description?: string; permissions: Permission[] };
async function loadRoleAndPerms(id: string) {
try {
const [roleRes, permsRes] = await Promise.all([
fetch(`${API}/api/admin/roles/${id}`),
fetch(`${API}/api/admin/permissions`),
]);
if (!roleRes.ok) return null;
const role: Role = await roleRes.json();
const permsData = permsRes.ok ? await permsRes.json() : { permissions: [] };
const allPerms: Permission[] = Array.isArray(permsData) ? permsData : (permsData.permissions || []);
return { role, allPerms };
} catch {
return null;
}
}
export default function EditInternalRolePage() {
const params = useParams();
const navigate = useNavigate();
const [data] = createResource(() => params.id, loadRoleAndPerms);
const [roleName, setRoleName] = createSignal('');
const [description, setDescription] = createSignal('');
const [assignedModules, setAssignedModules] = createSignal<string[]>([]);
const [permissionIds, setPermissionIds] = createSignal<string[]>([]);
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal('');
// Pre-populate from loaded data
createEffect(() => {
const d = data();
if (!d) return;
setRoleName(d.role.name || '');
setDescription(d.role.description || '');
const ids = (d.role.permissions || []).map((p) => p.id);
setPermissionIds(ids);
const mods = [...new Set((d.role.permissions || []).map((p) => p.module))];
setAssignedModules(mods);
});
const permsByModule = createMemo(() => {
const map: Record<string, Permission[]> = {};
(data()?.allPerms || []).forEach((p) => {
if (!map[p.module]) map[p.module] = [];
map[p.module].push(p);
});
return map;
});
const allModules = createMemo(() => Object.keys(permsByModule()).sort());
const toggleModule = (mod: string) => {
const current = assignedModules();
if (current.includes(mod)) {
setAssignedModules(current.filter((m) => m !== mod));
const idsToRemove = (permsByModule()[mod] || []).map((p) => p.id);
setPermissionIds(permissionIds().filter((id) => !idsToRemove.includes(id)));
} else {
setAssignedModules([...current, mod]);
}
};
const togglePermission = (id: string) => {
const current = permissionIds();
if (current.includes(id)) {
setPermissionIds(current.filter((p) => p !== id));
} else {
setPermissionIds([...current, id]);
}
};
const handleSave = async () => {
if (!roleName().trim()) { setError('Role name is required'); return; }
if (assignedModules().length === 0) { setError('Select at least one module'); return; }
if (permissionIds().length === 0) { setError('Assign at least one permission'); return; }
try {
setSaving(true);
setError('');
const res = await fetch(`${API}/api/admin/roles/${params.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: roleName().trim(),
description: description().trim(),
audience: 'INTERNAL',
modules: assignedModules(),
permissionIds: permissionIds(),
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message || 'Failed to update role');
}
navigate(`/admin/roles/${params.id}`);
} catch (err: any) {
setError(err.message || 'Failed to update role');
} finally {
setSaving(false);
}
};
return (
<AdminShell>
<div class="page-actions">
<div>
<h1 class="page-title">Edit Internal Role</h1>
<p class="page-subtitle">Update role name, module access, and permissions.</p>
</div>
<a class="btn" href={`/admin/roles/${params.id}`}>Back to Role</a>
</div>
<Show when={data.loading}>
<div class="card"><p class="notice">Loading role...</p></div>
</Show>
<Show when={data.error}>
<div class="error-box">Failed to load role. Check that the backend is running.</div>
</Show>
<Show when={error()}>
<div class="error-box">{error()}</div>
</Show>
<Show when={!data.loading && data()}>
{/* Role Details */}
<div class="role-form-section">
<h3>Role Details</h3>
<p>Update the role name and description.</p>
<div class="field-grid-2">
<div class="field">
<label>Name of the Role <span style="color:#ef4444">*</span></label>
<input
value={roleName()}
onInput={(e) => setRoleName(e.currentTarget.value)}
placeholder="e.g. Customer Support Rep"
/>
</div>
<div class="field">
<label>Description</label>
<input
value={description()}
onInput={(e) => setDescription(e.currentTarget.value)}
placeholder="Short description of this role"
/>
</div>
</div>
</div>
{/* Module Access */}
<div class="role-form-section">
<h3>Module Access</h3>
<p>Select which modules this role can access.</p>
<div class="module-picker">
{allModules().map((mod) => (
<button
type="button"
class={`module-chip ${assignedModules().includes(mod) ? 'selected' : ''}`}
onClick={() => toggleModule(mod)}
>
<span style={`width:14px;height:14px;border-radius:3px;border:2px solid ${assignedModules().includes(mod) ? '#c2410c' : '#cbd5e1'};background:${assignedModules().includes(mod) ? '#c2410c' : '#fff'};flex-shrink:0;display:inline-block`} />
{mod}
</button>
))}
</div>
</div>
{/* Permission Table */}
<Show when={assignedModules().length > 0}>
<div class="role-form-section">
<h3>Permissions</h3>
<p>Set Read / Create / Update / Delete access for each assigned module.</p>
<div class="table-wrap">
<table class="perm-table">
<thead>
<tr>
<th style="width:45%">Name of the module</th>
<th style="width:11%">No Access</th>
<th style="width:11%">Read</th>
<th style="width:11%">Create</th>
<th style="width:11%">Update</th>
<th style="width:11%">Delete</th>
</tr>
</thead>
<tbody>
{assignedModules().sort().map((mod) => {
const perms = permsByModule()[mod] || [];
const actionMap: Record<string, string> = {};
perms.forEach((p) => { actionMap[p.action] = p.id; });
const hasRead = !!actionMap['Read'] && permissionIds().includes(actionMap['Read']);
const hasCreate = !!actionMap['Create'] && permissionIds().includes(actionMap['Create']);
const hasUpdate = !!actionMap['Update'] && permissionIds().includes(actionMap['Update']);
const hasDelete = !!actionMap['Delete'] && permissionIds().includes(actionMap['Delete']);
const noAccess = !hasRead && !hasCreate && !hasUpdate && !hasDelete;
return (
<tr>
<td style="font-weight:500">{mod}</td>
<td><input type="checkbox" checked={noAccess} disabled aria-label={`${mod} no access`} /></td>
<td>{actionMap['Read'] ? <input type="checkbox" checked={hasRead} onChange={() => togglePermission(actionMap['Read'])} aria-label={`${mod} read`} /> : <span style="color:#cbd5e1"></span>}</td>
<td>{actionMap['Create'] ? <input type="checkbox" checked={hasCreate} onChange={() => togglePermission(actionMap['Create'])} aria-label={`${mod} create`} /> : <span style="color:#cbd5e1"></span>}</td>
<td>{actionMap['Update'] ? <input type="checkbox" checked={hasUpdate} onChange={() => togglePermission(actionMap['Update'])} aria-label={`${mod} update`} /> : <span style="color:#cbd5e1"></span>}</td>
<td>{actionMap['Delete'] ? <input type="checkbox" checked={hasDelete} onChange={() => togglePermission(actionMap['Delete'])} aria-label={`${mod} delete`} /> : <span style="color:#cbd5e1"></span>}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</Show>
{/* Save */}
<div style="display:flex;justify-content:flex-end;margin-top:8px">
<button
class="btn navy"
onClick={handleSave}
disabled={saving() || !roleName().trim()}
>
{saving() ? 'Saving...' : 'Save Changes'}
</button>
</div>
</Show>
</AdminShell>
);
}

View file

@ -0,0 +1,131 @@
import { A, useParams } from '@solidjs/router';
import { createMemo, createResource, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
type Permission = { id: string; module: string; action: string };
type Role = { id: string; name: string; description?: string; permissions: Permission[] };
async function loadRoleDetail(id: string) {
try {
const [roleRes, permsRes] = await Promise.all([
fetch(`${API}/api/admin/roles/${id}`),
fetch(`${API}/api/admin/permissions`),
]);
if (!roleRes.ok) return null;
const role: Role = await roleRes.json();
const permsData = permsRes.ok ? await permsRes.json() : { permissions: [] };
const allPerms: Permission[] = Array.isArray(permsData) ? permsData : (permsData.permissions || []);
return { role, allPerms };
} catch {
return null;
}
}
export default function RoleDetailPage() {
const params = useParams();
const [data] = createResource(() => params.id, loadRoleDetail);
const grouped = createMemo(() => {
if (!data()?.allPerms) return {};
const map: Record<string, Permission[]> = {};
(data()!.allPerms).forEach((p) => {
if (!map[p.module]) map[p.module] = [];
map[p.module].push(p);
});
return map;
});
const rolePermIds = createMemo(() => new Set((data()?.role?.permissions || []).map((p) => p.id)));
const modules = createMemo(() => Object.keys(grouped()).sort());
return (
<AdminShell>
<div class="page-actions">
<div>
<h1 class="page-title">Role Details</h1>
<p class="page-subtitle">View role information and assigned permissions.</p>
</div>
<div class="page-actions-right">
<A class="btn" href="/admin/roles">Back to List</A>
<Show when={data()?.role}>
<A class="btn navy" href={`/admin/roles/${params.id}/edit`}>Edit Role</A>
</Show>
</div>
</div>
<Show when={data.loading}>
<div class="card">
<p class="notice">Loading role details...</p>
</div>
</Show>
<Show when={data.error}>
<div class="error-box">Failed to load role. Check that the backend is running.</div>
</Show>
<Show when={data()?.role}>
{/* Role Info */}
<div class="role-detail-card">
<div class="field-grid-2">
<div class="field">
<label>Role Name</label>
<input class="role-field-readonly" value={data()!.role.name} readOnly disabled />
</div>
<div class="field">
<label>Description</label>
<input class="role-field-readonly" value={data()!.role.description || '—'} readOnly disabled />
</div>
</div>
</div>
{/* Permission Matrix */}
<div class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrap">
<table class="perm-table">
<thead>
<tr>
<th style="width:45%">Name of the module</th>
<th style="width:11%">No Access</th>
<th style="width:11%">Read</th>
<th style="width:11%">Create</th>
<th style="width:11%">Update</th>
<th style="width:11%">Delete</th>
</tr>
</thead>
<tbody>
<Show when={modules().length === 0}>
<tr>
<td colspan="6" style="text-align:center;padding:24px;color:#94a3b8;">No permissions defined.</td>
</tr>
</Show>
{modules().map((mod) => {
const perms = grouped()[mod] || [];
const actionMap: Record<string, string> = {};
perms.forEach((p) => { actionMap[p.action] = p.id; });
const hasRead = !!actionMap['Read'] && rolePermIds().has(actionMap['Read']);
const hasCreate = !!actionMap['Create'] && rolePermIds().has(actionMap['Create']);
const hasUpdate = !!actionMap['Update'] && rolePermIds().has(actionMap['Update']);
const hasDelete = !!actionMap['Delete'] && rolePermIds().has(actionMap['Delete']);
const noAccess = !hasRead && !hasCreate && !hasUpdate && !hasDelete;
return (
<tr>
<td style="font-weight:500">{mod}</td>
<td><input type="checkbox" checked={noAccess} disabled aria-label={`${mod} no access`} /></td>
<td>{actionMap['Read'] ? <input type="checkbox" checked={hasRead} disabled aria-label={`${mod} read`} /> : <span style="color:#cbd5e1"></span>}</td>
<td>{actionMap['Create'] ? <input type="checkbox" checked={hasCreate} disabled aria-label={`${mod} create`} /> : <span style="color:#cbd5e1"></span>}</td>
<td>{actionMap['Update'] ? <input type="checkbox" checked={hasUpdate} disabled aria-label={`${mod} update`} /> : <span style="color:#cbd5e1"></span>}</td>
<td>{actionMap['Delete'] ? <input type="checkbox" checked={hasDelete} disabled aria-label={`${mod} delete`} /> : <span style="color:#cbd5e1"></span>}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</Show>
</AdminShell>
);
}

View file

@ -0,0 +1,211 @@
import { useNavigate } from '@solidjs/router';
import { createMemo, createResource, createSignal, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
type Permission = { id: string; module: string; action: string };
async function loadPermissions(): Promise<Permission[]> {
const res = await fetch(`${API}/api/admin/permissions`);
if (!res.ok) return [];
const data = await res.json();
const rows = Array.isArray(data) ? data : (data.permissions || []);
return rows;
}
export default function CreateInternalRolePage() {
const navigate = useNavigate();
const [permissions] = createResource(loadPermissions);
const [roleName, setRoleName] = createSignal('');
const [description, setDescription] = createSignal('');
const [assignedModules, setAssignedModules] = createSignal<string[]>([]);
const [permissionIds, setPermissionIds] = createSignal<string[]>([]);
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal('');
const permsByModule = createMemo(() => {
const map: Record<string, Permission[]> = {};
(permissions() || []).forEach((p) => {
if (!map[p.module]) map[p.module] = [];
map[p.module].push(p);
});
return map;
});
const allModules = createMemo(() => Object.keys(permsByModule()).sort());
const toggleModule = (mod: string) => {
const current = assignedModules();
if (current.includes(mod)) {
setAssignedModules(current.filter((m) => m !== mod));
// remove permissions for this module
const idsToRemove = (permsByModule()[mod] || []).map((p) => p.id);
setPermissionIds(permissionIds().filter((id) => !idsToRemove.includes(id)));
} else {
setAssignedModules([...current, mod]);
}
};
const togglePermission = (id: string) => {
const current = permissionIds();
if (current.includes(id)) {
setPermissionIds(current.filter((p) => p !== id));
} else {
setPermissionIds([...current, id]);
}
};
const handleSave = async () => {
if (!roleName().trim()) { setError('Role name is required'); return; }
if (assignedModules().length === 0) { setError('Select at least one module'); return; }
if (permissionIds().length === 0) { setError('Assign at least one permission'); return; }
try {
setSaving(true);
setError('');
const res = await fetch(`${API}/api/admin/roles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: roleName().trim(),
description: description().trim(),
audience: 'INTERNAL',
modules: assignedModules(),
permissionIds: permissionIds(),
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message || 'Failed to create role');
}
navigate('/admin/roles');
} catch (err: any) {
setError(err.message || 'Failed to create role');
} finally {
setSaving(false);
}
};
return (
<AdminShell>
<div class="page-actions">
<div>
<h1 class="page-title">Create Internal Role</h1>
<p class="page-subtitle">Add a new internal role with module access and permissions.</p>
</div>
<a class="btn" href="/admin/roles">Back to Roles</a>
</div>
<Show when={error()}>
<div class="error-box">{error()}</div>
</Show>
{/* Role Details */}
<div class="role-form-section">
<h3>Role Details</h3>
<p>Start by giving the role a simple business name.</p>
<div class="field-grid-2">
<div class="field">
<label>Name of the Role <span style="color:#ef4444">*</span></label>
<input
value={roleName()}
onInput={(e) => setRoleName(e.currentTarget.value)}
placeholder="e.g. Customer Support Rep"
/>
</div>
<div class="field">
<label>Description</label>
<input
value={description()}
onInput={(e) => setDescription(e.currentTarget.value)}
placeholder="Short description of this role"
/>
</div>
</div>
</div>
{/* Module Access */}
<div class="role-form-section">
<h3>Module Access</h3>
<p>Select which modules this role can access. Only selected modules will appear in the permission table below.</p>
<Show when={permissions.loading}>
<p class="notice">Loading modules...</p>
</Show>
<Show when={!permissions.loading && allModules().length > 0}>
<div class="module-picker">
{allModules().map((mod) => (
<button
type="button"
class={`module-chip ${assignedModules().includes(mod) ? 'selected' : ''}`}
onClick={() => toggleModule(mod)}
>
<span style={`width:14px;height:14px;border-radius:3px;border:2px solid ${assignedModules().includes(mod) ? '#c2410c' : '#cbd5e1'};background:${assignedModules().includes(mod) ? '#c2410c' : '#fff'};flex-shrink:0;display:inline-block`} />
{mod}
</button>
))}
</div>
</Show>
<Show when={!permissions.loading && allModules().length === 0}>
<p class="notice">No modules available. Is the backend running?</p>
</Show>
</div>
{/* Permission Table */}
<Show when={assignedModules().length > 0}>
<div class="role-form-section">
<h3>Permissions</h3>
<p>Set Read / Create / Update / Delete access for each assigned module.</p>
<div class="table-wrap">
<table class="perm-table">
<thead>
<tr>
<th style="width:45%">Name of the module</th>
<th style="width:11%">No Access</th>
<th style="width:11%">Read</th>
<th style="width:11%">Create</th>
<th style="width:11%">Update</th>
<th style="width:11%">Delete</th>
</tr>
</thead>
<tbody>
{assignedModules().sort().map((mod) => {
const perms = permsByModule()[mod] || [];
const actionMap: Record<string, string> = {};
perms.forEach((p) => { actionMap[p.action] = p.id; });
const hasRead = !!actionMap['Read'] && permissionIds().includes(actionMap['Read']);
const hasCreate = !!actionMap['Create'] && permissionIds().includes(actionMap['Create']);
const hasUpdate = !!actionMap['Update'] && permissionIds().includes(actionMap['Update']);
const hasDelete = !!actionMap['Delete'] && permissionIds().includes(actionMap['Delete']);
const noAccess = !hasRead && !hasCreate && !hasUpdate && !hasDelete;
return (
<tr>
<td style="font-weight:500">{mod}</td>
<td><input type="checkbox" checked={noAccess} disabled aria-label={`${mod} no access`} /></td>
<td>{actionMap['Read'] ? <input type="checkbox" checked={hasRead} onChange={() => togglePermission(actionMap['Read'])} aria-label={`${mod} read`} /> : <span style="color:#cbd5e1"></span>}</td>
<td>{actionMap['Create'] ? <input type="checkbox" checked={hasCreate} onChange={() => togglePermission(actionMap['Create'])} aria-label={`${mod} create`} /> : <span style="color:#cbd5e1"></span>}</td>
<td>{actionMap['Update'] ? <input type="checkbox" checked={hasUpdate} onChange={() => togglePermission(actionMap['Update'])} aria-label={`${mod} update`} /> : <span style="color:#cbd5e1"></span>}</td>
<td>{actionMap['Delete'] ? <input type="checkbox" checked={hasDelete} onChange={() => togglePermission(actionMap['Delete'])} aria-label={`${mod} delete`} /> : <span style="color:#cbd5e1"></span>}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</Show>
{/* Save */}
<div style="display:flex;justify-content:flex-end;margin-top:8px">
<button
class="btn navy"
onClick={handleSave}
disabled={saving() || !roleName().trim()}
>
{saving() ? 'Creating...' : 'Create Role'}
</button>
</div>
</AdminShell>
);
}

View file

@ -0,0 +1,129 @@
import { A, useNavigate } from '@solidjs/router';
import { createResource, createSignal, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
type Role = {
id: string;
name: string;
description?: string;
code?: string;
};
async function loadInternalRoles(): Promise<Role[]> {
try {
const res = await fetch(`${API}/api/admin/roles?audience=INTERNAL`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
const rows = Array.isArray(data) ? data : (data.roles || []);
return rows.map((r: any) => ({
id: r.id,
name: r.name,
description: r.description || '',
code: r.code || r.key || '',
}));
} catch {
return [];
}
}
export default function InternalRolesPage() {
const navigate = useNavigate();
const [roles, { refetch }] = createResource(loadInternalRoles);
const [deleting, setDeleting] = createSignal('');
const [deleteError, setDeleteError] = createSignal('');
const handleDelete = async (id: string, name: string) => {
if (!confirm(`Delete role "${name}"? This cannot be undone.`)) return;
try {
setDeleting(id);
setDeleteError('');
const res = await fetch(`${API}/api/admin/roles/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete role');
refetch();
} catch (err: any) {
setDeleteError(err.message || 'Failed to delete role');
} finally {
setDeleting('');
}
};
return (
<AdminShell>
<div class="page-actions">
<div>
<h1 class="page-title">Internal Role Management</h1>
<p class="page-subtitle">Manage internal employee roles and permissions from one clean list.</p>
</div>
<A class="btn navy" href="/admin/roles/create">Create Internal Role</A>
</div>
<Show when={deleteError()}>
<div class="error-box">{deleteError()}</div>
</Show>
<section class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={roles.loading}>
<tr>
<td colspan="3" style="text-align:center;padding:32px;color:#64748b;">Loading internal roles...</td>
</tr>
</Show>
<Show when={!roles.loading && roles.error}>
<tr>
<td colspan="3" style="text-align:center;padding:32px;color:#b91c1c;">Failed to load roles. Is the backend running?</td>
</tr>
</Show>
<Show when={!roles.loading && !roles.error && roles()?.length === 0}>
<tr>
<td colspan="3" style="text-align:center;padding:32px;color:#94a3b8;">No internal roles found. Create your first role.</td>
</tr>
</Show>
<Show when={!roles.loading && !roles.error && (roles()?.length ?? 0) > 0}>
{roles()!.map((role) => (
<tr>
<td>
<div>
<p style="margin:0;font-weight:600;color:#0f172a;">{role.name}</p>
<p style="margin:2px 0 0;font-size:11px;color:#94a3b8;">{role.code || role.id?.slice(0, 8)}</p>
</div>
</td>
<td style="color:#475569;">{role.description || 'No description added yet.'}</td>
<td>
<div class="table-actions">
<A class="btn" href={`/admin/roles/${role.id}`}>View</A>
<button
class="btn"
onClick={() => navigate(`/admin/roles/${role.id}/edit`)}
>
Edit
</button>
<button
class="btn danger"
disabled={deleting() === role.id}
onClick={() => handleDelete(role.id, role.name)}
>
{deleting() === role.id ? 'Deleting...' : 'Delete'}
</button>
</div>
</td>
</tr>
))}
</Show>
</tbody>
</table>
</div>
</section>
</AdminShell>
);
}

View file

@ -1,105 +1,233 @@
import { useParams } from '@solidjs/router';
import { useNavigate, useParams } from '@solidjs/router';
import { createEffect, createResource, createSignal, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import { getRuntimeConfig, saveRuntimeConfig } from '~/lib/runtime/storage';
import type { RuntimeRoleConfig } from '~/lib/runtime/types';
export default function EditRolePage() {
const API = '/api/gateway';
type Config = {
id: string;
roleKey: string;
displayName: string;
vertical: 'jobs' | 'marketplace';
onboardingSchemaId: string;
enabledModules: string[];
requiresOnboardingApproval: boolean;
requiresLeadApproval: boolean;
requiresJobApproval: boolean;
isActive: boolean;
};
async function loadRole(roleKey: string): Promise<Config | null> {
try {
const res = await fetch(`${API}/api/admin/roles?audience=EXTERNAL`);
if (!res.ok) return null;
const data = await res.json();
const rows = Array.isArray(data) ? data : (data.roles || []);
const r = rows.find((r: any) => (r.key || r.role_key || r.roleKey || '') === decodeURIComponent(roleKey));
if (!r) return null;
return {
id: r.id,
roleKey: r.key || r.role_key || r.roleKey || '',
displayName: r.name || r.displayName || r.display_name || '',
vertical: r.config_json?.vertical || r.vertical || 'marketplace',
onboardingSchemaId: r.config_json?.onboardingSchemaId || r.onboarding_schema_id || '',
enabledModules: r.config_json?.enabledModules || r.enabled_modules || [],
requiresOnboardingApproval: r.config_json?.requiresOnboardingApproval ?? true,
requiresLeadApproval: r.config_json?.requiresLeadApproval ?? false,
requiresJobApproval: r.config_json?.requiresJobApproval ?? false,
isActive: r.is_active !== false,
};
} catch {
return null;
}
}
export default function EditExternalRolePage() {
const params = useParams();
const [data] = createResource(() => {
if (!params.roleKey) return null;
return getRuntimeConfig<RuntimeRoleConfig>('role', params.roleKey);
});
const navigate = useNavigate();
const [data] = createResource(() => params.roleKey, loadRole);
const [config, setConfig] = createSignal<RuntimeRoleConfig | null>(null);
const [statusMessage, setStatusMessage] = createSignal('');
const [isSaving, setIsSaving] = createSignal(false);
const [config, setConfig] = createSignal<Config | null>(null);
const [modulesRaw, setModulesRaw] = createSignal('');
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal('');
// Sync resource to local signals
createEffect(() => {
const item = data();
if (item?.payload) {
setConfig(JSON.parse(JSON.stringify(item.payload)));
}
const d = data();
if (!d) return;
setConfig(d);
setModulesRaw(d.enabledModules.join(', '));
});
const persist = async (status: 'draft' | 'published') => {
const payload = config();
if (!payload) return;
const set = (patch: Partial<Config>) => setConfig({ ...config()!, ...patch });
setIsSaving(true);
setStatusMessage('Saving to backend...');
const handleSave = async () => {
const c = config();
if (!c) return;
if (!c.roleKey.trim()) { setError('Role Key is required'); return; }
if (!c.displayName.trim()) { setError('Display Name is required'); return; }
try {
await saveRuntimeConfig('role', payload.roleKey, payload, status);
setStatusMessage(status === 'draft' ? 'Draft saved successfully.' : 'Role config published successfully.');
} catch (err) {
setStatusMessage(`Error: ${err instanceof Error ? err.message : String(err)}`);
setSaving(true);
setError('');
const enabledModules = modulesRaw().split(',').map((s) => s.trim()).filter(Boolean);
const res = await fetch(`${API}/api/admin/roles/${c.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key: c.roleKey.toUpperCase().replace(/\s+/g, '_'),
name: c.displayName,
audience: 'EXTERNAL',
config_json: {
roleKey: c.roleKey,
displayName: c.displayName,
vertical: c.vertical,
onboardingSchemaId: c.onboardingSchemaId,
enabledModules,
requiresOnboardingApproval: c.requiresOnboardingApproval,
requiresLeadApproval: c.requiresLeadApproval,
requiresJobApproval: c.requiresJobApproval,
},
is_active: c.isActive,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message || 'Failed to update external role');
}
navigate('/admin/runtime-roles');
} catch (err: any) {
setError(err.message || 'Failed to update external role');
} finally {
setIsSaving(false);
setSaving(false);
}
};
return (
<AdminShell>
<h1 class="page-title">Edit Role</h1>
<p class="page-subtitle">Update this runtime role without changing source code.</p>
<div class="page-actions">
<div>
<h1 class="page-title">Edit Runtime Role</h1>
<p class="page-subtitle">Update this external runtime role configuration.</p>
</div>
<a class="btn" href="/admin/runtime-roles">Back to Runtime Roles</a>
</div>
<Show when={data.loading}>
<section class="card"><p class="notice">Loading role configuration from database...</p></section>
<div class="card"><p class="notice">Loading role...</p></div>
</Show>
<Show when={!data.loading && !config()}>
<section class="card"><p class="notice">Role "{params.roleKey}" not found in database.</p></section>
<Show when={data.error}>
<div class="error-box">Failed to load role. Check that the backend is running.</div>
</Show>
<Show when={!data.loading && !data.error && !data()}>
<div class="card"><p class="notice">Role "{params.roleKey}" not found.</p></div>
</Show>
<Show when={error()}>
<div class="error-box">{error()}</div>
</Show>
<Show when={!data.loading && config()}>
<div class="grid">
<section class="card">
<section class="card external-role-form">
<h2>Role Builder</h2>
<div class="field">
<label>Role Key</label>
<input value={config()!.roleKey} onInput={(e) => setConfig({ ...config()!, roleKey: e.currentTarget.value.toUpperCase() })} />
<label>Role Key <span style="color:#ef4444">*</span></label>
<input
value={config()!.roleKey}
onInput={(e) => set({ roleKey: e.currentTarget.value.toUpperCase().replace(/\s+/g, '_') })}
placeholder="e.g. PHOTOGRAPHER"
/>
<p class="hint">Uppercase, underscores only. e.g. MAKEUP_ARTIST</p>
</div>
<div class="field">
<label>Display Name</label>
<input value={config()!.displayName} onInput={(e) => setConfig({ ...config()!, displayName: e.currentTarget.value })} />
<label>Display Name <span style="color:#ef4444">*</span></label>
<input
value={config()!.displayName}
onInput={(e) => set({ displayName: e.currentTarget.value })}
placeholder="e.g. Photographer"
/>
</div>
<div class="field">
<label>Vertical</label>
<select value={config()!.vertical} onInput={(e) => setConfig({ ...config()!, vertical: e.currentTarget.value as 'jobs' | 'marketplace' })}>
<select value={config()!.vertical} onChange={(e) => set({ vertical: e.currentTarget.value as 'jobs' | 'marketplace' })}>
<option value="marketplace">Marketplace</option>
<option value="jobs">Jobs</option>
</select>
</div>
<div class="field">
<label>Onboarding Schema ID</label>
<input value={config()!.onboardingSchemaId} onInput={(e) => setConfig({ ...config()!, onboardingSchemaId: e.currentTarget.value })} />
<input
value={config()!.onboardingSchemaId}
onInput={(e) => set({ onboardingSchemaId: e.currentTarget.value })}
placeholder="e.g. photographer_onboarding_v1"
/>
</div>
<div class="field">
<label>Enabled Modules (comma separated)</label>
<input value={config()!.enabledModules.join(', ')} onInput={(e) => setConfig({ ...config()!, enabledModules: e.currentTarget.value.split(',').map((x) => x.trim()).filter(Boolean) })} />
<label>Enabled Modules <span style="font-weight:400;color:#64748b">(comma separated)</span></label>
<input
value={modulesRaw()}
onInput={(e) => setModulesRaw(e.currentTarget.value)}
placeholder="profile, leads, portfolio, verification, notifications"
/>
<p class="hint">e.g. profile, leads, portfolio</p>
</div>
<div class="field">
<label>
<input
type="checkbox"
checked={config()!.requiresOnboardingApproval}
onInput={(e) => setConfig({ ...config()!, requiresOnboardingApproval: e.currentTarget.checked })}
/>
{' '}Requires onboarding approval
<label class="checkbox-label">
<input type="checkbox" checked={config()!.requiresOnboardingApproval} onChange={(e) => set({ requiresOnboardingApproval: e.currentTarget.checked })} />
Requires onboarding approval
</label>
</div>
<div class="field">
<label class="checkbox-label">
<input type="checkbox" checked={config()!.requiresLeadApproval} onChange={(e) => set({ requiresLeadApproval: e.currentTarget.checked })} />
Requires lead approval
</label>
</div>
<div class="field">
<label class="checkbox-label">
<input type="checkbox" checked={config()!.requiresJobApproval} onChange={(e) => set({ requiresJobApproval: e.currentTarget.checked })} />
Requires job approval
</label>
</div>
<div class="field">
<label class="checkbox-label">
<input type="checkbox" checked={config()!.isActive} onChange={(e) => set({ isActive: e.currentTarget.checked })} />
Active (publish immediately)
</label>
</div>
<div class="actions">
<button class="btn" onClick={() => persist('draft')} disabled={isSaving()}>
{isSaving() ? 'Saving...' : 'Save Draft'}
</button>
<button class="btn primary" onClick={() => persist('published')} disabled={isSaving()}>
{isSaving() ? 'Publishing...' : 'Publish'}
<button class="btn primary" onClick={handleSave} disabled={saving()}>
{saving() ? 'Saving...' : 'Save Changes'}
</button>
</div>
{statusMessage() && <p class="inline-note">{statusMessage()}</p>}
</section>
<section class="card">
<h2>Runtime Config Preview</h2>
<pre class="json">{JSON.stringify(config(), null, 2)}</pre>
<pre class="json">{JSON.stringify({
roleKey: config()!.roleKey,
displayName: config()!.displayName,
vertical: config()!.vertical,
onboardingSchemaId: config()!.onboardingSchemaId,
enabledModules: modulesRaw().split(',').map((s) => s.trim()).filter(Boolean),
requiresOnboardingApproval: config()!.requiresOnboardingApproval,
requiresLeadApproval: config()!.requiresLeadApproval,
requiresJobApproval: config()!.requiresJobApproval,
isActive: config()!.isActive,
}, null, 2)}</pre>
</section>
</div>
</Show>

View file

@ -1,61 +1,140 @@
import { A } from '@solidjs/router';
import { createResource, Show } from 'solid-js';
import { A, useNavigate } from '@solidjs/router';
import { createResource, createSignal, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import { deleteRuntimeConfig, listRuntimeConfigs, type RuntimeListItem } from '~/lib/runtime/storage';
import type { RuntimeRoleConfig } from '~/lib/runtime/types';
export default function ManageRolesPage() {
const [items, { refetch }] = createResource(() => listRuntimeConfigs<RuntimeRoleConfig>('role'));
const API = '/api/gateway';
const onDelete = async (key: string) => {
await deleteRuntimeConfig('role', key);
refetch();
type ExternalRole = {
id: string;
roleKey: string;
displayName: string;
vertical: string;
enabledModules: string[];
onboardingSchemaId: string;
isActive: boolean;
};
async function loadExternalRoles(): Promise<ExternalRole[]> {
try {
const res = await fetch(`${API}/api/admin/roles?audience=EXTERNAL`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
const rows = Array.isArray(data) ? data : (data.roles || []);
return rows.map((r: any) => ({
id: r.id,
roleKey: r.key || r.role_key || r.roleKey || '',
displayName: r.name || r.displayName || r.display_name || r.key || '',
vertical: r.config_json?.vertical || r.vertical || '',
enabledModules: r.config_json?.enabledModules || r.enabled_modules || [],
onboardingSchemaId: r.config_json?.onboardingSchemaId || r.onboarding_schema_id || '',
isActive: r.is_active !== false,
}));
} catch {
return [];
}
}
export default function RuntimeRolesPage() {
const navigate = useNavigate();
const [roles, { refetch }] = createResource(loadExternalRoles);
const [deleting, setDeleting] = createSignal('');
const [deleteError, setDeleteError] = createSignal('');
const handleDelete = async (id: string, name: string) => {
if (!confirm(`Delete external role "${name}"?`)) return;
try {
setDeleting(id);
const res = await fetch(`${API}/api/admin/roles/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete');
refetch();
} catch (err: any) {
setDeleteError(err.message || 'Failed to delete');
} finally {
setDeleting('');
}
};
return (
<AdminShell>
<h1 class="page-title">External Role Management</h1>
<p class="page-subtitle">Manage canonical external runtime roles from one place.</p>
<section class="card">
<div class="list-header">
<h2>Runtime Roles</h2>
<A class="btn primary" href="/admin/runtime-roles/new">Create Role</A>
<div class="page-actions">
<div>
<h1 class="page-title">External Role Management</h1>
<p class="page-subtitle">Manage canonical external runtime roles, enabled modules, onboarding assignment, and approval gates from one place.</p>
</div>
<Show when={items.loading}>
<p class="notice">Loading roles from database...</p>
</Show>
<Show when={!items.loading && items() && items()?.length === 0}>
<p class="notice">No runtime role configs found yet.</p>
</Show>
<Show when={!items.loading && items() && items()!.length > 0}>
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Role</th>
<th>Status</th>
<th>Updated</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
{items()!.map((item) => (
<A class="btn navy" href="/admin/runtime-roles/new">Create External Role</A>
</div>
<Show when={deleteError()}>
<div class="error-box">{deleteError()}</div>
</Show>
<section class="card" style="padding: 0; overflow: hidden;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid #e2e8f0">
<div>
<h2 style="margin:0;font-size:17px;font-weight:700">Published External Roles</h2>
<p style="margin:4px 0 0;font-size:12px;color:#64748b">Only canonical external runtime roles are shown here.</p>
</div>
<Show when={!roles.loading}>
<span style="font-size:13px;color:#64748b">{roles()?.length || 0} roles</span>
</Show>
</div>
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Role</th>
<th>Type</th>
<th>Modules</th>
<th>Schema</th>
<th>Status</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={roles.loading}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#64748b">Loading external roles...</td></tr>
</Show>
<Show when={!roles.loading && roles.error}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#b91c1c">Failed to load external roles. Is the backend running?</td></tr>
</Show>
<Show when={!roles.loading && !roles.error && roles()?.length === 0}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#94a3b8">No external roles configured yet.</td></tr>
</Show>
<Show when={!roles.loading && !roles.error && (roles()?.length ?? 0) > 0}>
{roles()!.map((role) => (
<tr>
<td>{item.key}</td>
<td><span class={`status-chip ${item.status === 'published' ? 'active' : ''}`}>{item.status}</span></td>
<td>{new Date(item.updatedAt).toLocaleDateString()}</td>
<td>
<div>
<p style="margin:0;font-weight:600;color:#0f172a">{role.displayName}</p>
<p style="margin:2px 0 0;font-size:11px;color:#94a3b8">{role.roleKey}</p>
</div>
</td>
<td style="color:#475569">{role.vertical || '—'}</td>
<td style="color:#475569">{role.enabledModules.length}</td>
<td style="color:#475569;font-size:12px">{role.onboardingSchemaId || '—'}</td>
<td>
<span class={`status-chip ${role.isActive ? 'active' : ''}`}>
{role.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td>
<div class="table-actions">
<A class="btn" href={`/admin/runtime-roles/${encodeURIComponent(item.key)}`}>Edit</A>
<button class="btn" onClick={() => onDelete(item.key)}>Delete</button>
<A class="btn" href={`/admin/runtime-roles/${encodeURIComponent(role.roleKey)}`}>Edit</A>
<button
class="btn danger"
disabled={deleting() === role.id}
onClick={() => handleDelete(role.id, role.displayName)}
>
{deleting() === role.id ? '...' : 'Delete'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Show>
</Show>
</tbody>
</table>
</div>
</section>
</AdminShell>
);

View file

@ -1,94 +1,196 @@
import { createSignal } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { createSignal, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
import { saveRuntimeConfig } from '~/lib/runtime/storage';
import type { RuntimeRoleConfig } from '~/lib/runtime/types';
export default function CreateRolePage() {
const [config, setConfig] = createSignal<RuntimeRoleConfig>({
roleKey: 'PHOTOGRAPHER',
displayName: 'Photographer',
const API = '/api/gateway';
type Config = {
roleKey: string;
displayName: string;
vertical: 'jobs' | 'marketplace';
onboardingSchemaId: string;
enabledModules: string[];
requiresOnboardingApproval: boolean;
requiresLeadApproval: boolean;
requiresJobApproval: boolean;
isActive: boolean;
};
function defaultConfig(): Config {
return {
roleKey: '',
displayName: '',
vertical: 'marketplace',
onboardingSchemaId: 'photographer_onboarding_v1',
enabledModules: ['profile', 'leads', 'portfolio', 'verification', 'notifications'],
onboardingSchemaId: '',
enabledModules: [],
requiresOnboardingApproval: true,
});
const [statusMessage, setStatusMessage] = createSignal('');
requiresLeadApproval: false,
requiresJobApproval: false,
isActive: true,
};
}
const [isSaving, setIsSaving] = createSignal(false);
export default function CreateExternalRolePage() {
const navigate = useNavigate();
const [config, setConfig] = createSignal<Config>(defaultConfig());
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal('');
const [modulesRaw, setModulesRaw] = createSignal('');
const persist = async (status: 'draft' | 'published') => {
const payload = config();
if (!payload.roleKey.trim()) {
setStatusMessage('Role key is required before saving.');
return;
}
setIsSaving(true);
setStatusMessage('Saving to backend...');
const set = (patch: Partial<Config>) => setConfig({ ...config(), ...patch });
const handleSubmit = async () => {
if (!config().roleKey.trim()) { setError('Role Key is required'); return; }
if (!config().displayName.trim()) { setError('Display Name is required'); return; }
try {
await saveRuntimeConfig('role', payload.roleKey, payload, status);
setStatusMessage(status === 'draft' ? 'Draft saved successfully.' : 'Role config published successfully.');
} catch (err) {
setStatusMessage(`Error: ${err instanceof Error ? err.message : String(err)}`);
setSaving(true);
setError('');
const enabledModules = modulesRaw()
.split(',').map((s) => s.trim()).filter(Boolean);
const res = await fetch(`${API}/api/admin/roles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key: config().roleKey.toUpperCase().replace(/\s+/g, '_'),
name: config().displayName,
audience: 'EXTERNAL',
config_json: {
roleKey: config().roleKey,
displayName: config().displayName,
vertical: config().vertical,
onboardingSchemaId: config().onboardingSchemaId,
enabledModules,
requiresOnboardingApproval: config().requiresOnboardingApproval,
requiresLeadApproval: config().requiresLeadApproval,
requiresJobApproval: config().requiresJobApproval,
},
is_active: config().isActive,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message || 'Failed to create external role');
}
navigate('/admin/runtime-roles');
} catch (err: any) {
setError(err.message || 'Failed to create external role');
} finally {
setIsSaving(false);
setSaving(false);
}
};
return (
<AdminShell>
<h1 class="page-title">Create Role</h1>
<p class="page-subtitle">Use the same admin flow, with simpler fields and live runtime config visibility.</p>
<div class="page-actions">
<div>
<h1 class="page-title">Create Runtime Role</h1>
<p class="page-subtitle">Create an API-backed runtime role for the external workspace shell.</p>
</div>
<a class="btn" href="/admin/runtime-roles">Back to Runtime Roles</a>
</div>
<Show when={error()}>
<div class="error-box">{error()}</div>
</Show>
<div class="grid">
<section class="card">
<section class="card external-role-form">
<h2>Role Builder</h2>
<div class="field">
<label>Role Key</label>
<input value={config().roleKey} onInput={(e) => setConfig({ ...config(), roleKey: e.currentTarget.value.toUpperCase() })} />
<label>Role Key <span style="color:#ef4444">*</span></label>
<input
value={config().roleKey}
onInput={(e) => set({ roleKey: e.currentTarget.value.toUpperCase().replace(/\s+/g, '_') })}
placeholder="e.g. PHOTOGRAPHER"
/>
<p class="hint">Uppercase, underscores only. e.g. MAKEUP_ARTIST</p>
</div>
<div class="field">
<label>Display Name</label>
<input value={config().displayName} onInput={(e) => setConfig({ ...config(), displayName: e.currentTarget.value })} />
<label>Display Name <span style="color:#ef4444">*</span></label>
<input
value={config().displayName}
onInput={(e) => set({ displayName: e.currentTarget.value })}
placeholder="e.g. Photographer"
/>
</div>
<div class="field">
<label>Vertical</label>
<select value={config().vertical} onInput={(e) => setConfig({ ...config(), vertical: e.currentTarget.value as 'jobs' | 'marketplace' })}>
<select value={config().vertical} onChange={(e) => set({ vertical: e.currentTarget.value as 'jobs' | 'marketplace' })}>
<option value="marketplace">Marketplace</option>
<option value="jobs">Jobs</option>
</select>
</div>
<div class="field">
<label>Onboarding Schema ID</label>
<input value={config().onboardingSchemaId} onInput={(e) => setConfig({ ...config(), onboardingSchemaId: e.currentTarget.value })} />
<input
value={config().onboardingSchemaId}
onInput={(e) => set({ onboardingSchemaId: e.currentTarget.value })}
placeholder="e.g. photographer_onboarding_v1"
/>
</div>
<div class="field">
<label>Enabled Modules (comma separated)</label>
<input value={config().enabledModules.join(', ')} onInput={(e) => setConfig({ ...config(), enabledModules: e.currentTarget.value.split(',').map((x) => x.trim()).filter(Boolean) })} />
<label>Enabled Modules <span style="font-weight:400;color:#64748b">(comma separated)</span></label>
<input
value={modulesRaw()}
onInput={(e) => setModulesRaw(e.currentTarget.value)}
placeholder="profile, leads, portfolio, verification, notifications"
/>
<p class="hint">e.g. profile, leads, portfolio</p>
</div>
<div class="field">
<label>
<input
type="checkbox"
checked={config().requiresOnboardingApproval}
onInput={(e) => setConfig({ ...config(), requiresOnboardingApproval: e.currentTarget.checked })}
/>
{' '}Requires onboarding approval
<label class="checkbox-label">
<input type="checkbox" checked={config().requiresOnboardingApproval} onChange={(e) => set({ requiresOnboardingApproval: e.currentTarget.checked })} />
Requires onboarding approval
</label>
</div>
<div class="field">
<label class="checkbox-label">
<input type="checkbox" checked={config().requiresLeadApproval} onChange={(e) => set({ requiresLeadApproval: e.currentTarget.checked })} />
Requires lead approval
</label>
</div>
<div class="field">
<label class="checkbox-label">
<input type="checkbox" checked={config().requiresJobApproval} onChange={(e) => set({ requiresJobApproval: e.currentTarget.checked })} />
Requires job approval
</label>
</div>
<div class="field">
<label class="checkbox-label">
<input type="checkbox" checked={config().isActive} onChange={(e) => set({ isActive: e.currentTarget.checked })} />
Active (publish immediately)
</label>
</div>
<div class="actions">
<button class="btn" onClick={() => persist('draft')} disabled={isSaving()}>
{isSaving() ? 'Saving...' : 'Save Draft'}
</button>
<button class="btn primary" onClick={() => persist('published')} disabled={isSaving()}>
{isSaving() ? 'Publishing...' : 'Publish'}
<button class="btn primary" onClick={handleSubmit} disabled={saving()}>
{saving() ? 'Creating...' : 'Create Runtime Role'}
</button>
</div>
{statusMessage() && <p class="inline-note">{statusMessage()}</p>}
</section>
<section class="card">
<h2>Runtime Config Preview</h2>
<pre class="json">{JSON.stringify(config(), null, 2)}</pre>
<pre class="json">{JSON.stringify({
roleKey: config().roleKey,
displayName: config().displayName,
vertical: config().vertical,
onboardingSchemaId: config().onboardingSchemaId,
enabledModules: modulesRaw().split(',').map((s) => s.trim()).filter(Boolean),
requiresOnboardingApproval: config().requiresOnboardingApproval,
requiresLeadApproval: config().requiresLeadApproval,
requiresJobApproval: config().requiresJobApproval,
isActive: config().isActive,
}, null, 2)}</pre>
</section>
</div>
</AdminShell>

View file

@ -0,0 +1,124 @@
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 [];
}
}
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="page-actions">
<div>
<h1 class="page-title">Social Media Manager Management</h1>
<p class="page-subtitle">Manage all social media manager accounts on the platform.</p>
</div>
</div>
<section class="card" style="padding: 0; overflow: hidden;">
<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="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Registered</th>
<th class="align-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>
<td style="font-weight:600;color:#0f172a">{item.name || item.full_name || '—'}</td>
<td style="color:#475569">{item.email}</td>
<td>
{item.status?.toUpperCase() === 'ACTIVE' && (
<span class="status-chip active">ACTIVE</span>
)}
{item.status?.toUpperCase() === 'INACTIVE' && (
<span class="status-chip">INACTIVE</span>
)}
{item.status?.toUpperCase() === 'PENDING' && (
<span class="status-chip" style="background:#fff7ed;color:#c2410c;border-color:#fed7aa;">PENDING</span>
)}
{!item.status && <span class="status-chip"></span>}
</td>
<td style="color:#475569">
{item.created_at ? new Date(item.created_at).toLocaleDateString() : '—'}
</td>
<td>
<div class="table-actions">
<A class="btn" href={`/admin/users/${item.id}`}>View</A>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</AdminShell>
);
}

View file

@ -0,0 +1,370 @@
import { createResource, createSignal, createMemo, Show, For } from 'solid-js';
import { A } from '@solidjs/router';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
type SupportCase = {
id: string;
title: string;
description: string;
type: 'platform_issue' | 'customer_query' | 'professional_query' | 'billing_issue' | 'lead_dispute';
priority: 'low' | 'medium' | 'high' | 'critical';
status: 'new' | 'in_progress' | 'waiting_for_user' | 'resolved' | 'closed';
requesterName?: string;
requesterEmail?: string;
updatedAt: string;
createdAt: string;
};
const STATUS_OPTIONS: SupportCase['status'][] = ['new', 'in_progress', 'waiting_for_user', 'resolved', 'closed'];
const TYPE_OPTIONS: SupportCase['type'][] = ['platform_issue', 'customer_query', 'professional_query', 'billing_issue', 'lead_dispute'];
const PRIORITY_OPTIONS: SupportCase['priority'][] = ['low', 'medium', 'high', 'critical'];
function formatValue(input: string): string {
return input.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
function typeBadgeStyle(type: string): string {
const map: Record<string, string> = {
platform_issue: 'background:#dbeafe;color:#1d4ed8',
customer_query: 'background:#dcfce7;color:#15803d',
billing_issue: 'background:#ffedd5;color:#c2410c',
lead_dispute: 'background:#fee2e2;color:#b91c1c',
professional_query: 'background:#f3e8ff;color:#7e22ce',
};
return map[type] || 'background:#f1f5f9;color:#475569';
}
function priorityBadgeStyle(priority: string): string {
const map: Record<string, string> = {
low: 'background:#f1f5f9;color:#475569',
medium: 'background:#dbeafe;color:#1d4ed8',
high: 'background:#ffedd5;color:#c2410c',
critical: 'background:#fee2e2;color:#b91c1c',
};
return map[priority] || 'background:#f1f5f9;color:#475569';
}
function statusBadgeStyle(status: string): string {
const map: Record<string, string> = {
new: 'background:#dbeafe;color:#1d4ed8',
in_progress: 'background:#ffedd5;color:#c2410c',
waiting_for_user: 'background:#fef9c3;color:#a16207',
resolved: 'background:#dcfce7;color:#15803d',
closed: 'background:#f1f5f9;color:#475569',
};
return map[status] || 'background:#f1f5f9;color:#475569';
}
const BADGE_STYLE = 'display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600';
async function loadAllCases(): Promise<SupportCase[]> {
try {
const res = await fetch(`${API}/api/admin/support-cases`);
if (!res.ok) throw new Error('Failed');
const data = await res.json();
return Array.isArray(data.cases) ? data.cases : Array.isArray(data) ? data : [];
} catch {
return [];
}
}
export default function SupportPage() {
const [activeTab, setActiveTab] = createSignal<'queue' | 'create'>('queue');
const [statusFilter, setStatusFilter] = createSignal<'all' | SupportCase['status']>('all');
const [refetchKey, setRefetchKey] = createSignal(0);
const [cases] = createResource(refetchKey, loadAllCases);
const refetch = () => setRefetchKey((k) => k + 1);
const filteredCases = createMemo(() => {
const all = cases() ?? [];
const sf = statusFilter();
if (sf === 'all') return all;
return all.filter((c) => c.status === sf);
});
const stats = createMemo(() => {
const all = cases() ?? [];
return {
newCount: all.filter((c) => c.status === 'new').length,
inProgressCount: all.filter((c) => c.status === 'in_progress').length,
waitingCount: all.filter((c) => c.status === 'waiting_for_user').length,
total: all.length,
};
});
// Create Case form state
const [fTitle, setFTitle] = createSignal('');
const [fDesc, setFDesc] = createSignal('');
const [fType, setFType] = createSignal<SupportCase['type']>('customer_query');
const [fPriority, setFPriority] = createSignal<SupportCase['priority']>('medium');
const [fRequesterName, setFRequesterName] = createSignal('');
const [fRequesterEmail, setFRequesterEmail] = createSignal('');
const [createLoading, setCreateLoading] = createSignal(false);
const [createSuccess, setCreateSuccess] = createSignal('');
const [createError, setCreateError] = createSignal('');
const resetForm = () => {
setFTitle('');
setFDesc('');
setFType('customer_query');
setFPriority('medium');
setFRequesterName('');
setFRequesterEmail('');
};
const handleCreate = async (e: Event) => {
e.preventDefault();
setCreateLoading(true);
setCreateSuccess('');
setCreateError('');
try {
const res = await fetch(`${API}/api/admin/support-cases`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: fTitle(),
description: fDesc(),
type: fType(),
priority: fPriority(),
requesterName: fRequesterName(),
requesterEmail: fRequesterEmail(),
}),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error((d as any).message || 'Failed to create case');
}
setCreateSuccess('Case created!');
resetForm();
refetch();
setActiveTab('queue');
} catch (err: any) {
setCreateError(err.message || 'Failed to create case');
} finally {
setCreateLoading(false);
}
};
const statCards = [
{ label: 'New', getValue: () => stats().newCount },
{ label: 'In Progress', getValue: () => stats().inProgressCount },
{ label: 'Waiting', getValue: () => stats().waitingCount },
{ label: 'Total', getValue: () => stats().total },
];
return (
<AdminShell>
<div class="page-actions">
<div>
<h1 class="page-title">Support Management</h1>
<p class="page-subtitle">Handle platform issues and customer queries</p>
</div>
</div>
{/* Stats bar */}
<div style="display:flex;gap:12px;margin-bottom:16px">
<For each={statCards}>
{(card) => (
<div style="background:#f8f9fa;border:1px solid #e5e7eb;border-radius:8px;padding:12px 20px;text-align:center">
<div style="font-size:24px;font-weight:700">{card.getValue()}</div>
<div style="font-size:12px;color:#6b7280">{card.label}</div>
</div>
)}
</For>
</div>
{/* Tabs */}
<div style="display:flex;border-bottom:2px solid #e2e8f0;margin-bottom:24px;gap:0;overflow-x:auto;">
<button
type="button"
class={`admin-tab${activeTab() === 'queue' ? ' active' : ''}`}
onClick={() => setActiveTab('queue')}
>
Support Queue
</button>
<button
type="button"
class={`admin-tab${activeTab() === 'create' ? ' active' : ''}`}
onClick={() => setActiveTab('create')}
>
Create Case
</button>
</div>
{/* Support Queue Tab */}
<Show when={activeTab() === 'queue'}>
<div style="display:flex;flex-direction:column;gap:16px">
<div style="display:flex;align-items:center;justify-content:flex-end">
<select
value={statusFilter()}
onChange={(e) => setStatusFilter(e.currentTarget.value as typeof statusFilter extends () => infer R ? R : never)}
style="padding:8px 12px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
>
<option value="all">All statuses</option>
<For each={STATUS_OPTIONS}>
{(s) => <option value={s}>{formatValue(s)}</option>}
</For>
</select>
</div>
<section class="card" style="padding:0;overflow:hidden">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Issue</th>
<th>Type</th>
<th>Priority</th>
<th>Status</th>
<th>Requester</th>
<th>Updated At</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={cases.loading}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!cases.loading && cases.error}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#b91c1c">Failed to load cases.</td></tr>
</Show>
<Show when={!cases.loading && !cases.error && filteredCases().length === 0}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">No support cases found.</td></tr>
</Show>
<Show when={!cases.loading && !cases.error && filteredCases().length > 0}>
<For each={filteredCases()}>
{(item) => (
<tr style="cursor:pointer" onClick={() => {}}>
<td>
<div style="font-weight:600;color:#0f172a">{item.title}</div>
<div style="font-size:12px;color:#64748b;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{item.description}</div>
</td>
<td>
<span style={`${BADGE_STYLE};${typeBadgeStyle(item.type)}`}>{formatValue(item.type)}</span>
</td>
<td>
<span style={`${BADGE_STYLE};${priorityBadgeStyle(item.priority)}`}>{formatValue(item.priority)}</span>
</td>
<td>
<span style={`${BADGE_STYLE};${statusBadgeStyle(item.status)}`}>{formatValue(item.status)}</span>
</td>
<td>
<div style="font-size:13px">{item.requesterName || '—'}</div>
<div style="font-size:11px;color:#64748b">{item.requesterEmail || ''}</div>
</td>
<td style="font-size:12px;color:#64748b">
{item.updatedAt ? new Date(item.updatedAt).toLocaleString() : '—'}
</td>
<td>
<div class="table-actions">
<A class="btn" href={`/admin/support/${item.id}`}>View</A>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</div>
</Show>
{/* Create Case Tab */}
<Show when={activeTab() === 'create'}>
<section class="card" style="max-width:600px">
<h2 style="margin:0 0 6px;font-size:16px;font-weight:700;color:#1e293b">Create Support Case</h2>
<p style="margin:0 0 20px;font-size:13px;color:#64748b">
Create an internal support record for platform issues, customer concerns, or compensation-related reviews.
</p>
<Show when={createSuccess()}>
<div style="background:#dcfce7;border:1px solid #86efac;border-radius:6px;padding:10px 14px;margin-bottom:14px;font-size:14px;color:#15803d;font-weight:600">
{createSuccess()}
</div>
</Show>
<Show when={createError()}>
<div class="error-box" style="margin-bottom:14px">{createError()}</div>
</Show>
<form onSubmit={handleCreate} style="display:flex;flex-direction:column;gap:14px">
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Title</label>
<input
type="text"
required
value={fTitle()}
onInput={(e) => setFTitle(e.currentTarget.value)}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
/>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Description</label>
<textarea
required
rows="4"
value={fDesc()}
onInput={(e) => setFDesc(e.currentTarget.value)}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;resize:vertical;box-sizing:border-box"
/>
</div>
<div class="field-grid-2">
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Type</label>
<select
value={fType()}
onChange={(e) => setFType(e.currentTarget.value as SupportCase['type'])}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
>
<For each={TYPE_OPTIONS}>
{(t) => <option value={t}>{formatValue(t)}</option>}
</For>
</select>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Priority</label>
<select
value={fPriority()}
onChange={(e) => setFPriority(e.currentTarget.value as SupportCase['priority'])}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
>
<For each={PRIORITY_OPTIONS}>
{(p) => <option value={p}>{formatValue(p)}</option>}
</For>
</select>
</div>
</div>
<div class="field-grid-2">
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Requester Name</label>
<input
type="text"
value={fRequesterName()}
onInput={(e) => setFRequesterName(e.currentTarget.value)}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
/>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Requester Email</label>
<input
type="email"
value={fRequesterEmail()}
onInput={(e) => setFRequesterEmail(e.currentTarget.value)}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
/>
</div>
</div>
<div>
<button class="btn navy" type="submit" disabled={createLoading()}>
{createLoading() ? 'Creating...' : 'Create Support Case'}
</button>
</div>
</form>
</section>
</Show>
</AdminShell>
);
}

179
src/routes/admin/tax.tsx Normal file
View file

@ -0,0 +1,179 @@
import { createResource, createSignal, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
async function loadTaxes(): Promise<any[]> {
try {
const res = await fetch(`${API}/api/admin/tax`);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.taxes || data.tax || []);
} catch {
return [];
}
}
export default function TaxPage() {
const [taxes, { refetch }] = createResource(loadTaxes);
const [deleting, setDeleting] = createSignal('');
const [showForm, setShowForm] = createSignal(false);
const [saving, setSaving] = createSignal(false);
const [formError, setFormError] = createSignal('');
const [name, setName] = createSignal('');
const [rate, setRate] = createSignal('');
const [description, setDescription] = createSignal('');
const handleSave = async (e: Event) => {
e.preventDefault();
try {
setSaving(true);
setFormError('');
const res = await fetch(`${API}/api/admin/tax`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name(), rate: Number(rate()), description: description() }),
});
if (!res.ok) throw new Error('Failed to create tax');
setName('');
setRate('');
setDescription('');
setShowForm(false);
refetch();
} catch (err: any) {
setFormError(err.message || 'Failed to save');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string, taxName: string) => {
if (!confirm(`Delete tax "${taxName}"?`)) return;
try {
setDeleting(id);
const res = await fetch(`${API}/api/admin/tax/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete');
refetch();
} catch {
// ignore
} finally {
setDeleting('');
}
};
return (
<AdminShell>
<div class="page-actions">
<div>
<h1 class="page-title">Tax Management</h1>
<p class="page-subtitle">Configure tax rates for platform transactions.</p>
</div>
<button class="btn navy" onClick={() => setShowForm(!showForm())}>
{showForm() ? 'Cancel' : 'Add Tax'}
</button>
</div>
<Show when={showForm()}>
<section class="card" style="margin-bottom:16px">
<h2 style="margin:0 0 16px;font-size:16px;font-weight:700">New Tax</h2>
<Show when={formError()}>
<div class="error-box" style="margin-bottom:12px">{formError()}</div>
</Show>
<form onSubmit={handleSave} style="display:flex;flex-direction:column;gap:12px;max-width:400px">
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Name</label>
<input
type="text"
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
required
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
/>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Rate (%)</label>
<input
type="number"
value={rate()}
onInput={(e) => setRate(e.currentTarget.value)}
required
min="0"
max="100"
step="0.01"
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
/>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Description</label>
<input
type="text"
value={description()}
onInput={(e) => setDescription(e.currentTarget.value)}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px"
/>
</div>
<div>
<button class="btn navy" type="submit" disabled={saving()}>
{saving() ? 'Saving...' : 'Save Tax'}
</button>
</div>
</form>
</section>
</Show>
<section class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Name</th>
<th>Rate (%)</th>
<th>Description</th>
<th>Status</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={taxes.loading}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!taxes.loading && taxes.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={!taxes.loading && !taxes.error && taxes()?.length === 0}>
<tr><td colspan="5" style="text-align:center;padding:32px;color:#94a3b8">No records found.</td></tr>
</Show>
<Show when={!taxes.loading && !taxes.error && (taxes()?.length ?? 0) > 0}>
{taxes()!.map((item) => (
<tr>
<td style="font-weight:600;color:#0f172a">{item.name}</td>
<td style="color:#475569">{item.rate}%</td>
<td style="color:#475569">{item.description || '—'}</td>
<td>
<span class={`status-chip ${item.is_active !== false ? 'active' : ''}`}>
{item.is_active !== false ? 'Active' : 'Inactive'}
</span>
</td>
<td>
<div class="table-actions">
<a class="btn" href={`/admin/tax/${item.id}/edit`}>Edit</a>
<button
class="btn danger"
disabled={deleting() === item.id}
onClick={() => handleDelete(item.id, item.name)}
>
{deleting() === item.id ? '...' : 'Delete'}
</button>
</div>
</td>
</tr>
))}
</Show>
</tbody>
</table>
</div>
</section>
</AdminShell>
);
}

124
src/routes/admin/tutors.tsx Normal file
View file

@ -0,0 +1,124 @@
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 [];
}
}
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="page-actions">
<div>
<h1 class="page-title">Tutors Management</h1>
<p class="page-subtitle">Manage all tutor accounts on the platform.</p>
</div>
</div>
<section class="card" style="padding: 0; overflow: hidden;">
<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="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Registered</th>
<th class="align-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 tutor users found.</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length > 0}>
<For each={filtered()}>
{(item) => (
<tr>
<td style="font-weight:600;color:#0f172a">{item.name || item.full_name || '—'}</td>
<td style="color:#475569">{item.email}</td>
<td>
{item.status?.toUpperCase() === 'ACTIVE' && (
<span class="status-chip active">ACTIVE</span>
)}
{item.status?.toUpperCase() === 'INACTIVE' && (
<span class="status-chip">INACTIVE</span>
)}
{item.status?.toUpperCase() === 'PENDING' && (
<span class="status-chip" style="background:#fff7ed;color:#c2410c;border-color:#fed7aa;">PENDING</span>
)}
{!item.status && <span class="status-chip"></span>}
</td>
<td style="color:#475569">
{item.created_at ? new Date(item.created_at).toLocaleDateString() : '—'}
</td>
<td>
<div class="table-actions">
<A class="btn" href={`/admin/users/${item.id}`}>View</A>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</AdminShell>
);
}

180
src/routes/admin/users.tsx Normal file
View file

@ -0,0 +1,180 @@
import { A } from '@solidjs/router';
import { createMemo, createResource, createSignal, For, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
interface User {
id: string;
name?: string;
full_name?: string;
email: string;
role?: string;
role_name?: string;
status: 'ACTIVE' | 'INACTIVE' | 'PENDING';
created_at?: string;
createdAt?: string;
}
const ROLE_OPTIONS = [
'company',
'job_seeker',
'customer',
'photographer',
'video_editor',
'graphic_designer',
'social_media_manager',
'fitness_trainer',
'catering_services',
'makeup_artist',
'tutor',
'developer',
];
async function fetchUsers(): Promise<User[]> {
try {
const res = await fetch(`${API}/api/admin/users`);
if (res.status === 404) {
const res2 = await fetch(`${API}/api/users`);
if (!res2.ok) throw new Error('Failed to load');
const data2 = await res2.json();
return Array.isArray(data2) ? data2 : (data2.users || []);
}
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(props: { status: string }) {
if (props.status === 'ACTIVE') {
return <span class="status-chip active">ACTIVE</span>;
}
if (props.status === 'PENDING') {
return <span class="status-chip" style="background:#f59e0b;color:#fff">PENDING</span>;
}
return <span class="status-chip">INACTIVE</span>;
}
export default function UsersPage() {
const [users, { refetch }] = createResource(fetchUsers);
const [search, setSearch] = createSignal('');
const [filterRole, setFilterRole] = createSignal('');
const [filterStatus, setFilterStatus] = createSignal('');
const filtered = createMemo(() => {
const list = users() ?? [];
const q = search().toLowerCase();
const role = filterRole();
const status = filterStatus();
return list.filter((u) => {
const name = (u.name || u.full_name || '').toLowerCase();
const email = u.email.toLowerCase();
const matchSearch = !q || name.includes(q) || email.includes(q);
const matchRole = !role || u.role === role || u.role_name === role;
const matchStatus = !status || u.status === status;
return matchSearch && matchRole && matchStatus;
});
});
return (
<AdminShell>
<div class="page-actions">
<div>
<h1 class="page-title">External User Management</h1>
<p class="page-subtitle">Manage all external platform users.</p>
</div>
</div>
{/* Filters */}
<div class="card" style="margin-bottom:16px;display:flex;gap:12px;flex-wrap:wrap;align-items:center;">
<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:7px 12px;font-size:14px;width:260px;outline:none;"
/>
<select
value={filterRole()}
onChange={(e) => setFilterRole(e.currentTarget.value)}
style="border:1px solid #cbd5e1;border-radius:6px;padding:7px 12px;font-size:14px;background:#fff;outline:none;"
>
<option value="">All Roles</option>
<For each={ROLE_OPTIONS}>
{(r) => <option value={r}>{r}</option>}
</For>
</select>
<select
value={filterStatus()}
onChange={(e) => setFilterStatus(e.currentTarget.value)}
style="border:1px solid #cbd5e1;border-radius:6px;padding:7px 12px;font-size:14px;background:#fff;outline:none;"
>
<option value="">All Status</option>
<option value="ACTIVE">ACTIVE</option>
<option value="INACTIVE">INACTIVE</option>
<option value="PENDING">PENDING</option>
</select>
<Show when={!users.loading}>
<span style="font-size:13px;color:#64748b;margin-left:auto;">
Showing {filtered().length} of {users()?.length ?? 0} users
</span>
</Show>
</div>
<section class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Created At</th>
<th class="align-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={users.loading}>
<tr><td colspan="6" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!users.loading && users.error}>
<tr><td colspan="6" 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="6" style="text-align:center;padding:32px;color:#94a3b8">No users found.</td></tr>
</Show>
<Show when={!users.loading && !users.error && filtered().length > 0}>
<For each={filtered()}>
{(item) => (
<tr>
<td style="font-weight:600;color:#0f172a">{item.name || item.full_name || '—'}</td>
<td style="color:#475569">{item.email}</td>
<td style="color:#475569">{item.role_name || item.role || '—'}</td>
<td>
<StatusBadge status={item.status} />
</td>
<td style="color:#475569">
{(item.created_at || item.createdAt)
? new Date((item.created_at || item.createdAt)!).toLocaleDateString()
: '—'}
</td>
<td>
<div class="table-actions">
<A class="btn" href={`/admin/users/${item.id}`}>View</A>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</AdminShell>
);
}

View file

@ -0,0 +1,124 @@
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 [];
}
}
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="page-actions">
<div>
<h1 class="page-title">Video Editor Management</h1>
<p class="page-subtitle">Manage all video editor accounts on the platform.</p>
</div>
</div>
<section class="card" style="padding: 0; overflow: hidden;">
<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="table-wrap">
<table class="list-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Registered</th>
<th class="align-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>
<td style="font-weight:600;color:#0f172a">{item.name || item.full_name || '—'}</td>
<td style="color:#475569">{item.email}</td>
<td>
{item.status?.toUpperCase() === 'ACTIVE' && (
<span class="status-chip active">ACTIVE</span>
)}
{item.status?.toUpperCase() === 'INACTIVE' && (
<span class="status-chip">INACTIVE</span>
)}
{item.status?.toUpperCase() === 'PENDING' && (
<span class="status-chip" style="background:#fff7ed;color:#c2410c;border-color:#fed7aa;">PENDING</span>
)}
{!item.status && <span class="status-chip"></span>}
</td>
<td style="color:#475569">
{item.created_at ? new Date(item.created_at).toLocaleDateString() : '—'}
</td>
<td>
<div class="table-actions">
<A class="btn" href={`/admin/users/${item.id}`}>View</A>
</div>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</section>
</AdminShell>
);
}

View file

@ -1,6 +1,14 @@
import { A } from '@solidjs/router';
import { A, useNavigate } from '@solidjs/router';
import { setAdminSession } from '~/lib/admin-session';
export default function Home() {
const navigate = useNavigate();
const skipToAdmin = () => {
setAdminSession();
navigate('/admin', { replace: true });
};
return (
<main class="auth-page">
<div class="auth-bg" />
@ -10,7 +18,7 @@ export default function Home() {
<p class="auth-copy">Secure sign-in and runtime control center for NXTGAUGE operations.</p>
<div class="actions">
<A class="btn primary" href="/login">Sign In</A>
<A class="btn" href="/admin">Skip to Admin</A>
<button type="button" class="btn" onClick={skipToAdmin}>Skip to Admin</button>
</div>
</section>
</main>