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:
parent
bbaeb1b2a9
commit
04a1079f68
50 changed files with 10823 additions and 434 deletions
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
779
src/app.css
779
src/app.css
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
9
src/lib/sidebar-state.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { createSignal } from 'solid-js';
|
||||
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = createSignal(false);
|
||||
|
||||
export function toggleSidebar() {
|
||||
setSidebarCollapsed((v) => !v);
|
||||
}
|
||||
|
||||
export { sidebarCollapsed };
|
||||
386
src/routes/admin/approval.tsx
Normal file
386
src/routes/admin/approval.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/routes/admin/candidate.tsx
Normal file
124
src/routes/admin/candidate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/routes/admin/catering-services.tsx
Normal file
124
src/routes/admin/catering-services.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
src/routes/admin/company.tsx
Normal file
156
src/routes/admin/company.tsx
Normal 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
335
src/routes/admin/coupon.tsx
Normal 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
405
src/routes/admin/credit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/routes/admin/customer.tsx
Normal file
124
src/routes/admin/customer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
424
src/routes/admin/department.tsx
Normal file
424
src/routes/admin/department.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
425
src/routes/admin/designation.tsx
Normal file
425
src/routes/admin/designation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/routes/admin/developers.tsx
Normal file
124
src/routes/admin/developers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
339
src/routes/admin/discount.tsx
Normal file
339
src/routes/admin/discount.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
353
src/routes/admin/employees.tsx
Normal file
353
src/routes/admin/employees.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
451
src/routes/admin/external-dashboard-management/index.tsx
Normal file
451
src/routes/admin/external-dashboard-management/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/routes/admin/fitness-trainers.tsx
Normal file
124
src/routes/admin/fitness-trainers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/routes/admin/graphic-designers.tsx
Normal file
124
src/routes/admin/graphic-designers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
556
src/routes/admin/internal-dashboard-management/index.tsx
Normal file
556
src/routes/admin/internal-dashboard-management/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
src/routes/admin/invoice.tsx
Normal file
118
src/routes/admin/invoice.tsx
Normal 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
157
src/routes/admin/jobs.tsx
Normal 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
544
src/routes/admin/kb.tsx
Normal 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
155
src/routes/admin/leads.tsx
Normal 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
118
src/routes/admin/ledger.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/routes/admin/makeup-artist.tsx
Normal file
124
src/routes/admin/makeup-artist.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
src/routes/admin/notifications.tsx
Normal file
187
src/routes/admin/notifications.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 [config, setConfig] = createSignal<RuntimeOnboardingConfig | null>(null);
|
||||
const [stepsJson, setStepsJson] = createSignal('[]');
|
||||
const [statusMessage, setStatusMessage] = createSignal('');
|
||||
const [stepsError, setStepsError] = createSignal('');
|
||||
const [isSaving, setIsSaving] = createSignal(false);
|
||||
const navigate = useNavigate();
|
||||
const [data] = createResource(() => params.schemaId, loadSchema);
|
||||
|
||||
// Sync resource to local signals
|
||||
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);
|
||||
|
||||
// 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
144
src/routes/admin/order.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/routes/admin/photographer.tsx
Normal file
124
src/routes/admin/photographer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
361
src/routes/admin/pricing.tsx
Normal file
361
src/routes/admin/pricing.tsx
Normal 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
120
src/routes/admin/report.tsx
Normal 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
318
src/routes/admin/review.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
234
src/routes/admin/roles/[id]/edit.tsx
Normal file
234
src/routes/admin/roles/[id]/edit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
src/routes/admin/roles/[id]/index.tsx
Normal file
131
src/routes/admin/roles/[id]/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
211
src/routes/admin/roles/create.tsx
Normal file
211
src/routes/admin/roles/create.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
src/routes/admin/roles/index.tsx
Normal file
129
src/routes/admin/roles/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 [config, setConfig] = createSignal<RuntimeRoleConfig | null>(null);
|
||||
const [statusMessage, setStatusMessage] = createSignal('');
|
||||
const [isSaving, setIsSaving] = createSignal(false);
|
||||
const navigate = useNavigate();
|
||||
const [data] = createResource(() => params.roleKey, loadRole);
|
||||
|
||||
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;
|
||||
|
||||
setIsSaving(true);
|
||||
setStatusMessage('Saving to backend...');
|
||||
|
||||
const set = (patch: Partial<Config>) => setConfig({ ...config()!, ...patch });
|
||||
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
124
src/routes/admin/social-media-managers.tsx
Normal file
124
src/routes/admin/social-media-managers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
370
src/routes/admin/support.tsx
Normal file
370
src/routes/admin/support.tsx
Normal 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
179
src/routes/admin/tax.tsx
Normal 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
124
src/routes/admin/tutors.tsx
Normal 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
180
src/routes/admin/users.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/routes/admin/video-editors.tsx
Normal file
124
src/routes/admin/video-editors.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue