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 { function getToken(): string {
return typeof sessionStorage !== 'undefined' return typeof sessionStorage !== "undefined"
? sessionStorage.getItem('nxtgauge_admin_access_token') || '' ? sessionStorage.getItem("nxtgauge_admin_access_token") || ""
: ''; : "";
} }
function authHeaders(): Record<string, string> { function authHeaders(): Record<string, string> {
const token = getToken(); const token = getToken();
return { return {
Accept: 'application/json', Accept: "application/json",
'Content-Type': 'application/json', "Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
}; };
} }
const ROLE_OPTIONS = [ const ROLE_OPTIONS = [
'company', "company",
'customer', "customer",
'job_seeker', "job_seeker",
'photographer', "photographer",
'video_editor', "video_editor",
'graphic_designer', "graphic_designer",
'social_media_manager', "social_media_manager",
'fitness_trainer', "fitness_trainer",
'catering_services', "catering_services",
'makeup_artist', "makeup_artist",
'tutor', "tutor",
'developer', "developer",
]; ];
type Coupon = { type Coupon = {
id: string; id: string;
code: string; code: string;
title: string; title: string;
type: 'PERCENT' | 'FIXED'; type: "PERCENT" | "FIXED";
value: number; value: number;
min_order_amount: number; min_order_amount: number;
used_count: number; used_count: number;
@ -45,44 +45,50 @@ type Coupon = {
role_keys: string[]; role_keys: string[];
}; };
const defaultForm = () => ({ const defaultForm = () => ({
id: '', id: "",
code: '', code: "",
title: '', title: "",
type: 'PERCENT' as 'PERCENT' | 'FIXED', type: "PERCENT" as "PERCENT" | "FIXED",
value: 10, value: 10,
min_order_amount: 0, min_order_amount: 0,
max_uses: '', max_uses: "",
role_keys: ['company', 'customer'] as string[], applies_to: "ALL" as "ALL" | "ROLE",
role_keys: ["company", "customer"] as string[],
}); });
export default function CouponPage() { export default function CouponPage() {
const [coupons, setCoupons] = createSignal<Coupon[]>([]); const [coupons, setCoupons] = createSignal<Coupon[]>([]);
const [loading, setLoading] = createSignal(true); const [loading, setLoading] = createSignal(true);
const [loadError, setLoadError] = createSignal(''); const [loadError, setLoadError] = createSignal("");
const [activeTab, setActiveTab] = createSignal<'list' | 'create'>('list'); const [activeTab, setActiveTab] = createSignal<"list" | "create">("list");
const [form, setForm] = createSignal(defaultForm()); const [form, setForm] = createSignal(defaultForm());
const [saving, setSaving] = createSignal(false); const [saving, setSaving] = createSignal(false);
const [toggling, setToggling] = createSignal(''); const [toggling, setToggling] = createSignal("");
const [formError, setFormError] = createSignal(''); const [formError, setFormError] = createSignal("");
// Filters // Filters
const [search, setSearch] = createSignal(''); const [search, setSearch] = createSignal("");
const [statusFilter, setStatusFilter] = createSignal('all'); const [statusFilter, setStatusFilter] = createSignal("all");
const [sortBy, setSortBy] = createSignal<'newest' | 'oldest' | 'code_asc' | 'code_desc'>('newest'); const [sortBy, setSortBy] = createSignal<"newest" | "oldest" | "code_asc" | "code_desc">(
"newest"
);
const [sortMenuOpen, setSortMenuOpen] = createSignal(false); const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false); const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
const load = async () => { const load = async () => {
setLoading(true); setLoadError(''); setLoading(true);
setLoadError("");
try { 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})`); if (!res.ok) throw new Error(`Request failed (${res.status})`);
const data = await res.json(); const data = await res.json();
setCoupons(Array.isArray(data) ? data : (data.coupons ?? [])); setCoupons(Array.isArray(data) ? data : (data.coupons ?? []));
} catch (err: any) { } catch (err: any) {
setLoadError(err.message || 'Could not load coupons.'); setLoadError(err.message || "Could not load coupons.");
setCoupons([]); setCoupons([]);
} finally { } finally {
setLoading(false); setLoading(false);
@ -94,59 +100,62 @@ export default function CouponPage() {
const filteredCoupons = createMemo(() => { const filteredCoupons = createMemo(() => {
let r = coupons(); let r = coupons();
const q = search().toLowerCase(); const q = search().toLowerCase();
if (q) r = r.filter((c) => c.code.toLowerCase().includes(q) || (c.title || '').toLowerCase().includes(q)); if (q)
if (statusFilter() === 'active') r = r.filter((c) => c.is_active); r = r.filter(
if (statusFilter() === 'inactive') r = r.filter((c) => !c.is_active); (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]; const sorted = [...r];
sorted.sort((a, b) => { sorted.sort((a, b) => {
if (sortBy() === 'oldest') return String(a.id || '').localeCompare(String(b.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_asc") return String(a.code || "").localeCompare(String(b.code || ""));
if (sortBy() === 'code_desc') return String(b.code || '').localeCompare(String(a.code || '')); if (sortBy() === "code_desc") return String(b.code || "").localeCompare(String(a.code || ""));
return String(b.id || '').localeCompare(String(a.id || '')); return String(b.id || "").localeCompare(String(a.id || ""));
}); });
r = sorted; r = sorted;
return r; return r;
}); });
const exportCsv = () => { 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) => [ const rows = filteredCoupons().map((item) => [
item.code, item.code,
item.title || '', item.title || "",
item.type, item.type,
item.type === 'PERCENT' ? `${item.value}%` : `${item.value}`, item.type === "PERCENT" ? `${item.value}%` : `${item.value}`,
item.usage_limit != null ? String(item.usage_limit) : '—', item.usage_limit != null ? String(item.usage_limit) : "—",
item.is_active ? 'Active' : 'Inactive', item.is_active ? "Active" : "Inactive",
]); ]);
const csv = [headers, ...rows] const csv = [headers, ...rows]
.map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(',')) .map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","))
.join('\n'); .join("\n");
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement("a");
link.href = url; link.href = url;
link.download = 'coupon-management.csv'; link.download = "coupon-management.csv";
link.click(); link.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
const resetForm = () => { const resetForm = () => {
setForm(defaultForm()); setForm(defaultForm());
setFormError(''); setFormError("");
}; };
const startEdit = (coupon: Coupon) => { const startEdit = (coupon: Coupon) => {
setForm({ setForm({
id: coupon.id, id: coupon.id,
code: coupon.code, code: coupon.code,
title: coupon.title || '', title: coupon.title || "",
type: coupon.type, type: coupon.type,
value: coupon.value, value: coupon.value,
min_order_amount: coupon.min_order_amount || 0, 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 : [], role_keys: Array.isArray(coupon.role_keys) ? coupon.role_keys : [],
}); });
setActiveTab('create'); setActiveTab("create");
}; };
const toggleRole = (role: string) => { const toggleRole = (role: string) => {
@ -162,32 +171,33 @@ export default function CouponPage() {
e.preventDefault(); e.preventDefault();
try { try {
setSaving(true); setSaving(true);
setFormError(''); setFormError("");
const f = form(); const f = form();
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
code: f.code.toUpperCase(), code: f.code.toUpperCase(),
title: f.title, title: f.title,
type: f.type, discount_type: f.type,
value: Number(f.value), discount_value: Number(f.value),
applies_to: f.applies_to,
min_order_amount: Number(f.min_order_amount), min_order_amount: Number(f.min_order_amount),
role_keys: f.role_keys, role_keys: f.role_keys,
}; };
if (f.max_uses) body.max_uses = Number(f.max_uses); 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 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, { const res = await fetch(url, {
method, method,
headers: authHeaders(), headers: authHeaders(),
credentials: 'include', credentials: "include",
body: JSON.stringify(body), 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(); resetForm();
await load(); await load();
setActiveTab('list'); setActiveTab("list");
} catch (err: unknown) { } catch (err: unknown) {
setFormError(err instanceof Error ? err.message : 'Failed to save'); setFormError(err instanceof Error ? err.message : "Failed to save");
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -197,17 +207,17 @@ export default function CouponPage() {
try { try {
setToggling(coupon.id); setToggling(coupon.id);
const res = await fetch(`${API}/api/admin/coupons/${coupon.id}`, { const res = await fetch(`${API}/api/admin/coupons/${coupon.id}`, {
method: 'PATCH', method: "PATCH",
headers: authHeaders(), headers: authHeaders(),
credentials: 'include', credentials: "include",
body: JSON.stringify({ is_active: !coupon.is_active }), 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(); await load();
} catch { } catch {
// ignore // ignore
} finally { } 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"> <div class="bg-white border-b border-gray-200 px-6 flex items-center gap-8 sticky top-0 z-10">
<button <button
type="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'} class={
onClick={() => setActiveTab('list')} 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 Coupons
</button> </button>
<button <button
type="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'} class={
onClick={() => { resetForm(); setActiveTab('create'); }} 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> </button>
</div> </div>
<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="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"> <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 <input
@ -251,22 +272,51 @@ export default function CouponPage() {
<div style="position:relative;"> <div style="position:relative;">
<button <button
type="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" 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 Sort
</button> </button>
<Show when={sortMenuOpen()}> <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"> <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={[ <For
{ key: 'newest', label: 'Newest First' }, each={
{ key: 'oldest', label: 'Oldest First' }, [
{ key: 'code_asc', label: 'Code A-Z' }, { key: "newest", label: "Newest First" },
{ key: 'code_desc', label: 'Code Z-A' }, { key: "oldest", label: "Oldest First" },
] as { key: 'newest' | 'oldest' | 'code_asc' | 'code_desc'; label: string }[]}> { 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) => ( {(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} {item.label}
</button> </button>
)} )}
@ -278,21 +328,44 @@ export default function CouponPage() {
<div style="position:relative;"> <div style="position:relative;">
<button <button
type="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" 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 Filters
</button> </button>
<Show when={filterMenuOpen()}> <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"> <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={[ <For
{ key: 'all', label: 'All Status' }, each={
{ key: 'active', label: 'Active' }, [
{ key: 'inactive', label: 'Inactive' }, { key: "all", label: "All Status" },
] as { key: string; label: string }[]}> { key: "active", label: "Active" },
{ key: "inactive", label: "Inactive" },
] as { key: string; label: string }[]
}
>
{(item) => ( {(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} {item.label}
</button> </button>
)} )}
@ -301,13 +374,30 @@ export default function CouponPage() {
</Show> </Show>
</div> </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"> <button
<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> 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 Export
</button> </button>
</div> </div>
<Show when={loadError()}> <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> </Show>
<div class="table-card"> <div class="table-card">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
@ -325,35 +415,62 @@ export default function CouponPage() {
</thead> </thead>
<tbody> <tbody>
<Show when={loading()}> <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>
<Show when={!loading() && filteredCoupons().length === 0}> <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>
<Show when={!loading() && filteredCoupons().length > 0}> <Show when={!loading() && filteredCoupons().length > 0}>
<For each={filteredCoupons()}> <For each={filteredCoupons()}>
{(item) => ( {(item) => (
<tr class="hover:bg-slate-50"> <tr class="hover:bg-slate-50">
<td class="font-semibold text-slate-900" style="font-family:monospace">{item.code}</td> <td class="font-semibold text-slate-900" style="font-family:monospace">
<td class="text-slate-500">{item.title || '—'}</td> {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}</td>
<td class="text-slate-500">{item.type === 'PERCENT' ? `${item.value}%` : `${item.value}`}</td> <td class="text-slate-500">
<td class="text-slate-500">{item.usage_limit != null ? item.usage_limit : '—'}</td> {item.type === "PERCENT" ? `${item.value}%` : `${item.value}`}
</td>
<td class="text-slate-500">
{item.usage_limit != null ? item.usage_limit : "—"}
</td>
<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
<span style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${item.is_active ? '#FF5E13' : '#9CA3AF'};margin-right:5px;flex-shrink:0`} /> 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`}
{item.is_active ? 'Active' : 'Inactive'} >
<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> </span>
</td> </td>
<td> <td>
<div class="flex items-center justify-end gap-1"> <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 <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" 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} disabled={toggling() === item.id}
onClick={() => handleToggle(item)} onClick={() => handleToggle(item)}
> >
{toggling() === item.id ? '...' : (item.is_active ? 'Disable' : 'Enable')} {toggling() === item.id
? "..."
: item.is_active
? "Disable"
: "Enable"}
</button> </button>
</div> </div>
</td> </td>
@ -373,11 +490,21 @@ export default function CouponPage() {
</div> </div>
</Show> </Show>
<Show when={activeTab() === 'create'}> <Show when={activeTab() === "create"}>
<section class="rounded-xl border border-gray-200 bg-white shadow-sm" style="max-width:520px"> <section
<h2 style="margin:0 0 20px;font-size:16px;font-weight:700">{form().id ? 'Edit Coupon' : 'Create Coupon'}</h2> 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()}> <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> </Show>
<form onSubmit={handleSave} style="display:flex;flex-direction:column;gap:14px"> <form onSubmit={handleSave} style="display:flex;flex-direction:column;gap:14px">
<div class="field"> <div class="field">
@ -406,7 +533,9 @@ export default function CouponPage() {
<label>Type</label> <label>Type</label>
<select <select
value={form().type} 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="PERCENT">Percent (%)</option>
<option value="FIXED">Fixed ()</option> <option value="FIXED">Fixed ()</option>
@ -429,7 +558,9 @@ export default function CouponPage() {
<input <input
type="number" type="number"
value={form().min_order_amount} 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" min="0"
placeholder="0" placeholder="0"
/> />
@ -444,9 +575,23 @@ export default function CouponPage() {
placeholder="Unlimited" placeholder="Unlimited"
/> />
</div> </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>
<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"> <div style="display:flex;flex-wrap:wrap;gap:8px">
<For each={ROLE_OPTIONS}> <For each={ROLE_OPTIONS}>
{(role) => { {(role) => {
@ -455,7 +600,7 @@ export default function CouponPage() {
<button <button
type="button" type="button"
onClick={() => toggleRole(role)} 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} {role}
</button> </button>
@ -466,10 +611,16 @@ export default function CouponPage() {
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn-primary" type="submit" disabled={saving()}> <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> </button>
<Show when={form().id}> <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> </Show>
</div> </div>
</form> </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 { A, useNavigate } from "@solidjs/router";
import { createResource, createSignal, For, onMount, Show } from 'solid-js'; import { createResource, createSignal, For, onMount, Show } from "solid-js";
const API = ''; const API = "";
type Role = { id: string; name: string }; type Role = { id: string; name: string };
type Dept = { id: string; name: string }; type Dept = { id: string; name: string };
type Desig = { id: string; name: string }; type Desig = { id: string; name: string };
function parseEmployeeCodeNumber(code: string): number | null { function parseEmployeeCodeNumber(code: string): number | null {
const normalized = String(code || '').trim().toUpperCase(); const normalized = String(code || "")
.trim()
.toUpperCase();
if (!normalized) return null; if (!normalized) return null;
const explicit = normalized.match(/^EMP[-_]?0*(\d+)$/); const explicit = normalized.match(/^EMP[-_]?0*(\d+)$/);
if (explicit) return Number(explicit[1]); if (explicit) return Number(explicit[1]);
@ -18,59 +20,68 @@ function parseEmployeeCodeNumber(code: string): number | null {
} }
function formatEmployeeCode(value: number): string { 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[]> { async function fetchRoles(): Promise<Role[]> {
try { try {
const accessToken = typeof sessionStorage !== 'undefined' const accessToken =
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim() typeof sessionStorage !== "undefined"
: ''; ? (sessionStorage.getItem("nxtgauge_admin_access_token") || "").trim()
: "";
const res = await fetch(`${API}/api/admin/roles?audience=INTERNAL&per_page=100`, { const res = await fetch(`${API}/api/admin/roles?audience=INTERNAL&per_page=100`, {
headers: { headers: {
Accept: 'application/json', Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
}, },
credentials: 'include', credentials: "include",
}); });
if (!res.ok) throw new Error(); if (!res.ok) throw new Error();
const data = await res.json(); const data = await res.json();
return Array.isArray(data) ? data : (data.roles ?? []); return Array.isArray(data) ? data : (data.roles ?? []);
} catch { return []; } } catch {
return [];
}
} }
async function fetchDepts(): Promise<Dept[]> { async function fetchDepts(): Promise<Dept[]> {
try { try {
const accessToken = typeof sessionStorage !== 'undefined' const accessToken =
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim() typeof sessionStorage !== "undefined"
: ''; ? (sessionStorage.getItem("nxtgauge_admin_access_token") || "").trim()
: "";
const res = await fetch(`${API}/api/admin/departments?per_page=100`, { const res = await fetch(`${API}/api/admin/departments?per_page=100`, {
headers: { headers: {
Accept: 'application/json', Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
}, },
credentials: 'include', credentials: "include",
}); });
if (!res.ok) throw new Error(); if (!res.ok) throw new Error();
const data = await res.json(); const data = await res.json();
return Array.isArray(data) ? data : (data.departments ?? []); return Array.isArray(data) ? data : (data.departments ?? []);
} catch { return []; } } catch {
return [];
}
} }
async function fetchDesigs(): Promise<Desig[]> { async function fetchDesigs(): Promise<Desig[]> {
try { try {
const accessToken = typeof sessionStorage !== 'undefined' const accessToken =
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim() typeof sessionStorage !== "undefined"
: ''; ? (sessionStorage.getItem("nxtgauge_admin_access_token") || "").trim()
: "";
const res = await fetch(`${API}/api/admin/designations?per_page=100`, { const res = await fetch(`${API}/api/admin/designations?per_page=100`, {
headers: { headers: {
Accept: 'application/json', Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
}, },
credentials: 'include', credentials: "include",
}); });
if (!res.ok) throw new Error(); if (!res.ok) throw new Error();
const data = await res.json(); const data = await res.json();
return Array.isArray(data) ? data : (data.designations ?? []); return Array.isArray(data) ? data : (data.designations ?? []);
} catch { return []; } } catch {
return [];
}
} }
export default function CreateEmployeePage() { export default function CreateEmployeePage() {
@ -79,33 +90,37 @@ export default function CreateEmployeePage() {
const [depts] = createResource(fetchDepts); const [depts] = createResource(fetchDepts);
const [desigs] = createResource(fetchDesigs); const [desigs] = createResource(fetchDesigs);
const [fullName, setFullName] = createSignal(''); const [fullName, setFullName] = createSignal("");
const [email, setEmail] = createSignal(''); const [email, setEmail] = createSignal("");
const [employeeCode, setEmployeeCode] = createSignal(''); const [employeeCode, setEmployeeCode] = createSignal("");
const [createLoginCreds, setCreateLoginCreds] = createSignal(true); const [createLoginCreds, setCreateLoginCreds] = createSignal(true);
const [loginPassword, setLoginPassword] = createSignal(''); const [loginPassword, setLoginPassword] = createSignal("");
const [confirmLoginPassword, setConfirmLoginPassword] = createSignal(''); const [confirmLoginPassword, setConfirmLoginPassword] = createSignal("");
const [roleId, setRoleId] = createSignal(''); const [roleId, setRoleId] = createSignal("");
const [deptId, setDeptId] = createSignal(''); const [deptId, setDeptId] = createSignal("");
const [desigId, setDesigId] = createSignal(''); const [desigId, setDesigId] = createSignal("");
const [saving, setSaving] = createSignal(false); const [saving, setSaving] = createSignal(false);
const [generatingCode, setGeneratingCode] = createSignal(false); const [generatingCode, setGeneratingCode] = createSignal(false);
const [error, setError] = createSignal(''); const [error, setError] = createSignal("");
const fetchNextEmployeeCode = async (): Promise<string> => { const fetchNextEmployeeCode = async (): Promise<string> => {
const accessToken = typeof sessionStorage !== 'undefined' const accessToken =
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim() typeof sessionStorage !== "undefined"
: ''; ? (sessionStorage.getItem("nxtgauge_admin_access_token") || "").trim()
: "";
let page = 1; let page = 1;
let maxNum = 0; let maxNum = 0;
while (page <= 100) { 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: { headers: {
Accept: 'application/json', Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
}, },
credentials: 'include', credentials: "include",
}).catch(() => null); }
).catch(() => null);
if (!res?.ok) break; if (!res?.ok) break;
const payload = await res.json().catch(() => null); const payload = await res.json().catch(() => null);
const list: any[] = Array.isArray(payload) const list: any[] = Array.isArray(payload)
@ -117,7 +132,7 @@ export default function CreateEmployeePage() {
: []; : [];
if (!Array.isArray(list) || list.length === 0) break; if (!Array.isArray(list) || list.length === 0) break;
for (const item of list) { 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); const parsed = parseEmployeeCodeNumber(raw);
if (parsed && parsed > maxNum) maxNum = parsed; if (parsed && parsed > maxNum) maxNum = parsed;
} }
@ -133,7 +148,7 @@ export default function CreateEmployeePage() {
try { try {
setEmployeeCode(await fetchNextEmployeeCode()); setEmployeeCode(await fetchNextEmployeeCode());
} catch { } catch {
setEmployeeCode(''); setEmployeeCode("");
} finally { } finally {
setGeneratingCode(false); setGeneratingCode(false);
} }
@ -142,46 +157,72 @@ export default function CreateEmployeePage() {
const handleSave = async (e: Event) => { const handleSave = async (e: Event) => {
e.preventDefault(); e.preventDefault();
if (!fullName().trim()) { setError('Full name is required'); return; } if (!fullName().trim()) {
if (!email().trim()) { setError('Email is required'); return; } setError("Full name is required");
if (!roleId()) { setError('Internal role is required'); return; } 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); 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 { try {
const accessToken = typeof sessionStorage !== 'undefined' const accessToken =
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim() typeof sessionStorage !== "undefined"
: ''; ? (sessionStorage.getItem("nxtgauge_admin_access_token") || "").trim()
: "";
const res = await fetch(`${API}/api/admin/employees/provision`, { const res = await fetch(`${API}/api/admin/employees/provision`, {
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
Accept: 'application/json', Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
}, },
method: 'POST', method: "POST",
credentials: 'include', credentials: "include",
body: JSON.stringify({ body: JSON.stringify({
email: email().trim(), email: email().trim(),
full_name: fullName().trim(), first_name: fullName().trim().split(" ")[0] || "",
role_id: roleId(), last_name: fullName().trim().split(" ").slice(1).join(" ") || "",
department_id: deptId(), role_code: roleId(),
designation_id: desigId(), department_id: deptId().trim(),
employee_code: employeeCode() || undefined, designation_id: desigId().trim(),
employee_code: employeeCode().trim() || undefined,
generate_login: createLoginCreds(), generate_login: createLoginCreds(),
password: createLoginCreds() ? loginPassword().trim() : undefined, password: createLoginCreds() ? loginPassword().trim() : undefined,
}), }),
}); });
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => ({})); 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) { } catch (err: any) {
setError(err.message || 'Failed to create employee'); setError(err.message || "Failed to create employee");
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -189,13 +230,16 @@ export default function CreateEmployeePage() {
return ( return (
<div class="w-full space-y-8 pb-8"> <div class="w-full space-y-8 pb-8">
{/* Page header */} {/* Page header */}
<div class="flex items-end justify-between"> <div class="flex items-end justify-between">
<div> <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> <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> </div>
<A <A
href="/admin/employees" 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="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm overflow-hidden">
<div class="border-b border-[#F3F4F6] px-6 py-4"> <div class="border-b border-[#F3F4F6] px-6 py-4">
<h2 class="text-[15px] font-semibold text-[#111827]">Employee Details</h2> <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> </div>
<form onSubmit={handleSave} class="p-6 space-y-5"> <form onSubmit={handleSave} class="p-6 space-y-5">
<div class="grid grid-cols-2 gap-5"> <div class="grid grid-cols-2 gap-5">
{/* Full Name */} {/* Full Name */}
<div> <div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5"> <label class="block text-[13px] font-medium text-[#111827] mb-1.5">
@ -225,7 +270,7 @@ export default function CreateEmployeePage() {
required required
placeholder="e.g. Arjun Sharma" placeholder="e.g. Arjun Sharma"
value={fullName()} value={fullName()}
onInput={e => setFullName(e.currentTarget.value)} onInput={(e) => setFullName(e.currentTarget.value)}
maxlength="100" 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]" 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 required
placeholder="e.g. arjun@nxtgauge.com" placeholder="e.g. arjun@nxtgauge.com"
value={email()} 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]" class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
/> />
</div> </div>
@ -255,7 +300,7 @@ export default function CreateEmployeePage() {
type="text" type="text"
readOnly readOnly
value={employeeCode()} 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]" class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg bg-[#F9FAFB] text-[#111827] placeholder-[#9CA3AF]"
/> />
</div> </div>
@ -263,10 +308,18 @@ export default function CreateEmployeePage() {
{/* Login Credential Controls */} {/* Login Credential Controls */}
<div class="col-span-2 rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] px-4 py-3"> <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]"> <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 Create login credentials if this email does not exist
</label> </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> </div>
<Show when={createLoginCreds()}> <Show when={createLoginCreds()}>
@ -278,7 +331,7 @@ export default function CreateEmployeePage() {
<input <input
type="password" type="password"
value={loginPassword()} value={loginPassword()}
onInput={e => setLoginPassword(e.currentTarget.value)} onInput={(e) => setLoginPassword(e.currentTarget.value)}
placeholder="Minimum 8 characters" 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]" 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 <input
type="password" type="password"
value={confirmLoginPassword()} value={confirmLoginPassword()}
onInput={e => setConfirmLoginPassword(e.currentTarget.value)} onInput={(e) => setConfirmLoginPassword(e.currentTarget.value)}
placeholder="Repeat password" 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]" 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> </label>
<select <select
value={roleId()} 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]" 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> <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> </select>
</div> </div>
@ -320,11 +373,11 @@ export default function CreateEmployeePage() {
</label> </label>
<select <select
value={deptId()} 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]" 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> <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> </select>
</div> </div>
@ -335,14 +388,13 @@ export default function CreateEmployeePage() {
</label> </label>
<select <select
value={desigId()} 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]" 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> <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> </select>
</div> </div>
</div> </div>
{/* Info note */} {/* Info note */}
@ -352,7 +404,9 @@ export default function CreateEmployeePage() {
{/* Error */} {/* Error */}
{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 */} {/* Footer */}
@ -368,12 +422,11 @@ export default function CreateEmployeePage() {
disabled={saving()} 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" 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> </button>
</div> </div>
</form> </form>
</div> </div>
</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 { A, useParams } from "@solidjs/router";
import { createMemo, createResource, Show } from 'solid-js'; import { createMemo, createResource, Show } from "solid-js";
const API = ''; const API = "";
type Job = { type Job = {
id: string; id: string;
@ -28,7 +28,7 @@ type Job = {
async function fetchJob(id: string): Promise<Job | null> { async function fetchJob(id: string): Promise<Job | null> {
try { try {
const res = await fetch(`${API}/api/jobs/${id}`); const res = await fetch(`${API}/api/admin/jobs/${id}`);
if (!res.ok) return null; if (!res.ok) return null;
const data = await res.json(); const data = await res.json();
return data.job || data; return data.job || data;
@ -42,8 +42,11 @@ export default function JobDetailPage() {
const [job] = createResource(() => params.id, fetchJob); const [job] = createResource(() => params.id, fetchJob);
const skills = createMemo(() => job()?.requiredSkills || job()?.required_skills || []); const skills = createMemo(() => job()?.requiredSkills || job()?.required_skills || []);
const client = createMemo(() => job()?.clientName || job()?.client_name || job()?.companyName || job()?.company_name || '—'); const client = createMemo(
const exp = createMemo(() => job()?.experienceLevel || job()?.experience_level || '—'); () =>
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 rateMin = createMemo(() => job()?.hourlyRateMin ?? job()?.hourly_rate_min);
const rateMax = createMemo(() => job()?.hourlyRateMax ?? job()?.hourly_rate_max); const rateMax = createMemo(() => job()?.hourlyRateMax ?? job()?.hourly_rate_max);
const duration = createMemo(() => job()?.durationDays ?? job()?.duration_days); 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 class="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
<div> <div>
<h1 class="text-xl font-semibold text-gray-900">Job Management</h1> <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> </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>
<div class="p-6 flex-1"> <div class="p-6 flex-1">
<Show when={job.loading}> <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>
<Show when={!job.loading && !job()}> <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>
<Show when={job()}> <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 class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div> <div>
<p class="hint">Title</p> <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>
<div> <div>
<p class="hint">Status</p> <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>
<div> <div>
<p class="hint">Client</p> <p class="hint">Client</p>
@ -88,28 +101,36 @@ export default function JobDetailPage() {
</div> </div>
<div> <div>
<p class="hint">Rate</p> <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>
<div> <div>
<p class="hint">Location</p> <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>
<div> <div>
<p class="hint">Availability</p> <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>
<div> <div>
<p class="hint">Duration</p> <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>
<div style="grid-column:1/-1"> <div style="grid-column:1/-1">
<p class="hint">Required Skills</p> <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> </div>
<div style="margin-top:18px"> <div style="margin-top:18px">
<p class="hint">Description</p> <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> </div>
</section> </section>
</Show> </Show>

View file

@ -1,12 +1,12 @@
import { A, useNavigate, useParams } from '@solidjs/router'; import { A, useNavigate, useParams } from "@solidjs/router";
import { createEffect, createResource, createSignal, Show } from 'solid-js'; import { createEffect, createResource, createSignal, Show } from "solid-js";
const API = ''; const API = "";
function getToken(): string { function getToken(): string {
return typeof sessionStorage !== 'undefined' return typeof sessionStorage !== "undefined"
? sessionStorage.getItem('nxtgauge_admin_access_token') || '' ? sessionStorage.getItem("nxtgauge_admin_access_token") || ""
: ''; : "";
} }
type KbArticle = { type KbArticle = {
@ -24,17 +24,17 @@ async function loadArticle(id: string): Promise<KbArticle | null> {
const token = getToken(); const token = getToken();
const res = await fetch(`${API}/api/admin/kb/articles/${id}`, { const res = await fetch(`${API}/api/admin/kb/articles/${id}`, {
headers: { headers: {
Accept: 'application/json', Accept: "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
}, },
credentials: 'include', credentials: "include",
}); });
if (!res.ok) return null; if (!res.ok) return null;
const data = await res.json(); const data = await res.json();
return { return {
...data, ...data,
content: data?.content ?? data?.body ?? '', content: data?.content ?? data?.body ?? "",
body: data?.body ?? data?.content ?? '', body: data?.body ?? data?.content ?? "",
}; };
} catch { } catch {
return null; return null;
@ -45,23 +45,23 @@ export default function KbArticleEditPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const params = useParams(); const params = useParams();
const [article] = createResource(() => params.id, loadArticle); const [article] = createResource(() => params.id, loadArticle);
const [title, setTitle] = createSignal(''); const [title, setTitle] = createSignal("");
const [slug, setSlug] = createSignal(''); const [slug, setSlug] = createSignal("");
const [categoryId, setCategoryId] = createSignal(''); const [categoryId, setCategoryId] = createSignal("");
const [status, setStatus] = createSignal('DRAFT'); const [status, setStatus] = createSignal("DRAFT");
const [content, setContent] = createSignal(''); const [content, setContent] = createSignal("");
const [saving, setSaving] = createSignal(false); const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal(''); const [error, setError] = createSignal("");
const [loaded, setLoaded] = createSignal(false); const [loaded, setLoaded] = createSignal(false);
createEffect(() => { createEffect(() => {
const value = article(); const value = article();
if (!value || loaded()) return; if (!value || loaded()) return;
setTitle(value.title || ''); setTitle(value.title || "");
setSlug(value.slug || ''); setSlug(value.slug || "");
setCategoryId(value.category_id || ''); setCategoryId(value.category_id || "");
setStatus(value.status || 'DRAFT'); setStatus(value.status || "DRAFT");
setContent(value.content || value.body || ''); setContent(value.content || value.body || "");
setLoaded(true); setLoaded(true);
}); });
@ -69,33 +69,34 @@ export default function KbArticleEditPage() {
e.preventDefault(); e.preventDefault();
try { try {
setSaving(true); setSaving(true);
setError(''); setError("");
const res = await fetch(`${API}/api/admin/kb/articles/${params.id}`, { const res = await fetch(`${API}/api/admin/kb/articles/${params.id}`, {
method: 'PATCH', method: "PATCH",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}), ...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}),
}, },
credentials: 'include', credentials: "include",
body: JSON.stringify({ body: JSON.stringify({
title: title(), title: title(),
slug: slug(), slug: slug(),
category_id: categoryId() || null, category_id: categoryId() || null,
status: status(), 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}`); navigate(`/admin/kb/articles/${params.id}`);
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Failed to save article'); setError(err.message || "Failed to save article");
} finally { } finally {
setSaving(false); 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 inputCls =
const labelCls = 'mb-1.5 block text-sm font-medium text-gray-700'; "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 ( return (
<div class="flex flex-col -mx-6 -mt-6 min-h-full"> <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> <p class="text-sm text-gray-500 mt-0.5">Update article metadata, status, and content.</p>
</div> </div>
<div class="flex items-center gap-2"> <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
<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> 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> </div>
<div class="p-6 flex-1"> <div class="p-6 flex-1">
<Show when={article.loading}> <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>
<Show when={!article.loading && !article()}> <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>
<Show when={article() && loaded()}> <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 class="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div> <div>
<label class={labelCls}>Title</label> <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>
<div> <div>
<label class={labelCls}>Slug</label> <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>
<div> <div>
<label class={labelCls}>Category ID</label> <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>
<div> <div>
<label class={labelCls}>Status</label> <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="DRAFT">DRAFT</option>
<option value="PUBLISHED">PUBLISHED</option> <option value="PUBLISHED">PUBLISHED</option>
</select> </select>
</div> </div>
<div class="sm:col-span-2"> <div class="sm:col-span-2">
<label class={labelCls}>Content</label> <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>
</div> </div>
<Show when={error()}> <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> </Show>
<div class="mt-6 flex justify-end border-t border-gray-100 pt-5"> <div class="mt-6 flex justify-end border-t border-gray-100 pt-5">
<button class="btn-primary" type="submit" disabled={saving()}> <button class="btn-primary" type="submit" disabled={saving()}>
{saving() ? 'Saving…' : 'Save Article'} {saving() ? "Saving…" : "Save Article"}
</button> </button>
</div> </div>
</form> </form>

View file

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

View file

@ -1,19 +1,19 @@
import { createResource, createSignal, createMemo, Show, For } from 'solid-js'; import { createResource, createSignal, createMemo, Show, For } from "solid-js";
import { A } from '@solidjs/router'; import { A } from "@solidjs/router";
const API = ''; const API = "";
function getToken(): string { function getToken(): string {
return typeof sessionStorage !== 'undefined' return typeof sessionStorage !== "undefined"
? sessionStorage.getItem('nxtgauge_admin_access_token') || '' ? sessionStorage.getItem("nxtgauge_admin_access_token") || ""
: ''; : "";
} }
function authHeaders(contentType = false): Record<string, string> { function authHeaders(contentType = false): Record<string, string> {
const token = getToken(); const token = getToken();
return { return {
Accept: 'application/json', Accept: "application/json",
...(contentType ? { 'Content-Type': 'application/json' } : {}), ...(contentType ? { "Content-Type": "application/json" } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
}; };
} }
@ -22,9 +22,14 @@ type SupportCase = {
id: string; id: string;
title: string; title: string;
description: string; description: string;
type: 'platform_issue' | 'customer_query' | 'professional_query' | 'billing_issue' | 'lead_dispute'; type:
priority: 'low' | 'medium' | 'high' | 'critical'; | "platform_issue"
status: 'new' | 'in_progress' | 'waiting_for_user' | 'resolved' | 'closed'; | "customer_query"
| "professional_query"
| "billing_issue"
| "lead_dispute";
priority: "low" | "medium" | "high" | "critical";
status: "new" | "in_progress" | "waiting_for_user" | "resolved" | "closed";
requesterName?: string; requesterName?: string;
requesterEmail?: string; requesterEmail?: string;
updatedAt: string; updatedAt: string;
@ -37,55 +42,68 @@ type AssigneeOption = {
email?: string; email?: string;
}; };
const STATUS_OPTIONS: SupportCase['status'][] = ['new', 'in_progress', 'waiting_for_user', 'resolved', 'closed']; const STATUS_OPTIONS: SupportCase["status"][] = [
const TYPE_OPTIONS: SupportCase['type'][] = ['platform_issue', 'customer_query', 'professional_query', 'billing_issue', 'lead_dispute']; "new",
const PRIORITY_OPTIONS: SupportCase['priority'][] = ['low', 'medium', 'high', 'critical']; "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 { 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 { function typeBadgeStyle(type: string): string {
const map: Record<string, string> = { const map: Record<string, string> = {
platform_issue: 'background:#dbeafe;color:#1d4ed8', platform_issue: "background:#dbeafe;color:#1d4ed8",
customer_query: 'background:#dcfce7;color:#15803d', customer_query: "background:#dcfce7;color:#15803d",
billing_issue: 'background:#ffedd5;color:#c2410c', billing_issue: "background:#ffedd5;color:#c2410c",
lead_dispute: 'background:#fee2e2;color:#b91c1c', lead_dispute: "background:#fee2e2;color:#b91c1c",
professional_query: 'background:#f3e8ff;color:#7e22ce', 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 { function priorityBadgeStyle(priority: string): string {
const map: Record<string, string> = { const map: Record<string, string> = {
low: 'background:#f1f5f9;color:#475569', low: "background:#f1f5f9;color:#475569",
medium: 'background:#dbeafe;color:#1d4ed8', medium: "background:#dbeafe;color:#1d4ed8",
high: 'background:#ffedd5;color:#c2410c', high: "background:#ffedd5;color:#c2410c",
critical: 'background:#fee2e2;color:#b91c1c', critical: "background:#fee2e2;color:#b91c1c",
}; };
return map[priority] || 'background:#f1f5f9;color:#475569'; return map[priority] || "background:#f1f5f9;color:#475569";
} }
function statusBadgeStyle(status: string): string { function statusBadgeStyle(status: string): string {
const map: Record<string, string> = { const map: Record<string, string> = {
new: 'background:#dbeafe;color:#1d4ed8', new: "background:#dbeafe;color:#1d4ed8",
in_progress: 'background:#ffedd5;color:#c2410c', in_progress: "background:#ffedd5;color:#c2410c",
waiting_for_user: 'background:#fef9c3;color:#a16207', waiting_for_user: "background:#fef9c3;color:#a16207",
resolved: 'background:#dcfce7;color:#15803d', resolved: "background:#dcfce7;color:#15803d",
closed: 'background:#f1f5f9;color:#475569', 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[]> { async function loadAllCases(): Promise<SupportCase[]> {
try { try {
const res = await fetch(`${API}/api/admin/support-cases`, { const res = await fetch(`${API}/api/admin/support-cases`, {
headers: authHeaders(), 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 data = await res.json();
return Array.isArray(data.cases) ? data.cases : Array.isArray(data) ? data : []; return Array.isArray(data.cases) ? data.cases : Array.isArray(data) ? data : [];
} catch { } catch {
@ -95,29 +113,31 @@ async function loadAllCases(): Promise<SupportCase[]> {
async function loadAssignees(): Promise<AssigneeOption[]> { async function loadAssignees(): Promise<AssigneeOption[]> {
try { 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()}`, { const res = await fetch(`${API}/api/admin/employees?${params.toString()}`, {
headers: authHeaders(), 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 data = await res.json();
const raw = Array.isArray(data?.items) ? data.items : Array.isArray(data) ? data : []; const raw = Array.isArray(data?.items) ? data.items : Array.isArray(data) ? data : [];
return raw.map((item: any) => ({ return raw
id: String(item.id ?? ''), .map((item: any) => ({
name: String(item.name ?? item.full_name ?? item.email ?? 'Unknown'), id: String(item.id ?? ""),
name: String(item.name ?? item.full_name ?? item.email ?? "Unknown"),
email: item.email ? String(item.email) : undefined, email: item.email ? String(item.email) : undefined,
})).filter((item: AssigneeOption) => Boolean(item.id)); }))
.filter((item: AssigneeOption) => Boolean(item.id));
} catch { } catch {
return []; return [];
} }
} }
export default function SupportPage() { export default function SupportPage() {
const [activeTab, setActiveTab] = createSignal<'queue' | 'create'>('queue'); const [activeTab, setActiveTab] = createSignal<"queue" | "create">("queue");
const [statusFilter, setStatusFilter] = createSignal<'all' | SupportCase['status']>('all'); const [statusFilter, setStatusFilter] = createSignal<"all" | SupportCase["status"]>("all");
const [search, setSearch] = createSignal(''); const [search, setSearch] = createSignal("");
const [sortBy, setSortBy] = createSignal<'newest' | 'oldest' | 'priority'>('newest'); const [sortBy, setSortBy] = createSignal<"newest" | "oldest" | "priority">("newest");
const [sortMenuOpen, setSortMenuOpen] = createSignal(false); const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false); const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
const [refetchKey, setRefetchKey] = createSignal(0); const [refetchKey, setRefetchKey] = createSignal(0);
@ -131,21 +151,39 @@ export default function SupportPage() {
let all = cases() ?? []; let all = cases() ?? [];
const q = search().toLowerCase().trim(); const q = search().toLowerCase().trim();
if (q) { if (q) {
all = all.filter((c) => all = all.filter(
String(c.title || '').toLowerCase().includes(q) (c) =>
|| String(c.description || '').toLowerCase().includes(q) String(c.title || "")
|| String(c.requesterName || '').toLowerCase().includes(q) .toLowerCase()
|| String(c.requesterEmail || '').toLowerCase().includes(q) .includes(q) ||
|| String(c.type || '').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(); const sf = statusFilter();
if (sf !== 'all') all = all.filter((c) => c.status === sf); 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 priorityRank: Record<SupportCase["priority"], number> = {
critical: 4,
high: 3,
medium: 2,
low: 1,
};
const sorted = [...all]; const sorted = [...all];
sorted.sort((a, b) => { sorted.sort((a, b) => {
if (sortBy() === 'oldest') return new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime(); if (sortBy() === "oldest")
if (sortBy() === 'priority') return (priorityRank[b.priority] || 0) - (priorityRank[a.priority] || 0); 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 new Date(b.createdAt || 0).getTime() - new Date(a.createdAt || 0).getTime();
}); });
return sorted; return sorted;
@ -154,49 +192,49 @@ export default function SupportPage() {
const stats = createMemo(() => { const stats = createMemo(() => {
const all = cases() ?? []; const all = cases() ?? [];
return { return {
newCount: all.filter((c) => c.status === 'new').length, newCount: all.filter((c) => c.status === "new").length,
inProgressCount: all.filter((c) => c.status === 'in_progress').length, inProgressCount: all.filter((c) => c.status === "in_progress").length,
waitingCount: all.filter((c) => c.status === 'waiting_for_user').length, waitingCount: all.filter((c) => c.status === "waiting_for_user").length,
total: all.length, total: all.length,
}; };
}); });
// Create Case form state // Create Case form state
const [fTitle, setFTitle] = createSignal(''); const [fTitle, setFTitle] = createSignal("");
const [fDesc, setFDesc] = createSignal(''); const [fDesc, setFDesc] = createSignal("");
const [fType, setFType] = createSignal<SupportCase['type']>('customer_query'); const [fType, setFType] = createSignal<SupportCase["type"]>("customer_query");
const [fPriority, setFPriority] = createSignal<SupportCase['priority']>('medium'); const [fPriority, setFPriority] = createSignal<SupportCase["priority"]>("medium");
const [fRequesterName, setFRequesterName] = createSignal(''); const [fRequesterName, setFRequesterName] = createSignal("");
const [fRequesterEmail, setFRequesterEmail] = createSignal(''); const [fRequesterEmail, setFRequesterEmail] = createSignal("");
const [fAssignedTo, setFAssignedTo] = createSignal(''); const [fAssignedTo, setFAssignedTo] = createSignal("");
const [createLoading, setCreateLoading] = createSignal(false); const [createLoading, setCreateLoading] = createSignal(false);
const [createSuccess, setCreateSuccess] = createSignal(''); const [createSuccess, setCreateSuccess] = createSignal("");
const [createError, setCreateError] = createSignal(''); const [createError, setCreateError] = createSignal("");
const resetForm = () => { const resetForm = () => {
setFTitle(''); setFTitle("");
setFDesc(''); setFDesc("");
setFType('customer_query'); setFType("customer_query");
setFPriority('medium'); setFPriority("medium");
setFRequesterName(''); setFRequesterName("");
setFRequesterEmail(''); setFRequesterEmail("");
setFAssignedTo(''); setFAssignedTo("");
}; };
const handleCreate = async (e: Event) => { const handleCreate = async (e: Event) => {
e.preventDefault(); e.preventDefault();
setCreateLoading(true); setCreateLoading(true);
setCreateSuccess(''); setCreateSuccess("");
setCreateError(''); setCreateError("");
try { try {
const res = await fetch(`${API}/api/admin/support-cases`, { const res = await fetch(`${API}/api/admin/support-cases`, {
method: 'POST', method: "POST",
headers: authHeaders(true), headers: authHeaders(true),
credentials: 'include', credentials: "include",
body: JSON.stringify({ body: JSON.stringify({
title: fTitle(), title: fTitle(),
description: fDesc(), description: fDesc(),
type: fType(), category: fType(),
priority: fPriority(), priority: fPriority(),
requesterName: fRequesterName(), requesterName: fRequesterName(),
requesterEmail: fRequesterEmail(), requesterEmail: fRequesterEmail(),
@ -204,52 +242,54 @@ export default function SupportPage() {
}); });
if (!res.ok) { if (!res.ok) {
const d = await res.json().catch(() => ({})); 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 created = await res.json().catch(() => ({}));
const createdId = String((created as any)?.id || ''); const createdId = String((created as any)?.id || "");
if (createdId && fAssignedTo()) { if (createdId && fAssignedTo()) {
await fetch(`${API}/api/admin/support-cases/${createdId}`, { await fetch(`${API}/api/admin/support-cases/${createdId}`, {
method: 'PATCH', method: "PATCH",
headers: authHeaders(true), headers: authHeaders(true),
credentials: 'include', credentials: "include",
body: JSON.stringify({ assigned_to: fAssignedTo() }), body: JSON.stringify({ assigned_to: fAssignedTo() }),
}); });
} }
setCreateSuccess('Case created!'); setCreateSuccess("Case created!");
resetForm(); resetForm();
refetch(); refetch();
setActiveTab('queue'); setActiveTab("queue");
} catch (err: any) { } catch (err: any) {
setCreateError(err.message || 'Failed to create case'); setCreateError(err.message || "Failed to create case");
} finally { } finally {
setCreateLoading(false); setCreateLoading(false);
} }
}; };
const statCards = [ const statCards = [
{ label: 'New', getValue: () => stats().newCount }, { label: "New", getValue: () => stats().newCount },
{ label: 'In Progress', getValue: () => stats().inProgressCount }, { label: "In Progress", getValue: () => stats().inProgressCount },
{ label: 'Waiting', getValue: () => stats().waitingCount }, { label: "Waiting", getValue: () => stats().waitingCount },
{ label: 'Total', getValue: () => stats().total }, { label: "Total", getValue: () => stats().total },
]; ];
const exportCsv = () => { const exportCsv = () => {
const headers = ['Issue', 'Type', 'Priority', 'Status', 'Requester', 'Updated']; const headers = ["Issue", "Type", "Priority", "Status", "Requester", "Updated"];
const rows = filteredCases().map((item) => [ const rows = filteredCases().map((item) => [
item.title, item.title,
item.type, item.type,
item.priority, item.priority,
item.status, item.status,
item.requesterEmail || item.requesterName || '—', item.requesterEmail || item.requesterName || "—",
item.updatedAt ? new Date(item.updatedAt).toLocaleString() : '—', item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "—",
]); ]);
const csv = [headers, ...rows].map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(',')).join('\n'); const csv = [headers, ...rows]
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 url = URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement("a");
link.href = url; link.href = url;
link.download = 'support-management.csv'; link.download = "support-management.csv";
link.click(); link.click();
URL.revokeObjectURL(url); 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"> <div class="bg-white border-b border-gray-200 px-6 flex items-center gap-8 sticky top-0 z-10">
<button <button
type="button" type="button"
class={activeTab() === 'queue' class={
? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium' activeTab() === "queue"
: 'py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors'} ? "py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium"
onClick={() => setActiveTab('queue')} : "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 Support Queue
</button> </button>
<button <button
type="button" type="button"
class={activeTab() === 'create' class={
? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium' activeTab() === "create"
: 'py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors'} ? "py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium"
onClick={() => setActiveTab('create')} : "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 Create Case
</button> </button>
@ -297,7 +341,7 @@ export default function SupportPage() {
</div> </div>
{/* Support Queue Tab */} {/* 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="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"> <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 <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" 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;"> <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"> <button
<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> 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 Sort
</button> </button>
<Show when={sortMenuOpen()}> <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"> <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={[ <For
{ key: 'newest', label: 'Newest First' }, each={
{ key: 'oldest', label: 'Oldest First' }, [
{ key: 'priority', label: 'Priority High-Low' }, { key: "newest", label: "Newest First" },
] as { key: 'newest' | 'oldest' | 'priority'; label: string }[]}> { key: "oldest", label: "Oldest First" },
{ key: "priority", label: "Priority High-Low" },
] as { key: "newest" | "oldest" | "priority"; label: string }[]
}
>
{(item) => ( {(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> </For>
</div> </div>
</Show> </Show>
</div> </div>
<div style="position:relative;"> <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"> <button
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h18M6 12h12M10 19h4"/></svg> 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 Filters
</button> </button>
<Show when={filterMenuOpen()}> <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"> <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}> <For each={STATUS_OPTIONS}>
{(s) => ( {(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> </For>
</div> </div>
</Show> </Show>
</div> </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"> <button
<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> 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 Export
</button> </button>
</div> </div>
@ -364,13 +489,25 @@ export default function SupportPage() {
</thead> </thead>
<tbody> <tbody>
<Show when={cases.loading}> <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>
<Show when={!cases.loading && cases.error}> <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>
<Show when={!cases.loading && !cases.error && filteredCases().length === 0}> <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>
<Show when={!cases.loading && !cases.error && filteredCases().length > 0}> <Show when={!cases.loading && !cases.error && filteredCases().length > 0}>
<For each={filteredCases()}> <For each={filteredCases()}>
@ -378,27 +515,42 @@ export default function SupportPage() {
<tr class="hover:bg-slate-50" style="cursor:pointer" onClick={() => {}}> <tr class="hover:bg-slate-50" style="cursor:pointer" onClick={() => {}}>
<td> <td>
<div class="font-semibold text-slate-900">{item.title}</div> <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>
<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>
<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>
<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>
<td> <td>
<div style="font-size:13px">{item.requesterName || '—'}</div> <div style="font-size:13px">{item.requesterName || "—"}</div>
<div style="font-size:11px;color:#64748b">{item.requesterEmail || ''}</div> <div style="font-size:11px;color:#64748b">
{item.requesterEmail || ""}
</div>
</td> </td>
<td class="text-slate-500" style="font-size:12px"> <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>
<td> <td>
<div class="flex items-center justify-end gap-1"> <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> </div>
</td> </td>
</tr> </tr>
@ -411,14 +563,45 @@ export default function SupportPage() {
<Show when={!cases.loading && !cases.error && filteredCases().length > 0}> <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"> <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"> <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> </p>
<div style="display:flex;align-items:center;gap:4px"> <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
<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> type="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> 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 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>
<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>
</div> </div>
</Show> </Show>
@ -427,11 +610,14 @@ export default function SupportPage() {
</Show> </Show>
{/* Create Case Tab */} {/* 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"> <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"> <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> </p>
<Show when={createSuccess()}> <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"> <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> </div>
</Show> </Show>
<Show when={createError()}> <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> </Show>
<form onSubmit={handleCreate} style="display:flex;flex-direction:column;gap:16px"> <form onSubmit={handleCreate} style="display:flex;flex-direction:column;gap:16px">
<div class="field"> <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 <input
type="text" type="text"
required required
@ -453,7 +646,9 @@ export default function SupportPage() {
/> />
</div> </div>
<div class="field"> <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 <textarea
required required
rows="4" rows="4"
@ -464,10 +659,12 @@ export default function SupportPage() {
</div> </div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="field"> <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 <select
value={fType()} 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" style="width:100%;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;font-size:14px;box-sizing:border-box"
> >
<For each={TYPE_OPTIONS}> <For each={TYPE_OPTIONS}>
@ -476,10 +673,12 @@ export default function SupportPage() {
</select> </select>
</div> </div>
<div class="field"> <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 <select
value={fPriority()} 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" style="width:100%;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;font-size:14px;box-sizing:border-box"
> >
<For each={PRIORITY_OPTIONS}> <For each={PRIORITY_OPTIONS}>
@ -490,7 +689,9 @@ export default function SupportPage() {
</div> </div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="field"> <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 <input
type="text" type="text"
value={fRequesterName()} value={fRequesterName()}
@ -499,7 +700,9 @@ export default function SupportPage() {
/> />
</div> </div>
<div class="field"> <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 <input
type="email" type="email"
value={fRequesterEmail()} value={fRequesterEmail()}
@ -509,7 +712,9 @@ export default function SupportPage() {
</div> </div>
</div> </div>
<div class="field"> <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 <select
value={fAssignedTo()} value={fAssignedTo()}
onChange={(e) => setFAssignedTo(e.currentTarget.value)} onChange={(e) => setFAssignedTo(e.currentTarget.value)}
@ -519,7 +724,8 @@ export default function SupportPage() {
<For each={assignees()}> <For each={assignees()}>
{(assignee) => ( {(assignee) => (
<option value={assignee.id}> <option value={assignee.id}>
{assignee.name}{assignee.email ? ` (${assignee.email})` : ''} {assignee.name}
{assignee.email ? ` (${assignee.email})` : ""}
</option> </option>
)} )}
</For> </For>
@ -527,7 +733,7 @@ export default function SupportPage() {
</div> </div>
<div> <div>
<button class="btn-primary" type="submit" disabled={createLoading()}> <button class="btn-primary" type="submit" disabled={createLoading()}>
{createLoading() ? 'Creating...' : 'Create Support Case'} {createLoading() ? "Creating..." : "Create Support Case"}
</button> </button>
</div> </div>
</form> </form>

View file

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