fix: update admin forms to match DB schema

This commit is contained in:
Tracewebstudio Dev 2026-04-15 06:23:29 +02:00
parent 3265b1a16a
commit 232309e353
12 changed files with 4520 additions and 2820 deletions

0
admin-solid.dev.log Normal file
View file

1
admin-solid.dev.pid Normal file
View file

@ -0,0 +1 @@
7741

View file

@ -1,42 +1,42 @@
import { createSignal, createMemo, onMount, Show, For } from 'solid-js';
import { createSignal, createMemo, onMount, Show, For } from "solid-js";
const API = '';
const API = "";
function getToken(): string {
return typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
return typeof sessionStorage !== "undefined"
? sessionStorage.getItem("nxtgauge_admin_access_token") || ""
: "";
}
function authHeaders(): Record<string, string> {
const token = getToken();
return {
Accept: 'application/json',
'Content-Type': 'application/json',
Accept: "application/json",
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
const ROLE_OPTIONS = [
'company',
'customer',
'job_seeker',
'photographer',
'video_editor',
'graphic_designer',
'social_media_manager',
'fitness_trainer',
'catering_services',
'makeup_artist',
'tutor',
'developer',
"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';
type: "PERCENT" | "FIXED";
value: number;
min_order_amount: number;
used_count: number;
@ -45,44 +45,50 @@ type Coupon = {
role_keys: string[];
};
const defaultForm = () => ({
id: '',
code: '',
title: '',
type: 'PERCENT' as 'PERCENT' | 'FIXED',
id: "",
code: "",
title: "",
type: "PERCENT" as "PERCENT" | "FIXED",
value: 10,
min_order_amount: 0,
max_uses: '',
role_keys: ['company', 'customer'] as string[],
max_uses: "",
applies_to: "ALL" as "ALL" | "ROLE",
role_keys: ["company", "customer"] as string[],
});
export default function CouponPage() {
const [coupons, setCoupons] = createSignal<Coupon[]>([]);
const [loading, setLoading] = createSignal(true);
const [loadError, setLoadError] = createSignal('');
const [activeTab, setActiveTab] = createSignal<'list' | 'create'>('list');
const [loadError, setLoadError] = createSignal("");
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 [toggling, setToggling] = createSignal("");
const [formError, setFormError] = createSignal("");
// Filters
const [search, setSearch] = createSignal('');
const [statusFilter, setStatusFilter] = createSignal('all');
const [sortBy, setSortBy] = createSignal<'newest' | 'oldest' | 'code_asc' | 'code_desc'>('newest');
const [search, setSearch] = createSignal("");
const [statusFilter, setStatusFilter] = createSignal("all");
const [sortBy, setSortBy] = createSignal<"newest" | "oldest" | "code_asc" | "code_desc">(
"newest"
);
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
const load = async () => {
setLoading(true); setLoadError('');
setLoading(true);
setLoadError("");
try {
const res = await fetch(`${API}/api/admin/coupons`, { headers: authHeaders(), credentials: 'include' });
const res = await fetch(`${API}/api/admin/coupons`, {
headers: authHeaders(),
credentials: "include",
});
if (!res.ok) throw new Error(`Request failed (${res.status})`);
const data = await res.json();
setCoupons(Array.isArray(data) ? data : (data.coupons ?? []));
} catch (err: any) {
setLoadError(err.message || 'Could not load coupons.');
setLoadError(err.message || "Could not load coupons.");
setCoupons([]);
} finally {
setLoading(false);
@ -94,59 +100,62 @@ export default function CouponPage() {
const filteredCoupons = createMemo(() => {
let r = coupons();
const q = search().toLowerCase();
if (q) r = r.filter((c) => c.code.toLowerCase().includes(q) || (c.title || '').toLowerCase().includes(q));
if (statusFilter() === 'active') r = r.filter((c) => c.is_active);
if (statusFilter() === 'inactive') r = r.filter((c) => !c.is_active);
if (q)
r = r.filter(
(c) => c.code.toLowerCase().includes(q) || (c.title || "").toLowerCase().includes(q)
);
if (statusFilter() === "active") r = r.filter((c) => c.is_active);
if (statusFilter() === "inactive") r = r.filter((c) => !c.is_active);
const sorted = [...r];
sorted.sort((a, b) => {
if (sortBy() === 'oldest') return String(a.id || '').localeCompare(String(b.id || ''));
if (sortBy() === 'code_asc') return String(a.code || '').localeCompare(String(b.code || ''));
if (sortBy() === 'code_desc') return String(b.code || '').localeCompare(String(a.code || ''));
return String(b.id || '').localeCompare(String(a.id || ''));
if (sortBy() === "oldest") return String(a.id || "").localeCompare(String(b.id || ""));
if (sortBy() === "code_asc") return String(a.code || "").localeCompare(String(b.code || ""));
if (sortBy() === "code_desc") return String(b.code || "").localeCompare(String(a.code || ""));
return String(b.id || "").localeCompare(String(a.id || ""));
});
r = sorted;
return r;
});
const exportCsv = () => {
const headers = ['Code', 'Title', 'Type', 'Value', 'Max Uses', 'Status'];
const headers = ["Code", "Title", "Type", "Value", "Max Uses", "Status"];
const rows = filteredCoupons().map((item) => [
item.code,
item.title || '',
item.title || "",
item.type,
item.type === 'PERCENT' ? `${item.value}%` : `${item.value}`,
item.usage_limit != null ? String(item.usage_limit) : '—',
item.is_active ? 'Active' : 'Inactive',
item.type === "PERCENT" ? `${item.value}%` : `${item.value}`,
item.usage_limit != null ? String(item.usage_limit) : "—",
item.is_active ? "Active" : "Inactive",
]);
const csv = [headers, ...rows]
.map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
.map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","))
.join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const link = document.createElement("a");
link.href = url;
link.download = 'coupon-management.csv';
link.download = "coupon-management.csv";
link.click();
URL.revokeObjectURL(url);
};
const resetForm = () => {
setForm(defaultForm());
setFormError('');
setFormError("");
};
const startEdit = (coupon: Coupon) => {
setForm({
id: coupon.id,
code: coupon.code,
title: coupon.title || '',
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) : '',
max_uses: coupon.usage_limit != null ? String(coupon.usage_limit) : "",
role_keys: Array.isArray(coupon.role_keys) ? coupon.role_keys : [],
});
setActiveTab('create');
setActiveTab("create");
};
const toggleRole = (role: string) => {
@ -162,32 +171,33 @@ export default function CouponPage() {
e.preventDefault();
try {
setSaving(true);
setFormError('');
setFormError("");
const f = form();
const body: Record<string, unknown> = {
code: f.code.toUpperCase(),
title: f.title,
type: f.type,
value: Number(f.value),
discount_type: f.type,
discount_value: Number(f.value),
applies_to: f.applies_to,
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 method = f.id ? "PATCH" : "POST";
const res = await fetch(url, {
method,
headers: authHeaders(),
credentials: 'include',
credentials: "include",
body: JSON.stringify(body),
});
if (!res.ok) throw new Error('Failed to save coupon');
if (!res.ok) throw new Error("Failed to save coupon");
resetForm();
await load();
setActiveTab('list');
setActiveTab("list");
} catch (err: unknown) {
setFormError(err instanceof Error ? err.message : 'Failed to save');
setFormError(err instanceof Error ? err.message : "Failed to save");
} finally {
setSaving(false);
}
@ -197,17 +207,17 @@ export default function CouponPage() {
try {
setToggling(coupon.id);
const res = await fetch(`${API}/api/admin/coupons/${coupon.id}`, {
method: 'PATCH',
method: "PATCH",
headers: authHeaders(),
credentials: 'include',
credentials: "include",
body: JSON.stringify({ is_active: !coupon.is_active }),
});
if (!res.ok) throw new Error('Failed to toggle');
if (!res.ok) throw new Error("Failed to toggle");
await load();
} catch {
// ignore
} finally {
setToggling('');
setToggling("");
}
};
@ -222,22 +232,33 @@ export default function CouponPage() {
<div class="bg-white border-b border-gray-200 px-6 flex items-center gap-8 sticky top-0 z-10">
<button
type="button"
class={activeTab() === 'list' ? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium' : 'py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors'}
onClick={() => setActiveTab('list')}
class={
activeTab() === "list"
? "py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium"
: "py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors"
}
onClick={() => setActiveTab("list")}
>
Coupons
</button>
<button
type="button"
class={activeTab() === 'create' ? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium' : 'py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors'}
onClick={() => { resetForm(); setActiveTab('create'); }}
class={
activeTab() === "create"
? "py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium"
: "py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors"
}
onClick={() => {
resetForm();
setActiveTab("create");
}}
>
{form().id ? 'Edit Coupon' : 'Create Coupon'}
{form().id ? "Edit Coupon" : "Create Coupon"}
</button>
</div>
<div>
<Show when={activeTab() === 'list'}>
<Show when={activeTab() === "list"}>
<div style="position:relative;margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:visible;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6;position:relative;z-index:20;margin-bottom:0">
<input
@ -251,22 +272,51 @@ export default function CouponPage() {
<div style="position:relative;">
<button
type="button"
onClick={() => { setSortMenuOpen((v) => !v); setFilterMenuOpen(false); }}
onClick={() => {
setSortMenuOpen((v) => !v);
setFilterMenuOpen(false);
}}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 4v13"/><path d="m3 13 4 4 4-4"/><path d="M17 20V7"/><path d="m21 11-4-4-4 4"/></svg>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M7 4v13" />
<path d="m3 13 4 4 4-4" />
<path d="M17 20V7" />
<path d="m21 11-4-4-4 4" />
</svg>
Sort
</button>
<Show when={sortMenuOpen()}>
<div style="position:absolute;right:0;top:38px;z-index:40;min-width:180px;border:1px solid #E5E7EB;border-radius:12px;background:#fff;box-shadow:0 12px 24px rgba(2,6,23,0.12);padding:8px">
<For each={[
{ key: 'newest', label: 'Newest First' },
{ key: 'oldest', label: 'Oldest First' },
{ key: 'code_asc', label: 'Code A-Z' },
{ key: 'code_desc', label: 'Code Z-A' },
] as { key: 'newest' | 'oldest' | 'code_asc' | 'code_desc'; label: string }[]}>
<For
each={
[
{ key: "newest", label: "Newest First" },
{ key: "oldest", label: "Oldest First" },
{ key: "code_asc", label: "Code A-Z" },
{ key: "code_desc", label: "Code Z-A" },
] as {
key: "newest" | "oldest" | "code_asc" | "code_desc";
label: string;
}[]
}
>
{(item) => (
<button type="button" onClick={() => { setSortBy(item.key); setSortMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === item.key ? '#FF5E13' : '#374151'};background:${sortBy() === item.key ? '#FFF1EB' : 'transparent'}`}>
<button
type="button"
onClick={() => {
setSortBy(item.key);
setSortMenuOpen(false);
}}
style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === item.key ? "#FF5E13" : "#374151"};background:${sortBy() === item.key ? "#FFF1EB" : "transparent"}`}
>
{item.label}
</button>
)}
@ -278,21 +328,44 @@ export default function CouponPage() {
<div style="position:relative;">
<button
type="button"
onClick={() => { setFilterMenuOpen((v) => !v); setSortMenuOpen(false); }}
onClick={() => {
setFilterMenuOpen((v) => !v);
setSortMenuOpen(false);
}}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h18M6 12h12M10 19h4"/></svg>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 5h18M6 12h12M10 19h4" />
</svg>
Filters
</button>
<Show when={filterMenuOpen()}>
<div style="position:absolute;right:0;top:38px;z-index:40;min-width:180px;border:1px solid #E5E7EB;border-radius:12px;background:#fff;box-shadow:0 12px 24px rgba(2,6,23,0.12);padding:8px">
<For each={[
{ key: 'all', label: 'All Status' },
{ key: 'active', label: 'Active' },
{ key: 'inactive', label: 'Inactive' },
] as { key: string; label: string }[]}>
<For
each={
[
{ key: "all", label: "All Status" },
{ key: "active", label: "Active" },
{ key: "inactive", label: "Inactive" },
] as { key: string; label: string }[]
}
>
{(item) => (
<button type="button" onClick={() => { setStatusFilter(item.key); setFilterMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === item.key ? '#FF5E13' : '#374151'};background:${statusFilter() === item.key ? '#FFF1EB' : 'transparent'}`}>
<button
type="button"
onClick={() => {
setStatusFilter(item.key);
setFilterMenuOpen(false);
}}
style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === item.key ? "#FF5E13" : "#374151"};background:${statusFilter() === item.key ? "#FFF1EB" : "transparent"}`}
>
{item.label}
</button>
)}
@ -301,13 +374,30 @@ export default function CouponPage() {
</Show>
</div>
<button type="button" onClick={exportCsv} style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:600;color:white;border:none;cursor:pointer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<button
type="button"
onClick={exportCsv}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:600;color:white;border:none;cursor:pointer"
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Export
</button>
</div>
<Show when={loadError()}>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{loadError()}</div>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{loadError()}
</div>
</Show>
<div class="table-card">
<div class="overflow-x-auto">
@ -325,35 +415,62 @@ export default function CouponPage() {
</thead>
<tbody>
<Show when={loading()}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
<tr>
<td colspan="7" style="text-align:center;padding:32px;color:#64748b">
Loading...
</td>
</tr>
</Show>
<Show when={!loading() && filteredCoupons().length === 0}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">No coupons found.</td></tr>
<tr>
<td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">
No coupons found.
</td>
</tr>
</Show>
<Show when={!loading() && filteredCoupons().length > 0}>
<For each={filteredCoupons()}>
{(item) => (
<tr class="hover:bg-slate-50">
<td class="font-semibold text-slate-900" style="font-family:monospace">{item.code}</td>
<td class="text-slate-500">{item.title || '—'}</td>
<td class="font-semibold text-slate-900" style="font-family:monospace">
{item.code}
</td>
<td class="text-slate-500">{item.title || "—"}</td>
<td class="text-slate-500">{item.type}</td>
<td class="text-slate-500">{item.type === 'PERCENT' ? `${item.value}%` : `${item.value}`}</td>
<td class="text-slate-500">{item.usage_limit != null ? item.usage_limit : '—'}</td>
<td class="text-slate-500">
{item.type === "PERCENT" ? `${item.value}%` : `${item.value}`}
</td>
<td class="text-slate-500">
{item.usage_limit != null ? item.usage_limit : "—"}
</td>
<td>
<span style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${item.is_active ? '#FFD8C2' : '#D1D5DB'};background:${item.is_active ? '#FFF1EB' : '#F3F4F6'};color:${item.is_active ? '#FF5E13' : '#4B5563'};padding:2px 10px;font-size:12px;font-weight:500`}>
<span style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${item.is_active ? '#FF5E13' : '#9CA3AF'};margin-right:5px;flex-shrink:0`} />
{item.is_active ? 'Active' : 'Inactive'}
<span
style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${item.is_active ? "#FFD8C2" : "#D1D5DB"};background:${item.is_active ? "#FFF1EB" : "#F3F4F6"};color:${item.is_active ? "#FF5E13" : "#4B5563"};padding:2px 10px;font-size:12px;font-weight:500`}
>
<span
style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${item.is_active ? "#FF5E13" : "#9CA3AF"};margin-right:5px;flex-shrink:0`}
/>
{item.is_active ? "Active" : "Inactive"}
</span>
</td>
<td>
<div class="flex items-center justify-end gap-1">
<button class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" onClick={() => startEdit(item)}>Edit</button>
<button
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
onClick={() => startEdit(item)}
>
Edit
</button>
<button
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
disabled={toggling() === item.id}
onClick={() => handleToggle(item)}
>
{toggling() === item.id ? '...' : (item.is_active ? 'Disable' : 'Enable')}
{toggling() === item.id
? "..."
: item.is_active
? "Disable"
: "Enable"}
</button>
</div>
</td>
@ -373,11 +490,21 @@ export default function CouponPage() {
</div>
</Show>
<Show when={activeTab() === 'create'}>
<section class="rounded-xl border border-gray-200 bg-white shadow-sm" 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={activeTab() === "create"}>
<section
class="rounded-xl border border-gray-200 bg-white shadow-sm"
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="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-bottom:12px">{formError()}</div>
<div
class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
style="margin-bottom:12px"
>
{formError()}
</div>
</Show>
<form onSubmit={handleSave} style="display:flex;flex-direction:column;gap:14px">
<div class="field">
@ -406,7 +533,9 @@ export default function CouponPage() {
<label>Type</label>
<select
value={form().type}
onChange={(e) => setForm({ ...form(), type: e.currentTarget.value as 'PERCENT' | 'FIXED' })}
onChange={(e) =>
setForm({ ...form(), type: e.currentTarget.value as "PERCENT" | "FIXED" })
}
>
<option value="PERCENT">Percent (%)</option>
<option value="FIXED">Fixed ()</option>
@ -429,7 +558,9 @@ export default function CouponPage() {
<input
type="number"
value={form().min_order_amount}
onInput={(e) => setForm({ ...form(), min_order_amount: Number(e.currentTarget.value) })}
onInput={(e) =>
setForm({ ...form(), min_order_amount: Number(e.currentTarget.value) })
}
min="0"
placeholder="0"
/>
@ -444,9 +575,23 @@ export default function CouponPage() {
placeholder="Unlimited"
/>
</div>
<div class="field">
<label>Applies To</label>
<select
value={form().applies_to}
onChange={(e) =>
setForm({ ...form(), applies_to: e.currentTarget.value as "ALL" | "ROLE" })
}
>
<option value="ALL">All</option>
<option value="ROLE">Specific Roles</option>
</select>
</div>
</div>
<div>
<p style="font-size:13px;font-weight:600;margin:0 0 8px;color:#1e293b">Applicable Roles</p>
<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) => {
@ -455,7 +600,7 @@ export default function CouponPage() {
<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'}`}
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>
@ -466,10 +611,16 @@ export default function CouponPage() {
</div>
<div class="actions">
<button class="btn-primary" type="submit" disabled={saving()}>
{saving() ? 'Saving...' : (form().id ? 'Update Coupon' : 'Save Coupon')}
{saving() ? "Saving..." : form().id ? "Update Coupon" : "Save Coupon"}
</button>
<Show when={form().id}>
<button type="button" class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" onClick={resetForm}>Cancel Edit</button>
<button
type="button"
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
onClick={resetForm}
>
Cancel Edit
</button>
</Show>
</div>
</form>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,14 +1,16 @@
import { A, useNavigate } from '@solidjs/router';
import { createResource, createSignal, For, onMount, Show } from 'solid-js';
import { A, useNavigate } from "@solidjs/router";
import { createResource, createSignal, For, onMount, Show } from "solid-js";
const API = '';
const API = "";
type Role = { id: string; name: string };
type Dept = { id: string; name: string };
type Desig = { id: string; name: string };
function parseEmployeeCodeNumber(code: string): number | null {
const normalized = String(code || '').trim().toUpperCase();
const normalized = String(code || "")
.trim()
.toUpperCase();
if (!normalized) return null;
const explicit = normalized.match(/^EMP[-_]?0*(\d+)$/);
if (explicit) return Number(explicit[1]);
@ -18,59 +20,68 @@ function parseEmployeeCodeNumber(code: string): number | null {
}
function formatEmployeeCode(value: number): string {
return `EMP-${String(Math.max(1, value)).padStart(4, '0')}`;
return `EMP-${String(Math.max(1, value)).padStart(4, "0")}`;
}
async function fetchRoles(): Promise<Role[]> {
try {
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const accessToken =
typeof sessionStorage !== "undefined"
? (sessionStorage.getItem("nxtgauge_admin_access_token") || "").trim()
: "";
const res = await fetch(`${API}/api/admin/roles?audience=INTERNAL&per_page=100`, {
headers: {
Accept: 'application/json',
Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
credentials: "include",
});
if (!res.ok) throw new Error();
const data = await res.json();
return Array.isArray(data) ? data : (data.roles ?? []);
} catch { return []; }
} catch {
return [];
}
}
async function fetchDepts(): Promise<Dept[]> {
try {
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const accessToken =
typeof sessionStorage !== "undefined"
? (sessionStorage.getItem("nxtgauge_admin_access_token") || "").trim()
: "";
const res = await fetch(`${API}/api/admin/departments?per_page=100`, {
headers: {
Accept: 'application/json',
Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
credentials: "include",
});
if (!res.ok) throw new Error();
const data = await res.json();
return Array.isArray(data) ? data : (data.departments ?? []);
} catch { return []; }
} catch {
return [];
}
}
async function fetchDesigs(): Promise<Desig[]> {
try {
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const accessToken =
typeof sessionStorage !== "undefined"
? (sessionStorage.getItem("nxtgauge_admin_access_token") || "").trim()
: "";
const res = await fetch(`${API}/api/admin/designations?per_page=100`, {
headers: {
Accept: 'application/json',
Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
credentials: "include",
});
if (!res.ok) throw new Error();
const data = await res.json();
return Array.isArray(data) ? data : (data.designations ?? []);
} catch { return []; }
} catch {
return [];
}
}
export default function CreateEmployeePage() {
@ -79,33 +90,37 @@ export default function CreateEmployeePage() {
const [depts] = createResource(fetchDepts);
const [desigs] = createResource(fetchDesigs);
const [fullName, setFullName] = createSignal('');
const [email, setEmail] = createSignal('');
const [employeeCode, setEmployeeCode] = createSignal('');
const [fullName, setFullName] = createSignal("");
const [email, setEmail] = createSignal("");
const [employeeCode, setEmployeeCode] = createSignal("");
const [createLoginCreds, setCreateLoginCreds] = createSignal(true);
const [loginPassword, setLoginPassword] = createSignal('');
const [confirmLoginPassword, setConfirmLoginPassword] = createSignal('');
const [roleId, setRoleId] = createSignal('');
const [deptId, setDeptId] = createSignal('');
const [desigId, setDesigId] = createSignal('');
const [loginPassword, setLoginPassword] = createSignal("");
const [confirmLoginPassword, setConfirmLoginPassword] = createSignal("");
const [roleId, setRoleId] = createSignal("");
const [deptId, setDeptId] = createSignal("");
const [desigId, setDesigId] = createSignal("");
const [saving, setSaving] = createSignal(false);
const [generatingCode, setGeneratingCode] = createSignal(false);
const [error, setError] = createSignal('');
const [error, setError] = createSignal("");
const fetchNextEmployeeCode = async (): Promise<string> => {
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const accessToken =
typeof sessionStorage !== "undefined"
? (sessionStorage.getItem("nxtgauge_admin_access_token") || "").trim()
: "";
let page = 1;
let maxNum = 0;
while (page <= 100) {
const res = await fetch(`${API}/api/admin/employees?page=${page}&per_page=100&sort=joined_desc`, {
const res = await fetch(
`${API}/api/admin/employees?page=${page}&per_page=100&sort=joined_desc`,
{
headers: {
Accept: 'application/json',
Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
}).catch(() => null);
credentials: "include",
}
).catch(() => null);
if (!res?.ok) break;
const payload = await res.json().catch(() => null);
const list: any[] = Array.isArray(payload)
@ -117,7 +132,7 @@ export default function CreateEmployeePage() {
: [];
if (!Array.isArray(list) || list.length === 0) break;
for (const item of list) {
const raw = String(item?.employee_id ?? item?.employeeId ?? item?.employee_code ?? '');
const raw = String(item?.employee_id ?? item?.employeeId ?? item?.employee_code ?? "");
const parsed = parseEmployeeCodeNumber(raw);
if (parsed && parsed > maxNum) maxNum = parsed;
}
@ -133,7 +148,7 @@ export default function CreateEmployeePage() {
try {
setEmployeeCode(await fetchNextEmployeeCode());
} catch {
setEmployeeCode('');
setEmployeeCode("");
} finally {
setGeneratingCode(false);
}
@ -142,46 +157,72 @@ export default function CreateEmployeePage() {
const handleSave = async (e: Event) => {
e.preventDefault();
if (!fullName().trim()) { setError('Full name is required'); return; }
if (!email().trim()) { setError('Email is required'); return; }
if (!roleId()) { setError('Internal role is required'); return; }
if (!deptId()) { setError('Department is required'); return; }
if (!desigId()) { setError('Designation is required'); return; }
if (createLoginCreds()) {
if (loginPassword().trim().length < 8) { setError('Password must be at least 8 characters'); return; }
if (loginPassword().trim() !== confirmLoginPassword().trim()) { setError('Password and confirm password do not match'); return; }
if (!fullName().trim()) {
setError("Full name is required");
return;
}
setError(''); setSaving(true);
if (!email().trim()) {
setError("Email is required");
return;
}
if (!roleId()) {
setError("Internal role is required");
return;
}
if (!deptId()) {
setError("Department is required");
return;
}
if (!desigId()) {
setError("Designation is required");
return;
}
if (createLoginCreds()) {
if (loginPassword().trim().length < 8) {
setError("Password must be at least 8 characters");
return;
}
if (loginPassword().trim() !== confirmLoginPassword().trim()) {
setError("Password and confirm password do not match");
return;
}
}
setError("");
setSaving(true);
try {
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const accessToken =
typeof sessionStorage !== "undefined"
? (sessionStorage.getItem("nxtgauge_admin_access_token") || "").trim()
: "";
const res = await fetch(`${API}/api/admin/employees/provision`, {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
"Content-Type": "application/json",
Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
method: 'POST',
credentials: 'include',
method: "POST",
credentials: "include",
body: JSON.stringify({
email: email().trim(),
full_name: fullName().trim(),
role_id: roleId(),
department_id: deptId(),
designation_id: desigId(),
employee_code: employeeCode() || undefined,
first_name: fullName().trim().split(" ")[0] || "",
last_name: fullName().trim().split(" ").slice(1).join(" ") || "",
role_code: roleId(),
department_id: deptId().trim(),
designation_id: desigId().trim(),
employee_code: employeeCode().trim() || undefined,
generate_login: createLoginCreds(),
password: createLoginCreds() ? loginPassword().trim() : undefined,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error((body as any).error || (body as any).message || 'Failed to create employee');
throw new Error(
(body as any).error || (body as any).message || "Failed to create employee"
);
}
navigate('/admin/employees');
navigate("/admin/employees");
} catch (err: any) {
setError(err.message || 'Failed to create employee');
setError(err.message || "Failed to create employee");
} finally {
setSaving(false);
}
@ -189,13 +230,16 @@ export default function CreateEmployeePage() {
return (
<div class="w-full space-y-8 pb-8">
{/* Page header */}
<div class="flex items-end justify-between">
<div>
<p class="text-[12px] font-semibold uppercase tracking-widest text-[#FF5E13]">Internal Team</p>
<p class="text-[12px] font-semibold uppercase tracking-widest text-[#FF5E13]">
Internal Team
</p>
<h1 class="mt-1 text-[28px] font-bold leading-tight text-[#111827]">Add Employee</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">Dashboard / Employee Management / Add Employee</p>
<p class="mt-1 text-[14px] text-[#6B7280]">
Dashboard / Employee Management / Add Employee
</p>
</div>
<A
href="/admin/employees"
@ -209,12 +253,13 @@ export default function CreateEmployeePage() {
<div class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm overflow-hidden">
<div class="border-b border-[#F3F4F6] px-6 py-4">
<h2 class="text-[15px] font-semibold text-[#111827]">Employee Details</h2>
<p class="mt-0.5 text-[13px] text-[#6B7280]">Login credentials will be emailed to the employee automatically.</p>
<p class="mt-0.5 text-[13px] text-[#6B7280]">
Login credentials will be emailed to the employee automatically.
</p>
</div>
<form onSubmit={handleSave} class="p-6 space-y-5">
<div class="grid grid-cols-2 gap-5">
{/* Full Name */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
@ -225,7 +270,7 @@ export default function CreateEmployeePage() {
required
placeholder="e.g. Arjun Sharma"
value={fullName()}
onInput={e => setFullName(e.currentTarget.value)}
onInput={(e) => setFullName(e.currentTarget.value)}
maxlength="100"
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
/>
@ -241,7 +286,7 @@ export default function CreateEmployeePage() {
required
placeholder="e.g. arjun@nxtgauge.com"
value={email()}
onInput={e => setEmail(e.currentTarget.value)}
onInput={(e) => setEmail(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
/>
</div>
@ -255,7 +300,7 @@ export default function CreateEmployeePage() {
type="text"
readOnly
value={employeeCode()}
placeholder={generatingCode() ? 'Generating...' : 'Auto generated'}
placeholder={generatingCode() ? "Generating..." : "Auto generated"}
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg bg-[#F9FAFB] text-[#111827] placeholder-[#9CA3AF]"
/>
</div>
@ -263,10 +308,18 @@ export default function CreateEmployeePage() {
{/* Login Credential Controls */}
<div class="col-span-2 rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] px-4 py-3">
<label class="flex items-center gap-2 text-[13px] font-medium text-[#111827]">
<input type="checkbox" checked={createLoginCreds()} onChange={e => setCreateLoginCreds(e.currentTarget.checked)} class="h-4 w-4 accent-[#FF5E13]" />
<input
type="checkbox"
checked={createLoginCreds()}
onChange={(e) => setCreateLoginCreds(e.currentTarget.checked)}
class="h-4 w-4 accent-[#FF5E13]"
/>
Create login credentials if this email does not exist
</label>
<p class="mt-1 text-[12px] text-[#6B7280]">When enabled, a user account is created with the password below and then linked as employee.</p>
<p class="mt-1 text-[12px] text-[#6B7280]">
When enabled, a user account is created with the password below and then linked as
employee.
</p>
</div>
<Show when={createLoginCreds()}>
@ -278,7 +331,7 @@ export default function CreateEmployeePage() {
<input
type="password"
value={loginPassword()}
onInput={e => setLoginPassword(e.currentTarget.value)}
onInput={(e) => setLoginPassword(e.currentTarget.value)}
placeholder="Minimum 8 characters"
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
/>
@ -290,7 +343,7 @@ export default function CreateEmployeePage() {
<input
type="password"
value={confirmLoginPassword()}
onInput={e => setConfirmLoginPassword(e.currentTarget.value)}
onInput={(e) => setConfirmLoginPassword(e.currentTarget.value)}
placeholder="Repeat password"
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
/>
@ -305,11 +358,11 @@ export default function CreateEmployeePage() {
</label>
<select
value={roleId()}
onChange={e => setRoleId(e.currentTarget.value)}
onChange={(e) => setRoleId(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] bg-white text-[#111827]"
>
<option value="">Select role</option>
<For each={roles() ?? []}>{r => <option value={r.id}>{r.name}</option>}</For>
<For each={roles() ?? []}>{(r) => <option value={r.id}>{r.name}</option>}</For>
</select>
</div>
@ -320,11 +373,11 @@ export default function CreateEmployeePage() {
</label>
<select
value={deptId()}
onChange={e => setDeptId(e.currentTarget.value)}
onChange={(e) => setDeptId(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] bg-white text-[#111827]"
>
<option value="">Select department</option>
<For each={depts() ?? []}>{d => <option value={d.id}>{d.name}</option>}</For>
<For each={depts() ?? []}>{(d) => <option value={d.id}>{d.name}</option>}</For>
</select>
</div>
@ -335,14 +388,13 @@ export default function CreateEmployeePage() {
</label>
<select
value={desigId()}
onChange={e => setDesigId(e.currentTarget.value)}
onChange={(e) => setDesigId(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] bg-white text-[#111827]"
>
<option value="">Select designation</option>
<For each={desigs() ?? []}>{d => <option value={d.id}>{d.name}</option>}</For>
<For each={desigs() ?? []}>{(d) => <option value={d.id}>{d.name}</option>}</For>
</select>
</div>
</div>
{/* Info note */}
@ -352,7 +404,9 @@ export default function CreateEmployeePage() {
{/* Error */}
{error() && (
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700">{error()}</div>
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700">
{error()}
</div>
)}
{/* Footer */}
@ -368,12 +422,11 @@ export default function CreateEmployeePage() {
disabled={saving()}
class="h-[40px] rounded-xl bg-[#FF5E13] px-6 text-[13px] font-semibold text-white hover:bg-[#e04d0a] transition-colors disabled:opacity-60"
>
{saving() ? 'Creating…' : 'Add Employee'}
{saving() ? "Creating…" : "Add Employee"}
</button>
</div>
</form>
</div>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
import { A, useParams } from '@solidjs/router';
import { createMemo, createResource, Show } from 'solid-js';
import { A, useParams } from "@solidjs/router";
import { createMemo, createResource, Show } from "solid-js";
const API = '';
const API = "";
type Job = {
id: string;
@ -28,7 +28,7 @@ type Job = {
async function fetchJob(id: string): Promise<Job | null> {
try {
const res = await fetch(`${API}/api/jobs/${id}`);
const res = await fetch(`${API}/api/admin/jobs/${id}`);
if (!res.ok) return null;
const data = await res.json();
return data.job || data;
@ -42,8 +42,11 @@ export default function JobDetailPage() {
const [job] = createResource(() => params.id, fetchJob);
const skills = createMemo(() => job()?.requiredSkills || job()?.required_skills || []);
const client = createMemo(() => job()?.clientName || job()?.client_name || job()?.companyName || job()?.company_name || '—');
const exp = createMemo(() => job()?.experienceLevel || job()?.experience_level || '—');
const client = createMemo(
() =>
job()?.clientName || job()?.client_name || job()?.companyName || job()?.company_name || "—"
);
const exp = createMemo(() => job()?.experienceLevel || job()?.experience_level || "—");
const rateMin = createMemo(() => job()?.hourlyRateMin ?? job()?.hourly_rate_min);
const rateMax = createMemo(() => job()?.hourlyRateMax ?? job()?.hourly_rate_max);
const duration = createMemo(() => job()?.durationDays ?? job()?.duration_days);
@ -53,18 +56,28 @@ export default function JobDetailPage() {
<div class="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold text-gray-900">Job Management</h1>
<p class="text-sm text-gray-500 mt-0.5">Review one live backend job in the same detail-first style as other admin modules.</p>
<p class="text-sm text-gray-500 mt-0.5">
Review one live backend job in the same detail-first style as other admin modules.
</p>
</div>
<A class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href="/admin/jobs">Back to Jobs</A>
<A
class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
href="/admin/jobs"
>
Back to Jobs
</A>
</div>
<div class="p-6 flex-1">
<Show when={job.loading}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm"><p class="notice">Loading job...</p></div>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm">
<p class="notice">Loading job...</p>
</div>
</Show>
<Show when={!job.loading && !job()}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm"><p class="notice">Job not found.</p></div>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm">
<p class="notice">Job not found.</p>
</div>
</Show>
<Show when={job()}>
@ -72,11 +85,11 @@ export default function JobDetailPage() {
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<p class="hint">Title</p>
<p style="margin:6px 0 0;font-weight:700;color:#0f172a">{job()!.title || '—'}</p>
<p style="margin:6px 0 0;font-weight:700;color:#0f172a">{job()!.title || "—"}</p>
</div>
<div>
<p class="hint">Status</p>
<p style="margin:6px 0 0;color:#334155">{job()!.status || '—'}</p>
<p style="margin:6px 0 0;color:#334155">{job()!.status || "—"}</p>
</div>
<div>
<p class="hint">Client</p>
@ -88,28 +101,36 @@ export default function JobDetailPage() {
</div>
<div>
<p class="hint">Rate</p>
<p style="margin:6px 0 0;color:#334155">{rateMin() != null ? `${rateMin()} - ₹${rateMax() ?? rateMin()}` : '—'}</p>
<p style="margin:6px 0 0;color:#334155">
{rateMin() != null ? `${rateMin()} - ₹${rateMax() ?? rateMin()}` : "—"}
</p>
</div>
<div>
<p class="hint">Location</p>
<p style="margin:6px 0 0;color:#334155">{job()!.location || '—'}</p>
<p style="margin:6px 0 0;color:#334155">{job()!.location || "—"}</p>
</div>
<div>
<p class="hint">Availability</p>
<p style="margin:6px 0 0;color:#334155">{job()!.availability || '—'}</p>
<p style="margin:6px 0 0;color:#334155">{job()!.availability || "—"}</p>
</div>
<div>
<p class="hint">Duration</p>
<p style="margin:6px 0 0;color:#334155">{duration() != null ? `${duration()} days` : '—'}</p>
<p style="margin:6px 0 0;color:#334155">
{duration() != null ? `${duration()} days` : "—"}
</p>
</div>
<div style="grid-column:1/-1">
<p class="hint">Required Skills</p>
<p style="margin:6px 0 0;color:#334155">{skills().length > 0 ? skills().join(', ') : '—'}</p>
<p style="margin:6px 0 0;color:#334155">
{skills().length > 0 ? skills().join(", ") : "—"}
</p>
</div>
</div>
<div style="margin-top:18px">
<p class="hint">Description</p>
<p style="margin:6px 0 0;color:#334155;white-space:pre-wrap">{job()!.description || '—'}</p>
<p style="margin:6px 0 0;color:#334155;white-space:pre-wrap">
{job()!.description || "—"}
</p>
</div>
</section>
</Show>

View file

@ -1,12 +1,12 @@
import { A, useNavigate, useParams } from '@solidjs/router';
import { createEffect, createResource, createSignal, Show } from 'solid-js';
import { A, useNavigate, useParams } from "@solidjs/router";
import { createEffect, createResource, createSignal, Show } from "solid-js";
const API = '';
const API = "";
function getToken(): string {
return typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
return typeof sessionStorage !== "undefined"
? sessionStorage.getItem("nxtgauge_admin_access_token") || ""
: "";
}
type KbArticle = {
@ -24,17 +24,17 @@ async function loadArticle(id: string): Promise<KbArticle | null> {
const token = getToken();
const res = await fetch(`${API}/api/admin/kb/articles/${id}`, {
headers: {
Accept: 'application/json',
Accept: "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
credentials: 'include',
credentials: "include",
});
if (!res.ok) return null;
const data = await res.json();
return {
...data,
content: data?.content ?? data?.body ?? '',
body: data?.body ?? data?.content ?? '',
content: data?.content ?? data?.body ?? "",
body: data?.body ?? data?.content ?? "",
};
} catch {
return null;
@ -45,23 +45,23 @@ export default function KbArticleEditPage() {
const navigate = useNavigate();
const params = useParams();
const [article] = createResource(() => params.id, loadArticle);
const [title, setTitle] = createSignal('');
const [slug, setSlug] = createSignal('');
const [categoryId, setCategoryId] = createSignal('');
const [status, setStatus] = createSignal('DRAFT');
const [content, setContent] = createSignal('');
const [title, setTitle] = createSignal("");
const [slug, setSlug] = createSignal("");
const [categoryId, setCategoryId] = createSignal("");
const [status, setStatus] = createSignal("DRAFT");
const [content, setContent] = createSignal("");
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal('');
const [error, setError] = createSignal("");
const [loaded, setLoaded] = createSignal(false);
createEffect(() => {
const value = article();
if (!value || loaded()) return;
setTitle(value.title || '');
setSlug(value.slug || '');
setCategoryId(value.category_id || '');
setStatus(value.status || 'DRAFT');
setContent(value.content || value.body || '');
setTitle(value.title || "");
setSlug(value.slug || "");
setCategoryId(value.category_id || "");
setStatus(value.status || "DRAFT");
setContent(value.content || value.body || "");
setLoaded(true);
});
@ -69,33 +69,34 @@ export default function KbArticleEditPage() {
e.preventDefault();
try {
setSaving(true);
setError('');
setError("");
const res = await fetch(`${API}/api/admin/kb/articles/${params.id}`, {
method: 'PATCH',
method: "PATCH",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}),
},
credentials: 'include',
credentials: "include",
body: JSON.stringify({
title: title(),
slug: slug(),
category_id: categoryId() || null,
status: status(),
content: content(),
body: content(),
}),
});
if (!res.ok) throw new Error('Failed to save article');
if (!res.ok) throw new Error("Failed to save article");
navigate(`/admin/kb/articles/${params.id}`);
} catch (err: any) {
setError(err.message || 'Failed to save article');
setError(err.message || "Failed to save article");
} finally {
setSaving(false);
}
};
const inputCls = 'w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]';
const labelCls = 'mb-1.5 block text-sm font-medium text-gray-700';
const inputCls =
"w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]";
const labelCls = "mb-1.5 block text-sm font-medium text-gray-700";
return (
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
@ -105,17 +106,31 @@ export default function KbArticleEditPage() {
<p class="text-sm text-gray-500 mt-0.5">Update article metadata, status, and content.</p>
</div>
<div class="flex items-center gap-2">
<A class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href={`/admin/kb/articles/${params.id}`}>Back to Detail</A>
<A class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href="/admin/kb/articles">Back to Articles</A>
<A
class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
href={`/admin/kb/articles/${params.id}`}
>
Back to Detail
</A>
<A
class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
href="/admin/kb/articles"
>
Back to Articles
</A>
</div>
</div>
<div class="p-6 flex-1">
<Show when={article.loading}>
<div class="table-card"><p class="py-10 text-center text-sm text-slate-400">Loading article</p></div>
<div class="table-card">
<p class="py-10 text-center text-sm text-slate-400">Loading article</p>
</div>
</Show>
<Show when={!article.loading && !article()}>
<div class="table-card"><p class="py-10 text-center text-sm text-slate-400">Article not found.</p></div>
<div class="table-card">
<p class="py-10 text-center text-sm text-slate-400">Article not found.</p>
</div>
</Show>
<Show when={article() && loaded()}>
@ -123,36 +138,60 @@ export default function KbArticleEditPage() {
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div>
<label class={labelCls}>Title</label>
<input class={inputCls} value={title()} onInput={(e) => setTitle(e.currentTarget.value)} required />
<input
class={inputCls}
value={title()}
onInput={(e) => setTitle(e.currentTarget.value)}
required
/>
</div>
<div>
<label class={labelCls}>Slug</label>
<input class={inputCls} value={slug()} onInput={(e) => setSlug(e.currentTarget.value)} />
<input
class={inputCls}
value={slug()}
onInput={(e) => setSlug(e.currentTarget.value)}
/>
</div>
<div>
<label class={labelCls}>Category ID</label>
<input class={inputCls} value={categoryId()} onInput={(e) => setCategoryId(e.currentTarget.value)} />
<input
class={inputCls}
value={categoryId()}
onInput={(e) => setCategoryId(e.currentTarget.value)}
/>
</div>
<div>
<label class={labelCls}>Status</label>
<select class={inputCls} value={status()} onChange={(e) => setStatus(e.currentTarget.value)}>
<select
class={inputCls}
value={status()}
onChange={(e) => setStatus(e.currentTarget.value)}
>
<option value="DRAFT">DRAFT</option>
<option value="PUBLISHED">PUBLISHED</option>
</select>
</div>
<div class="sm:col-span-2">
<label class={labelCls}>Content</label>
<textarea rows="16" class={inputCls} value={content()} onInput={(e) => setContent(e.currentTarget.value)} />
<textarea
rows="16"
class={inputCls}
value={content()}
onInput={(e) => setContent(e.currentTarget.value)}
/>
</div>
</div>
<Show when={error()}>
<p class="mt-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error()}</p>
<p class="mt-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error()}
</p>
</Show>
<div class="mt-6 flex justify-end border-t border-gray-100 pt-5">
<button class="btn-primary" type="submit" disabled={saving()}>
{saving() ? 'Saving…' : 'Save Article'}
{saving() ? "Saving…" : "Save Article"}
</button>
</div>
</form>

View file

@ -1,7 +1,7 @@
import { A, useNavigate } from '@solidjs/router';
import { createEffect, createMemo, createResource, createSignal, For, Show } from 'solid-js';
import { A, useNavigate } from "@solidjs/router";
import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js";
const API = '';
const API = "";
type Permission = { key: string; module: string; action: string };
type Department = { id: string; name: string };
@ -10,9 +10,9 @@ function formatRoleKey(input: string): string {
return input
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.replace(/_{2,}/g, '_');
.replace(/[^A-Z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.replace(/_{2,}/g, "_");
}
async function loadPermissions(): Promise<Permission[]> {
@ -39,42 +39,69 @@ async function loadDepartments(): Promise<Department[]> {
// Fallback static permissions matching backend MODULES
const STATIC_MODULES = [
'Department Management', 'Designation Management', 'Internal Role Management',
'Employee Management', 'External Role Management',
'Internal Dashboard Management', 'External Dashboard Management', 'Verification Management',
'Approval Management', 'Users Management', 'Company Management', 'Candidate Management',
'Customer Management', 'Photographer Management', 'Makeup Artist Management',
'Tutor Management', 'Developer Management', 'Fitness Trainer Management',
'Graphic Designer Management', 'Social Media Management', 'Video Editor Management',
'Catering Services Management', 'Jobs Management', 'Leads Management',
'Applications Management', 'Responses Management', 'Review Management',
'Pricing Management', 'Credit Management', 'Coupon Management', 'Discount Management',
'Tax Management', 'Order Management', 'Invoice Management', 'Ledger Management',
'Knowledge Base Management', 'Support Management', 'Report Management', 'Notifications',
"Department Management",
"Designation Management",
"Internal Role Management",
"Employee Management",
"External Role Management",
"Internal Dashboard Management",
"External Dashboard Management",
"Verification Management",
"Approval Management",
"Users Management",
"Company Management",
"Candidate Management",
"Customer Management",
"Photographer Management",
"Makeup Artist Management",
"Tutor Management",
"Developer Management",
"Fitness Trainer Management",
"Graphic Designer Management",
"Social Media Management",
"Video Editor Management",
"Catering Services Management",
"Jobs Management",
"Leads Management",
"Applications Management",
"Responses Management",
"Review Management",
"Pricing Management",
"Credit Management",
"Coupon Management",
"Discount Management",
"Tax Management",
"Order Management",
"Invoice Management",
"Ledger Management",
"Knowledge Base Management",
"Support Management",
"Report Management",
"Notifications",
];
const ACTIONS = ['View', 'Create', 'Update', 'Delete'] as const;
const ACTIONS = ["View", "Create", "Update", "Delete"] as const;
const STATIC_PERMISSIONS: Permission[] = STATIC_MODULES.flatMap((module) =>
ACTIONS.map((action) => ({
key: `${module.replace(/ /g, '_').toLowerCase()}:${action.toLowerCase()}`,
key: `${module.replace(/ /g, "_").toLowerCase()}:${action.toLowerCase()}`,
module,
action,
})),
}))
);
type SubTab = 'general' | 'module' | 'settings';
type SubTab = "general" | "module" | "settings";
export default function CreateInternalRolePage() {
const navigate = useNavigate();
const [permissions] = createResource(loadPermissions);
const [departments] = createResource(loadDepartments);
const [subTab, setSubTab] = createSignal<SubTab>('general');
const [subTab, setSubTab] = createSignal<SubTab>("general");
// General Information
const [roleName, setRoleName] = createSignal('');
const [roleCode, setRoleCode] = createSignal('');
const [departmentId, setDepartmentId] = createSignal('');
const [description, setDescription] = createSignal('');
const [roleName, setRoleName] = createSignal("");
const [roleCode, setRoleCode] = createSignal("");
const [departmentId, setDepartmentId] = createSignal("");
const [description, setDescription] = createSignal("");
// Module Access: selected permission keys
const [selectedKeys, setSelectedKeys] = createSignal<Set<string>>(new Set());
@ -85,7 +112,7 @@ export default function CreateInternalRolePage() {
const [canManage, setCanManage] = createSignal(false);
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal('');
const [error, setError] = createSignal("");
createEffect(() => {
setRoleCode(formatRoleKey(roleName()));
@ -138,50 +165,79 @@ export default function CreateInternalRolePage() {
const handleSave = async () => {
if (saving()) return;
if (!roleName().trim()) { setError('Role name is required'); setSubTab('general'); return; }
if (!roleName().trim()) {
setError("Role name is required");
setSubTab("general");
return;
}
const normalizedRoleCode = formatRoleKey(roleName());
if (!normalizedRoleCode) { setError('Role code is required'); setSubTab('general'); return; }
setError('');
if (!normalizedRoleCode) {
setError("Role code is required");
setSubTab("general");
return;
}
setError("");
try {
setSaving(true);
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const accessToken =
typeof sessionStorage !== "undefined"
? (sessionStorage.getItem("nxtgauge_admin_access_token") || "").trim()
: "";
const res = await fetch(`${API}/api/admin/roles`, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
"Content-Type": "application/json",
Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
credentials: "include",
body: JSON.stringify({
key: normalizedRoleCode,
name: roleName().trim(),
audience: 'INTERNAL',
audience: "INTERNAL",
is_active: isActive(),
description: description().trim() || null,
department_id: departmentId() || null,
is_active: isActive(),
can_approve_requests: canApprove(),
can_manage_system_settings: canManage(),
permission_keys: [...selectedKeys()],
}),
});
const raw = await res.text();
let message = '';
let message = "";
if (raw) {
try {
const parsed = JSON.parse(raw) as { message?: string; error?: string };
message = parsed?.message || parsed?.error || '';
const parsed = JSON.parse(raw) as { message?: string; error?: string; id?: string };
message = parsed?.message || parsed?.error || "";
} catch {
message = raw;
}
}
if (!res.ok) throw new Error(message || `Failed to create role (${res.status})`);
navigate('/admin/roles');
const roleData = JSON.parse(raw) as { id?: string };
if (roleData.id) {
await fetch(`${API}/api/admin/internal-roles`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: "include",
body: JSON.stringify({
role_id: roleData.id,
description: description().trim() || null,
department_id: departmentId() || null,
can_approve_requests: canApprove(),
can_manage_system_settings: canManage(),
}),
});
}
navigate("/admin/roles");
} catch (err: any) {
setError(String(err?.message || '').trim() || 'Failed to create role');
setError(String(err?.message || "").trim() || "Failed to create role");
} finally {
setSaving(false);
}
@ -189,35 +245,42 @@ export default function CreateInternalRolePage() {
return (
<div class="w-full space-y-8 pb-8">
{/* Page header */}
<div class="flex items-end justify-between">
<div>
<p class="text-[12px] font-semibold uppercase tracking-widest text-[#FF5E13]">Access Control</p>
<h1 class="mt-1 text-[28px] font-bold leading-tight text-[#111827]">Create Internal Role</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">Dashboard / Internal Role Management / Create Role</p>
<p class="text-[12px] font-semibold uppercase tracking-widest text-[#FF5E13]">
Access Control
</p>
<h1 class="mt-1 text-[28px] font-bold leading-tight text-[#111827]">
Create Internal Role
</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">
Dashboard / Internal Role Management / Create Role
</p>
</div>
<A href="/admin/roles" class="inline-flex items-center gap-2 rounded-xl border border-[#E5E7EB] bg-white px-4 py-2.5 text-[13px] font-semibold text-[#374151] hover:bg-[#F9FAFB] transition-colors">
<A
href="/admin/roles"
class="inline-flex items-center gap-2 rounded-xl border border-[#E5E7EB] bg-white px-4 py-2.5 text-[13px] font-semibold text-[#374151] hover:bg-[#F9FAFB] transition-colors"
>
Back to Roles
</A>
</div>
<div class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm overflow-hidden">
{/* Sub-tabs */}
<div class="flex items-center gap-1 border-b border-[#F3F4F6] px-6">
{(
[
{ key: 'general', label: 'General Information' },
{ key: 'module', label: 'Module Access' },
{ key: 'settings', label: 'Role Settings' },
{ key: "general", label: "General Information" },
{ key: "module", label: "Module Access" },
{ key: "settings", label: "Role Settings" },
] as const
).map((t) => (
<button
type="button"
onClick={() => setSubTab(t.key)}
class={`relative px-4 py-4 text-[13px] font-semibold transition-colors ${
subTab() === t.key ? 'text-[#111827]' : 'text-[#9CA3AF] hover:text-[#6B7280]'
subTab() === t.key ? "text-[#111827]" : "text-[#9CA3AF] hover:text-[#6B7280]"
}`}
>
{t.label}
@ -236,7 +299,7 @@ export default function CreateInternalRolePage() {
</Show>
{/* ── Tab: General Information ── */}
<Show when={subTab() === 'general'}>
<Show when={subTab() === "general"}>
<div class="p-6 space-y-5">
<div class="grid grid-cols-2 gap-5">
<div>
@ -296,7 +359,7 @@ export default function CreateInternalRolePage() {
</Show>
{/* ── Tab: Module Access ── */}
<Show when={subTab() === 'module'}>
<Show when={subTab() === "module"}>
<div class="p-6">
<p class="text-[13px] text-[rgba(13,13,42,0.5)] mb-4">
Configure module access permissions for this role.
@ -316,7 +379,7 @@ export default function CreateInternalRolePage() {
onClick={() => (allSelected() ? deselectAll() : selectAll())}
class="text-[11px] font-semibold text-[#FF5E13] hover:text-[#e04d0a] transition-colors whitespace-nowrap"
>
{allSelected() ? 'Deselect All' : 'Select All'}
{allSelected() ? "Deselect All" : "Select All"}
</button>
</th>
</tr>
@ -324,7 +387,10 @@ export default function CreateInternalRolePage() {
<tbody class="divide-y divide-[#e5e7eb]">
<Show when={permissions.loading}>
<tr>
<td colspan="6" class="px-5 py-6 text-center text-[13px] text-[rgba(13,13,42,0.4)]">
<td
colspan="6"
class="px-5 py-6 text-center text-[13px] text-[rgba(13,13,42,0.4)]"
>
Loading modules
</td>
</tr>
@ -334,7 +400,9 @@ export default function CreateInternalRolePage() {
const perms = () => permsByModule()[module] ?? [];
const byAction = () => {
const m: Record<string, Permission> = {};
perms().forEach((p) => { m[p.action] = p; });
perms().forEach((p) => {
m[p.action] = p;
});
return m;
};
const rowAllSelected = () => perms().every((p) => selectedKeys().has(p.key));
@ -380,7 +448,7 @@ export default function CreateInternalRolePage() {
</Show>
{/* ── Tab: Role Settings ── */}
<Show when={subTab() === 'settings'}>
<Show when={subTab() === "settings"}>
<div class="p-6 space-y-6">
{/* Status toggle */}
<div>
@ -391,8 +459,8 @@ export default function CreateInternalRolePage() {
onClick={() => setIsActive(true)}
class={`h-[38px] rounded-xl border px-5 text-[13px] font-semibold transition-colors ${
isActive()
? 'border-[#059669] bg-[#ECFDF5] text-[#059669]'
: 'border-[#E5E7EB] bg-white text-[#6B7280] hover:bg-[#F9FAFB]'
? "border-[#059669] bg-[#ECFDF5] text-[#059669]"
: "border-[#E5E7EB] bg-white text-[#6B7280] hover:bg-[#F9FAFB]"
}`}
>
Active
@ -402,8 +470,8 @@ export default function CreateInternalRolePage() {
onClick={() => setIsActive(false)}
class={`h-[38px] rounded-xl border px-5 text-[13px] font-semibold transition-colors ${
!isActive()
? 'border-[#6B7280] bg-[#F3F4F6] text-[#374151]'
: 'border-[#E5E7EB] bg-white text-[#6B7280] hover:bg-[#F9FAFB]'
? "border-[#6B7280] bg-[#F3F4F6] text-[#374151]"
: "border-[#E5E7EB] bg-white text-[#6B7280] hover:bg-[#F9FAFB]"
}`}
>
Inactive
@ -443,11 +511,10 @@ export default function CreateInternalRolePage() {
disabled={saving()}
class="h-[40px] rounded-xl bg-[#0D0D2A] px-6 text-[13px] font-semibold text-white hover:bg-[#1a1a3e] transition-colors disabled:opacity-60"
>
{saving() ? 'Creating…' : 'Create Role'}
{saving() ? "Creating…" : "Create Role"}
</button>
</div>
</div>
</div>
);
}
@ -471,12 +538,12 @@ function SettingToggle(props: {
aria-checked={props.value}
onClick={() => props.onChange(!props.value)}
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${
props.value ? 'bg-[#FF5E13]' : 'bg-[#d1d5db]'
props.value ? "bg-[#FF5E13]" : "bg-[#d1d5db]"
}`}
>
<span
class={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
props.value ? 'translate-x-6' : 'translate-x-1'
props.value ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>

View file

@ -1,19 +1,19 @@
import { createResource, createSignal, createMemo, Show, For } from 'solid-js';
import { A } from '@solidjs/router';
import { createResource, createSignal, createMemo, Show, For } from "solid-js";
import { A } from "@solidjs/router";
const API = '';
const API = "";
function getToken(): string {
return typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
return typeof sessionStorage !== "undefined"
? sessionStorage.getItem("nxtgauge_admin_access_token") || ""
: "";
}
function authHeaders(contentType = false): Record<string, string> {
const token = getToken();
return {
Accept: 'application/json',
...(contentType ? { 'Content-Type': 'application/json' } : {}),
Accept: "application/json",
...(contentType ? { "Content-Type": "application/json" } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
@ -22,9 +22,14 @@ 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';
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;
@ -37,55 +42,68 @@ type AssigneeOption = {
email?: 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'];
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());
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',
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';
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',
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';
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',
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';
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';
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`, {
headers: authHeaders(),
credentials: 'include',
credentials: "include",
});
if (!res.ok) throw new Error('Failed');
if (!res.ok) throw new Error("Failed");
const data = await res.json();
return Array.isArray(data.cases) ? data.cases : Array.isArray(data) ? data : [];
} catch {
@ -95,29 +113,31 @@ async function loadAllCases(): Promise<SupportCase[]> {
async function loadAssignees(): Promise<AssigneeOption[]> {
try {
const params = new URLSearchParams({ page: '1', per_page: '200', sort: 'joined_desc' });
const params = new URLSearchParams({ page: "1", per_page: "200", sort: "joined_desc" });
const res = await fetch(`${API}/api/admin/employees?${params.toString()}`, {
headers: authHeaders(),
credentials: 'include',
credentials: "include",
});
if (!res.ok) throw new Error('Failed');
if (!res.ok) throw new Error("Failed");
const data = await res.json();
const raw = Array.isArray(data?.items) ? data.items : Array.isArray(data) ? data : [];
return raw.map((item: any) => ({
id: String(item.id ?? ''),
name: String(item.name ?? item.full_name ?? item.email ?? 'Unknown'),
return raw
.map((item: any) => ({
id: String(item.id ?? ""),
name: String(item.name ?? item.full_name ?? item.email ?? "Unknown"),
email: item.email ? String(item.email) : undefined,
})).filter((item: AssigneeOption) => Boolean(item.id));
}))
.filter((item: AssigneeOption) => Boolean(item.id));
} catch {
return [];
}
}
export default function SupportPage() {
const [activeTab, setActiveTab] = createSignal<'queue' | 'create'>('queue');
const [statusFilter, setStatusFilter] = createSignal<'all' | SupportCase['status']>('all');
const [search, setSearch] = createSignal('');
const [sortBy, setSortBy] = createSignal<'newest' | 'oldest' | 'priority'>('newest');
const [activeTab, setActiveTab] = createSignal<"queue" | "create">("queue");
const [statusFilter, setStatusFilter] = createSignal<"all" | SupportCase["status"]>("all");
const [search, setSearch] = createSignal("");
const [sortBy, setSortBy] = createSignal<"newest" | "oldest" | "priority">("newest");
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
const [refetchKey, setRefetchKey] = createSignal(0);
@ -131,21 +151,39 @@ export default function SupportPage() {
let all = cases() ?? [];
const q = search().toLowerCase().trim();
if (q) {
all = all.filter((c) =>
String(c.title || '').toLowerCase().includes(q)
|| String(c.description || '').toLowerCase().includes(q)
|| String(c.requesterName || '').toLowerCase().includes(q)
|| String(c.requesterEmail || '').toLowerCase().includes(q)
|| String(c.type || '').toLowerCase().includes(q)
all = all.filter(
(c) =>
String(c.title || "")
.toLowerCase()
.includes(q) ||
String(c.description || "")
.toLowerCase()
.includes(q) ||
String(c.requesterName || "")
.toLowerCase()
.includes(q) ||
String(c.requesterEmail || "")
.toLowerCase()
.includes(q) ||
String(c.type || "")
.toLowerCase()
.includes(q)
);
}
const sf = statusFilter();
if (sf !== 'all') all = all.filter((c) => c.status === sf);
const priorityRank: Record<SupportCase['priority'], number> = { critical: 4, high: 3, medium: 2, low: 1 };
if (sf !== "all") all = all.filter((c) => c.status === sf);
const priorityRank: Record<SupportCase["priority"], number> = {
critical: 4,
high: 3,
medium: 2,
low: 1,
};
const sorted = [...all];
sorted.sort((a, b) => {
if (sortBy() === 'oldest') return new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime();
if (sortBy() === 'priority') return (priorityRank[b.priority] || 0) - (priorityRank[a.priority] || 0);
if (sortBy() === "oldest")
return new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime();
if (sortBy() === "priority")
return (priorityRank[b.priority] || 0) - (priorityRank[a.priority] || 0);
return new Date(b.createdAt || 0).getTime() - new Date(a.createdAt || 0).getTime();
});
return sorted;
@ -154,49 +192,49 @@ export default function SupportPage() {
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,
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 [fAssignedTo, setFAssignedTo] = createSignal('');
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 [fAssignedTo, setFAssignedTo] = createSignal("");
const [createLoading, setCreateLoading] = createSignal(false);
const [createSuccess, setCreateSuccess] = createSignal('');
const [createError, setCreateError] = createSignal('');
const [createSuccess, setCreateSuccess] = createSignal("");
const [createError, setCreateError] = createSignal("");
const resetForm = () => {
setFTitle('');
setFDesc('');
setFType('customer_query');
setFPriority('medium');
setFRequesterName('');
setFRequesterEmail('');
setFAssignedTo('');
setFTitle("");
setFDesc("");
setFType("customer_query");
setFPriority("medium");
setFRequesterName("");
setFRequesterEmail("");
setFAssignedTo("");
};
const handleCreate = async (e: Event) => {
e.preventDefault();
setCreateLoading(true);
setCreateSuccess('');
setCreateError('');
setCreateSuccess("");
setCreateError("");
try {
const res = await fetch(`${API}/api/admin/support-cases`, {
method: 'POST',
method: "POST",
headers: authHeaders(true),
credentials: 'include',
credentials: "include",
body: JSON.stringify({
title: fTitle(),
description: fDesc(),
type: fType(),
category: fType(),
priority: fPriority(),
requesterName: fRequesterName(),
requesterEmail: fRequesterEmail(),
@ -204,52 +242,54 @@ export default function SupportPage() {
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error((d as any).message || 'Failed to create case');
throw new Error((d as any).message || "Failed to create case");
}
const created = await res.json().catch(() => ({}));
const createdId = String((created as any)?.id || '');
const createdId = String((created as any)?.id || "");
if (createdId && fAssignedTo()) {
await fetch(`${API}/api/admin/support-cases/${createdId}`, {
method: 'PATCH',
method: "PATCH",
headers: authHeaders(true),
credentials: 'include',
credentials: "include",
body: JSON.stringify({ assigned_to: fAssignedTo() }),
});
}
setCreateSuccess('Case created!');
setCreateSuccess("Case created!");
resetForm();
refetch();
setActiveTab('queue');
setActiveTab("queue");
} catch (err: any) {
setCreateError(err.message || 'Failed to create case');
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 },
{ label: "New", getValue: () => stats().newCount },
{ label: "In Progress", getValue: () => stats().inProgressCount },
{ label: "Waiting", getValue: () => stats().waitingCount },
{ label: "Total", getValue: () => stats().total },
];
const exportCsv = () => {
const headers = ['Issue', 'Type', 'Priority', 'Status', 'Requester', 'Updated'];
const headers = ["Issue", "Type", "Priority", "Status", "Requester", "Updated"];
const rows = filteredCases().map((item) => [
item.title,
item.type,
item.priority,
item.status,
item.requesterEmail || item.requesterName || '—',
item.updatedAt ? new Date(item.updatedAt).toLocaleString() : '—',
item.requesterEmail || item.requesterName || "—",
item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "—",
]);
const csv = [headers, ...rows].map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const csv = [headers, ...rows]
.map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","))
.join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const link = document.createElement("a");
link.href = url;
link.download = 'support-management.csv';
link.download = "support-management.csv";
link.click();
URL.revokeObjectURL(url);
};
@ -265,19 +305,23 @@ export default function SupportPage() {
<div class="bg-white border-b border-gray-200 px-6 flex items-center gap-8 sticky top-0 z-10">
<button
type="button"
class={activeTab() === 'queue'
? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium'
: 'py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors'}
onClick={() => setActiveTab('queue')}
class={
activeTab() === "queue"
? "py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium"
: "py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors"
}
onClick={() => setActiveTab("queue")}
>
Support Queue
</button>
<button
type="button"
class={activeTab() === 'create'
? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium'
: 'py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors'}
onClick={() => setActiveTab('create')}
class={
activeTab() === "create"
? "py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium"
: "py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors"
}
onClick={() => setActiveTab("create")}
>
Create Case
</button>
@ -297,7 +341,7 @@ export default function SupportPage() {
</div>
{/* Support Queue Tab */}
<Show when={activeTab() === 'queue'}>
<Show when={activeTab() === "queue"}>
<div style="position:relative;margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:visible;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6;position:relative;z-index:20;margin-bottom:0">
<input
@ -308,42 +352,123 @@ export default function SupportPage() {
style="height:34px;flex:1;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none"
/>
<div style="position:relative;">
<button type="button" onClick={() => { setSortMenuOpen((v) => !v); setFilterMenuOpen(false); }} style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 4v13"/><path d="m3 13 4 4 4-4"/><path d="M17 20V7"/><path d="m21 11-4-4-4 4"/></svg>
<button
type="button"
onClick={() => {
setSortMenuOpen((v) => !v);
setFilterMenuOpen(false);
}}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M7 4v13" />
<path d="m3 13 4 4 4-4" />
<path d="M17 20V7" />
<path d="m21 11-4-4-4 4" />
</svg>
Sort
</button>
<Show when={sortMenuOpen()}>
<div style="position:absolute;right:0;top:38px;z-index:40;min-width:180px;border:1px solid #E5E7EB;border-radius:12px;background:#fff;box-shadow:0 12px 24px rgba(2,6,23,0.12);padding:8px">
<For each={[
{ key: 'newest', label: 'Newest First' },
{ key: 'oldest', label: 'Oldest First' },
{ key: 'priority', label: 'Priority High-Low' },
] as { key: 'newest' | 'oldest' | 'priority'; label: string }[]}>
<For
each={
[
{ key: "newest", label: "Newest First" },
{ key: "oldest", label: "Oldest First" },
{ key: "priority", label: "Priority High-Low" },
] as { key: "newest" | "oldest" | "priority"; label: string }[]
}
>
{(item) => (
<button type="button" onClick={() => { setSortBy(item.key); setSortMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === item.key ? '#FF5E13' : '#374151'};background:${sortBy() === item.key ? '#FFF1EB' : 'transparent'}`}>{item.label}</button>
<button
type="button"
onClick={() => {
setSortBy(item.key);
setSortMenuOpen(false);
}}
style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === item.key ? "#FF5E13" : "#374151"};background:${sortBy() === item.key ? "#FFF1EB" : "transparent"}`}
>
{item.label}
</button>
)}
</For>
</div>
</Show>
</div>
<div style="position:relative;">
<button type="button" onClick={() => { setFilterMenuOpen((v) => !v); setSortMenuOpen(false); }} style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h18M6 12h12M10 19h4"/></svg>
<button
type="button"
onClick={() => {
setFilterMenuOpen((v) => !v);
setSortMenuOpen(false);
}}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 5h18M6 12h12M10 19h4" />
</svg>
Filters
</button>
<Show when={filterMenuOpen()}>
<div style="position:absolute;right:0;top:38px;z-index:40;min-width:220px;border:1px solid #E5E7EB;border-radius:12px;background:#fff;box-shadow:0 12px 24px rgba(2,6,23,0.12);padding:8px">
<button type="button" onClick={() => { setStatusFilter('all'); setFilterMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === 'all' ? '#FF5E13' : '#374151'};background:${statusFilter() === 'all' ? '#FFF1EB' : 'transparent'}`}>All statuses</button>
<button
type="button"
onClick={() => {
setStatusFilter("all");
setFilterMenuOpen(false);
}}
style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === "all" ? "#FF5E13" : "#374151"};background:${statusFilter() === "all" ? "#FFF1EB" : "transparent"}`}
>
All statuses
</button>
<For each={STATUS_OPTIONS}>
{(s) => (
<button type="button" onClick={() => { setStatusFilter(s); setFilterMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === s ? '#FF5E13' : '#374151'};background:${statusFilter() === s ? '#FFF1EB' : 'transparent'}`}>{formatValue(s)}</button>
<button
type="button"
onClick={() => {
setStatusFilter(s);
setFilterMenuOpen(false);
}}
style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === s ? "#FF5E13" : "#374151"};background:${statusFilter() === s ? "#FFF1EB" : "transparent"}`}
>
{formatValue(s)}
</button>
)}
</For>
</div>
</Show>
</div>
<button type="button" onClick={exportCsv} style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:600;color:white;border:none;cursor:pointer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<button
type="button"
onClick={exportCsv}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:600;color:white;border:none;cursor:pointer"
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Export
</button>
</div>
@ -364,13 +489,25 @@ export default function SupportPage() {
</thead>
<tbody>
<Show when={cases.loading}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
<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>
<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>
<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()}>
@ -378,27 +515,42 @@ export default function SupportPage() {
<tr class="hover:bg-slate-50" style="cursor:pointer" onClick={() => {}}>
<td>
<div class="font-semibold text-slate-900">{item.title}</div>
<div style="font-size:12px;color:#64748b;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{item.description}</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>
<span style={`${BADGE_STYLE};${typeBadgeStyle(item.type)}`}>
{formatValue(item.type)}
</span>
</td>
<td>
<span style={`${BADGE_STYLE};${priorityBadgeStyle(item.priority)}`}>{formatValue(item.priority)}</span>
<span style={`${BADGE_STYLE};${priorityBadgeStyle(item.priority)}`}>
{formatValue(item.priority)}
</span>
</td>
<td>
<span style={`${BADGE_STYLE};${statusBadgeStyle(item.status)}`}>{formatValue(item.status)}</span>
<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>
<div style="font-size:13px">{item.requesterName || "—"}</div>
<div style="font-size:11px;color:#64748b">
{item.requesterEmail || ""}
</div>
</td>
<td class="text-slate-500" style="font-size:12px">
{item.updatedAt ? new Date(item.updatedAt).toLocaleString() : '—'}
{item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "—"}
</td>
<td>
<div class="flex items-center justify-end gap-1">
<A class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href={`/admin/support/${item.id}`}>View</A>
<A
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
href={`/admin/support/${item.id}`}
>
View
</A>
</div>
</td>
</tr>
@ -411,14 +563,45 @@ export default function SupportPage() {
<Show when={!cases.loading && !cases.error && filteredCases().length > 0}>
<div style="display:flex;align-items:center;justify-content:space-between;border-top:1px solid #F3F4F6;padding:12px 20px">
<p style="font-size:13px;color:#6B7280">
Showing <strong style="font-weight:600;color:#111827">1{filteredCases().length}</strong> of <strong style="font-weight:600;color:#111827">{filteredCases().length}</strong> cases
Showing{" "}
<strong style="font-weight:600;color:#111827">
1{filteredCases().length}
</strong>{" "}
of{" "}
<strong style="font-weight:600;color:#111827">{filteredCases().length}</strong>{" "}
cases
</p>
<div style="display:flex;align-items:center;gap:4px">
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#6B7280;cursor:pointer;font-size:15px"></button>
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;background:#FF5E13;color:white;font-size:13px;font-weight:600;border:none;cursor:pointer">1</button>
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#374151;font-size:13px;font-weight:500;cursor:pointer">2</button>
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#374151;font-size:13px;font-weight:500;cursor:pointer">3</button>
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#6B7280;cursor:pointer;font-size:15px"></button>
<button
type="button"
style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#6B7280;cursor:pointer;font-size:15px"
>
</button>
<button
type="button"
style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;background:#FF5E13;color:white;font-size:13px;font-weight:600;border:none;cursor:pointer"
>
1
</button>
<button
type="button"
style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#374151;font-size:13px;font-weight:500;cursor:pointer"
>
2
</button>
<button
type="button"
style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#374151;font-size:13px;font-weight:500;cursor:pointer"
>
3
</button>
<button
type="button"
style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#6B7280;cursor:pointer;font-size:15px"
>
</button>
</div>
</div>
</Show>
@ -427,11 +610,14 @@ export default function SupportPage() {
</Show>
{/* Create Case Tab */}
<Show when={activeTab() === 'create'}>
<Show when={activeTab() === "create"}>
<section class="rounded-xl border border-gray-200 bg-white shadow-sm p-6">
<h2 style="margin:0 0 6px;font-size:16px;font-weight:700;color:#1e293b">Create Support Case</h2>
<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.
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">
@ -439,11 +625,18 @@ export default function SupportPage() {
</div>
</Show>
<Show when={createError()}>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-bottom:14px">{createError()}</div>
<div
class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
style="margin-bottom:14px"
>
{createError()}
</div>
</Show>
<form onSubmit={handleCreate} style="display:flex;flex-direction:column;gap:16px">
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Title</label>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
Title
</label>
<input
type="text"
required
@ -453,7 +646,9 @@ export default function SupportPage() {
/>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Description</label>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
Description
</label>
<textarea
required
rows="4"
@ -464,10 +659,12 @@ export default function SupportPage() {
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Type</label>
<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'])}
onChange={(e) => setFType(e.currentTarget.value as SupportCase["type"])}
style="width:100%;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;font-size:14px;box-sizing:border-box"
>
<For each={TYPE_OPTIONS}>
@ -476,10 +673,12 @@ export default function SupportPage() {
</select>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Priority</label>
<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'])}
onChange={(e) => setFPriority(e.currentTarget.value as SupportCase["priority"])}
style="width:100%;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;font-size:14px;box-sizing:border-box"
>
<For each={PRIORITY_OPTIONS}>
@ -490,7 +689,9 @@ export default function SupportPage() {
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Requester Name</label>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
Requester Name
</label>
<input
type="text"
value={fRequesterName()}
@ -499,7 +700,9 @@ export default function SupportPage() {
/>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Requester Email</label>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
Requester Email
</label>
<input
type="email"
value={fRequesterEmail()}
@ -509,7 +712,9 @@ export default function SupportPage() {
</div>
</div>
<div class="field">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Assign To (optional)</label>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
Assign To (optional)
</label>
<select
value={fAssignedTo()}
onChange={(e) => setFAssignedTo(e.currentTarget.value)}
@ -519,7 +724,8 @@ export default function SupportPage() {
<For each={assignees()}>
{(assignee) => (
<option value={assignee.id}>
{assignee.name}{assignee.email ? ` (${assignee.email})` : ''}
{assignee.name}
{assignee.email ? ` (${assignee.email})` : ""}
</option>
)}
</For>
@ -527,7 +733,7 @@ export default function SupportPage() {
</div>
<div>
<button class="btn-primary" type="submit" disabled={createLoading()}>
{createLoading() ? 'Creating...' : 'Create Support Case'}
{createLoading() ? "Creating..." : "Create Support Case"}
</button>
</div>
</form>

View file

@ -1,7 +1,7 @@
import { A, useNavigate, useParams } from '@solidjs/router';
import { createMemo, createResource, createSignal, Show } from 'solid-js';
import { A, useNavigate, useParams } from "@solidjs/router";
import { createMemo, createResource, createSignal, Show } from "solid-js";
const API = '';
const API = "";
type Role = {
id: string;
@ -16,7 +16,7 @@ type User = {
roleId?: string;
role_id?: string;
role?: Role;
status?: 'ACTIVE' | 'INACTIVE' | 'PENDING';
status?: "ACTIVE" | "INACTIVE" | "PENDING";
createdAt?: string;
created_at?: string;
};
@ -26,7 +26,7 @@ async function fetchRoles(): Promise<Role[]> {
const res = await fetch(`${API}/api/admin/roles?audience=INTERNAL`);
if (!res.ok) return [];
const data = await res.json();
const rows = Array.isArray(data) ? data : (data.roles || []);
const rows = Array.isArray(data) ? data : data.roles || [];
return rows.map((r: any) => ({ id: r.id, name: r.name }));
} catch {
return [];
@ -52,60 +52,64 @@ export default function EditUserPage() {
const [user] = createResource(() => params.id, fetchUser);
const [roles] = createResource(fetchRoles);
const [name, setName] = createSignal('');
const [email, setEmail] = createSignal('');
const [roleId, setRoleId] = createSignal('');
const [status, setStatus] = createSignal<'ACTIVE' | 'INACTIVE' | 'PENDING'>('ACTIVE');
const [name, setName] = createSignal("");
const [email, setEmail] = createSignal("");
const [phone, setPhone] = createSignal("");
const [password, setPassword] = createSignal("");
const [roleId, setRoleId] = createSignal("");
const [status, setStatus] = createSignal<"ACTIVE" | "INACTIVE" | "PENDING">("ACTIVE");
const [submitting, setSubmitting] = createSignal(false);
const [error, setError] = createSignal('');
const [error, setError] = createSignal("");
createMemo(() => {
const u = user();
if (!u) return null;
setName(u.name || u.full_name || '');
setEmail(u.email || '');
setRoleId(u.roleId || u.role_id || u.role?.id || '');
setStatus((u.status || 'ACTIVE').toUpperCase() as 'ACTIVE' | 'INACTIVE' | 'PENDING');
setName(u.name || u.full_name || "");
setEmail(u.email || "");
setPhone(u.phone || "");
setStatus((u.status || "ACTIVE").toUpperCase() as "ACTIVE" | "INACTIVE" | "PENDING");
return null;
});
const save = async () => {
if (!name().trim() || !email().trim() || !roleId()) {
setError('Please fill in name, email, and role.');
setError("Please fill in name, email, and role.");
return;
}
try {
setSubmitting(true);
setError('');
setError("");
const body = {
name: name().trim(),
first_name: name().trim(),
email: email().trim(),
roleId: roleId(),
phone: phone().trim(),
password: password() || "",
role_id: roleId(),
status: status().toLowerCase(),
};
let res = await fetch(`${API}/api/admin/users/${params.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
res = await fetch(`${API}/api/users/${params.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
throw new Error(payload.message || 'Failed to update user');
throw new Error(payload.message || "Failed to update user");
}
navigate('/admin/users');
navigate("/admin/users");
} catch (err: any) {
setError(err.message || 'Failed to update user');
setError(err.message || "Failed to update user");
} finally {
setSubmitting(false);
}
@ -116,25 +120,42 @@ export default function EditUserPage() {
<div class="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold text-gray-900">Edit User</h1>
<p class="text-sm text-gray-500 mt-0.5">Update user profile, role assignment, and account status.</p>
<p class="text-sm text-gray-500 mt-0.5">
Update user profile, role assignment, and account status.
</p>
</div>
<div class="flex items-center gap-2">
<A class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href={`/admin/users/details/${params.id}`}>View Details</A>
<A class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href="/admin/users">Back to Users</A>
<A
class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
href={`/admin/users/details/${params.id}`}
>
View Details
</A>
<A
class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
href="/admin/users"
>
Back to Users
</A>
</div>
</div>
<div class="p-6">
<Show when={error()}>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error()}</div>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error()}
</div>
</Show>
<Show when={user.loading}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm"><p class="notice">Loading user...</p></div>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm">
<p class="notice">Loading user...</p>
</div>
</Show>
<Show when={!user.loading && !user()}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm"><p class="notice">User not found.</p></div>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm">
<p class="notice">User not found.</p>
</div>
</Show>
<Show when={user()}>
@ -142,15 +163,28 @@ export default function EditUserPage() {
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Full Name</label>
<input class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]" value={name()} onInput={(e) => setName(e.currentTarget.value)} />
<input
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]"
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Email</label>
<input type="email" class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]" value={email()} onInput={(e) => setEmail(e.currentTarget.value)} />
<input
type="email"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Role</label>
<select class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]" value={roleId()} onChange={(e) => setRoleId(e.currentTarget.value)}>
<select
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]"
value={roleId()}
onChange={(e) => setRoleId(e.currentTarget.value)}
>
<option value="">Select role</option>
<Show when={!roles.loading}>
{roles()?.map((r) => (
@ -161,7 +195,13 @@ export default function EditUserPage() {
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Status</label>
<select class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]" value={status()} onChange={(e) => setStatus(e.currentTarget.value as 'ACTIVE' | 'INACTIVE' | 'PENDING')}>
<select
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]"
value={status()}
onChange={(e) =>
setStatus(e.currentTarget.value as "ACTIVE" | "INACTIVE" | "PENDING")
}
>
<option value="ACTIVE">Active</option>
<option value="PENDING">Pending</option>
<option value="INACTIVE">Inactive</option>
@ -170,9 +210,15 @@ export default function EditUserPage() {
</div>
<div class="mt-6 flex justify-end gap-3 border-t border-gray-100 pt-5">
<button class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" type="button" onClick={() => navigate('/admin/users')}>Cancel</button>
<button
class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
type="button"
onClick={() => navigate("/admin/users")}
>
Cancel
</button>
<button class="btn-primary" type="button" onClick={save} disabled={submitting()}>
{submitting() ? 'Saving…' : 'Save Changes'}
{submitting() ? "Saving…" : "Save Changes"}
</button>
</div>
</section>