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,48 +207,59 @@ 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("");
} }
}; };
return ( return (
<div class="w-full space-y-6 pb-8"> <div class="w-full space-y-6 pb-8">
<div style="margin-bottom:1.5rem"> <div style="margin-bottom:1.5rem">
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Coupon Management</h1> <h1 class="text-[28px] font-bold leading-tight text-[#111827]">Coupon Management</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">Reusable coupon codes for package checkout</p> <p class="mt-1 text-[14px] text-[#6B7280]">Reusable coupon codes for package checkout</p>
</div> </div>
{/* Tabs */} {/* Tabs */}
<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"
Coupons : "py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors"
</button> }
<button onClick={() => setActiveTab("list")}
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'} Coupons
onClick={() => { resetForm(); setActiveTab('create'); }} </button>
> <button
{form().id ? 'Edit Coupon' : 'Create Coupon'} type="button"
</button> class={
</div> 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"}
</button>
</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
type="text" type="text"
@ -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>
@ -370,112 +487,146 @@ export default function CouponPage() {
</div> </div>
</Show> </Show>
</div> </div>
</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"
<Show when={formError()}> style="max-width:520px"
<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> <h2 style="margin:0 0 20px;font-size:16px;font-weight:700">
<form onSubmit={handleSave} style="display:flex;flex-direction:column;gap:14px"> {form().id ? "Edit Coupon" : "Create Coupon"}
</h2>
<Show when={formError()}>
<div
class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
style="margin-bottom:12px"
>
{formError()}
</div>
</Show>
<form onSubmit={handleSave} style="display:flex;flex-direction:column;gap:14px">
<div class="field">
<label>Code</label>
<input
type="text"
value={form().code}
onInput={(e) => setForm({ ...form(), code: e.currentTarget.value.toUpperCase() })}
required
placeholder="e.g. SAVE10"
style="text-transform:uppercase"
/>
</div>
<div class="field">
<label>Title</label>
<input
type="text"
value={form().title}
onInput={(e) => setForm({ ...form(), title: e.currentTarget.value })}
required
placeholder="e.g. 10% off for companies"
/>
</div>
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="field"> <div class="field">
<label>Code</label> <label>Type</label>
<select
value={form().type}
onChange={(e) =>
setForm({ ...form(), type: e.currentTarget.value as "PERCENT" | "FIXED" })
}
>
<option value="PERCENT">Percent (%)</option>
<option value="FIXED">Fixed ()</option>
</select>
</div>
<div class="field">
<label>Value</label>
<input <input
type="text" type="number"
value={form().code} value={form().value}
onInput={(e) => setForm({ ...form(), code: e.currentTarget.value.toUpperCase() })} onInput={(e) => setForm({ ...form(), value: Number(e.currentTarget.value) })}
required required
placeholder="e.g. SAVE10" min="1"
style="text-transform:uppercase" />
</div>
</div>
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="field">
<label>Min Order Amount ()</label>
<input
type="number"
value={form().min_order_amount}
onInput={(e) =>
setForm({ ...form(), min_order_amount: Number(e.currentTarget.value) })
}
min="0"
placeholder="0"
/> />
</div> </div>
<div class="field"> <div class="field">
<label>Title</label> <label>Max Uses (blank = unlimited)</label>
<input <input
type="text" type="number"
value={form().title} value={form().max_uses}
onInput={(e) => setForm({ ...form(), title: e.currentTarget.value })} onInput={(e) => setForm({ ...form(), max_uses: e.currentTarget.value })}
required min="1"
placeholder="e.g. 10% off for companies" placeholder="Unlimited"
/> />
</div> </div>
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="field">
<div class="field"> <label>Applies To</label>
<label>Type</label> <select
<select value={form().applies_to}
value={form().type} onChange={(e) =>
onChange={(e) => setForm({ ...form(), type: e.currentTarget.value as 'PERCENT' | 'FIXED' })} setForm({ ...form(), applies_to: e.currentTarget.value as "ALL" | "ROLE" })
> }
<option value="PERCENT">Percent (%)</option> >
<option value="FIXED">Fixed ()</option> <option value="ALL">All</option>
</select> <option value="ROLE">Specific Roles</option>
</div> </select>
<div class="field">
<label>Value</label>
<input
type="number"
value={form().value}
onInput={(e) => setForm({ ...form(), value: Number(e.currentTarget.value) })}
required
min="1"
/>
</div>
</div> </div>
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2"> </div>
<div class="field"> <div>
<label>Min Order Amount ()</label> <p style="font-size:13px;font-weight:600;margin:0 0 8px;color:#1e293b">
<input Applicable Roles
type="number" </p>
value={form().min_order_amount} <div style="display:flex;flex-wrap:wrap;gap:8px">
onInput={(e) => setForm({ ...form(), min_order_amount: Number(e.currentTarget.value) })} <For each={ROLE_OPTIONS}>
min="0" {(role) => {
placeholder="0" const active = () => form().role_keys.includes(role);
/> return (
</div> <button
<div class="field"> type="button"
<label>Max Uses (blank = unlimited)</label> onClick={() => toggleRole(role)}
<input 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"}`}
type="number" >
value={form().max_uses} {role}
onInput={(e) => setForm({ ...form(), max_uses: e.currentTarget.value })} </button>
min="1" );
placeholder="Unlimited" }}
/> </For>
</div>
</div> </div>
<div> </div>
<p style="font-size:13px;font-weight:600;margin:0 0 8px;color:#1e293b">Applicable Roles</p> <div class="actions">
<div style="display:flex;flex-wrap:wrap;gap:8px"> <button class="btn-primary" type="submit" disabled={saving()}>
<For each={ROLE_OPTIONS}> {saving() ? "Saving..." : form().id ? "Update Coupon" : "Save Coupon"}
{(role) => { </button>
const active = () => form().role_keys.includes(role); <Show when={form().id}>
return ( <button
<button type="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={() => toggleRole(role)} onClick={resetForm}
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'}`} >
> Cancel Edit
{role}
</button>
);
}}
</For>
</div>
</div>
<div class="actions">
<button class="btn-primary" type="submit" disabled={saving()}>
{saving() ? 'Saving...' : (form().id ? 'Update Coupon' : 'Save Coupon')}
</button> </button>
<Show when={form().id}> </Show>
<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> </div>
</Show> </form>
</div> </section>
</form> </Show>
</section>
</Show>
</div>
</div> </div>
</div>
); );
} }

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(
headers: { `${API}/api/admin/employees?page=${page}&per_page=100&sort=joined_desc`,
Accept: 'application/json', {
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), headers: {
}, Accept: "application/json",
credentials: 'include', ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
}).catch(() => null); },
credentials: "include",
}
).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,238 +157,276 @@ 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);
} }
}; };
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]">
<p class="text-[12px] font-semibold uppercase tracking-widest text-[#FF5E13]">Internal Team</p> Internal Team
<h1 class="mt-1 text-[28px] font-bold leading-tight text-[#111827]">Add Employee</h1> </p>
<p class="mt-1 text-[14px] text-[#6B7280]">Dashboard / Employee Management / Add Employee</p> <h1 class="mt-1 text-[28px] font-bold leading-tight text-[#111827]">Add Employee</h1>
</div> <p class="mt-1 text-[14px] text-[#6B7280]">
<A Dashboard / Employee Management / Add Employee
href="/admin/employees" </p>
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 Employees
</A>
</div> </div>
<A
{/* Form card */} href="/admin/employees"
<div class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm overflow-hidden"> 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"
<div class="border-b border-[#F3F4F6] px-6 py-4"> >
<h2 class="text-[15px] font-semibold text-[#111827]">Employee Details</h2> Back to Employees
<p class="mt-0.5 text-[13px] text-[#6B7280]">Login credentials will be emailed to the employee automatically.</p> </A>
</div>
<form onSubmit={handleSave} class="p-6 space-y-5">
<div class="grid grid-cols-2 gap-5">
{/* Full Name */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Full Name <span class="text-red-500">*</span>
</label>
<input
type="text"
required
placeholder="e.g. Arjun Sharma"
value={fullName()}
onInput={e => setFullName(e.currentTarget.value)}
maxlength="100"
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
/>
</div>
{/* Email */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Email Address <span class="text-red-500">*</span>
</label>
<input
type="email"
required
placeholder="e.g. arjun@nxtgauge.com"
value={email()}
onInput={e => setEmail(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
/>
</div>
{/* Employee ID */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Employee ID <span class="text-red-500">*</span>
</label>
<input
type="text"
readOnly
value={employeeCode()}
placeholder={generatingCode() ? 'Generating...' : 'Auto generated'}
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg bg-[#F9FAFB] text-[#111827] placeholder-[#9CA3AF]"
/>
</div>
{/* Login Credential Controls */}
<div class="col-span-2 rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] px-4 py-3">
<label class="flex items-center gap-2 text-[13px] font-medium text-[#111827]">
<input type="checkbox" checked={createLoginCreds()} onChange={e => setCreateLoginCreds(e.currentTarget.checked)} class="h-4 w-4 accent-[#FF5E13]" />
Create login credentials if this email does not exist
</label>
<p class="mt-1 text-[12px] text-[#6B7280]">When enabled, a user account is created with the password below and then linked as employee.</p>
</div>
<Show when={createLoginCreds()}>
<>
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Login Password <span class="text-red-500">*</span>
</label>
<input
type="password"
value={loginPassword()}
onInput={e => setLoginPassword(e.currentTarget.value)}
placeholder="Minimum 8 characters"
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
/>
</div>
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Confirm Password <span class="text-red-500">*</span>
</label>
<input
type="password"
value={confirmLoginPassword()}
onInput={e => setConfirmLoginPassword(e.currentTarget.value)}
placeholder="Repeat password"
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
/>
</div>
</>
</Show>
{/* Internal Role */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Internal Role <span class="text-red-500">*</span>
</label>
<select
value={roleId()}
onChange={e => setRoleId(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] bg-white text-[#111827]"
>
<option value="">Select role</option>
<For each={roles() ?? []}>{r => <option value={r.id}>{r.name}</option>}</For>
</select>
</div>
{/* Department */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Department <span class="text-red-500">*</span>
</label>
<select
value={deptId()}
onChange={e => setDeptId(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] bg-white text-[#111827]"
>
<option value="">Select department</option>
<For each={depts() ?? []}>{d => <option value={d.id}>{d.name}</option>}</For>
</select>
</div>
{/* Designation */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Designation <span class="text-red-500">*</span>
</label>
<select
value={desigId()}
onChange={e => setDesigId(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] bg-white text-[#111827]"
>
<option value="">Select designation</option>
<For each={desigs() ?? []}>{d => <option value={d.id}>{d.name}</option>}</For>
</select>
</div>
</div>
{/* Info note */}
<div class="rounded-lg border border-[#EFF6FF] bg-[#EFF6FF] px-4 py-3 text-[13px] text-[#2563EB]">
Login credentials will be auto-generated and sent to the employee's email address.
</div>
{/* Error */}
{error() && (
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700">{error()}</div>
)}
{/* Footer */}
<div class="flex items-center justify-end gap-3 border-t border-[#F3F4F6] pt-5">
<A
href="/admin/employees"
class="h-[40px] inline-flex items-center rounded-xl border border-[#E5E7EB] bg-white px-5 text-[13px] font-semibold text-[#374151] hover:bg-[#F9FAFB] transition-colors"
>
Cancel
</A>
<button
type="submit"
disabled={saving()}
class="h-[40px] rounded-xl bg-[#FF5E13] px-6 text-[13px] font-semibold text-white hover:bg-[#e04d0a] transition-colors disabled:opacity-60"
>
{saving() ? 'Creating…' : 'Add Employee'}
</button>
</div>
</form>
</div>
</div> </div>
{/* Form card */}
<div class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm overflow-hidden">
<div class="border-b border-[#F3F4F6] px-6 py-4">
<h2 class="text-[15px] font-semibold text-[#111827]">Employee Details</h2>
<p class="mt-0.5 text-[13px] text-[#6B7280]">
Login credentials will be emailed to the employee automatically.
</p>
</div>
<form onSubmit={handleSave} class="p-6 space-y-5">
<div class="grid grid-cols-2 gap-5">
{/* Full Name */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Full Name <span class="text-red-500">*</span>
</label>
<input
type="text"
required
placeholder="e.g. Arjun Sharma"
value={fullName()}
onInput={(e) => setFullName(e.currentTarget.value)}
maxlength="100"
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
/>
</div>
{/* Email */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Email Address <span class="text-red-500">*</span>
</label>
<input
type="email"
required
placeholder="e.g. arjun@nxtgauge.com"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
/>
</div>
{/* Employee ID */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Employee ID <span class="text-red-500">*</span>
</label>
<input
type="text"
readOnly
value={employeeCode()}
placeholder={generatingCode() ? "Generating..." : "Auto generated"}
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg bg-[#F9FAFB] text-[#111827] placeholder-[#9CA3AF]"
/>
</div>
{/* Login Credential Controls */}
<div class="col-span-2 rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] px-4 py-3">
<label class="flex items-center gap-2 text-[13px] font-medium text-[#111827]">
<input
type="checkbox"
checked={createLoginCreds()}
onChange={(e) => setCreateLoginCreds(e.currentTarget.checked)}
class="h-4 w-4 accent-[#FF5E13]"
/>
Create login credentials if this email does not exist
</label>
<p class="mt-1 text-[12px] text-[#6B7280]">
When enabled, a user account is created with the password below and then linked as
employee.
</p>
</div>
<Show when={createLoginCreds()}>
<>
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Login Password <span class="text-red-500">*</span>
</label>
<input
type="password"
value={loginPassword()}
onInput={(e) => setLoginPassword(e.currentTarget.value)}
placeholder="Minimum 8 characters"
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
/>
</div>
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Confirm Password <span class="text-red-500">*</span>
</label>
<input
type="password"
value={confirmLoginPassword()}
onInput={(e) => setConfirmLoginPassword(e.currentTarget.value)}
placeholder="Repeat password"
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
/>
</div>
</>
</Show>
{/* Internal Role */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Internal Role <span class="text-red-500">*</span>
</label>
<select
value={roleId()}
onChange={(e) => setRoleId(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] bg-white text-[#111827]"
>
<option value="">Select role</option>
<For each={roles() ?? []}>{(r) => <option value={r.id}>{r.name}</option>}</For>
</select>
</div>
{/* Department */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Department <span class="text-red-500">*</span>
</label>
<select
value={deptId()}
onChange={(e) => setDeptId(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] bg-white text-[#111827]"
>
<option value="">Select department</option>
<For each={depts() ?? []}>{(d) => <option value={d.id}>{d.name}</option>}</For>
</select>
</div>
{/* Designation */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Designation <span class="text-red-500">*</span>
</label>
<select
value={desigId()}
onChange={(e) => setDesigId(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] bg-white text-[#111827]"
>
<option value="">Select designation</option>
<For each={desigs() ?? []}>{(d) => <option value={d.id}>{d.name}</option>}</For>
</select>
</div>
</div>
{/* Info note */}
<div class="rounded-lg border border-[#EFF6FF] bg-[#EFF6FF] px-4 py-3 text-[13px] text-[#2563EB]">
Login credentials will be auto-generated and sent to the employee's email address.
</div>
{/* Error */}
{error() && (
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700">
{error()}
</div>
)}
{/* Footer */}
<div class="flex items-center justify-end gap-3 border-t border-[#F3F4F6] pt-5">
<A
href="/admin/employees"
class="h-[40px] inline-flex items-center rounded-xl border border-[#E5E7EB] bg-white px-5 text-[13px] font-semibold text-[#374151] hover:bg-[#F9FAFB] transition-colors"
>
Cancel
</A>
<button
type="submit"
disabled={saving()}
class="h-[40px] rounded-xl bg-[#FF5E13] px-6 text-[13px] font-semibold text-white hover:bg-[#e04d0a] transition-colors disabled:opacity-60"
>
{saving() ? "Creating…" : "Add Employee"}
</button>
</div>
</form>
</div>
</div>
); );
} }

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
import { A, useParams } from '@solidjs/router'; import { 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,78 +42,99 @@ 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);
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">
<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">
<div class="rounded-xl border border-gray-200 bg-white shadow-sm"><p class="notice">Loading job...</p></div> <p class="notice">Loading job...</p>
</Show>
<Show when={!job.loading && !job()}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm"><p class="notice">Job not found.</p></div>
</Show>
<Show when={job()}>
<section class="rounded-xl border border-gray-200 bg-white shadow-sm">
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<p class="hint">Title</p>
<p style="margin:6px 0 0;font-weight:700;color:#0f172a">{job()!.title || '—'}</p>
</div>
<div>
<p class="hint">Status</p>
<p style="margin:6px 0 0;color:#334155">{job()!.status || '—'}</p>
</div>
<div>
<p class="hint">Client</p>
<p style="margin:6px 0 0;color:#334155">{client()}</p>
</div>
<div>
<p class="hint">Experience</p>
<p style="margin:6px 0 0;color:#334155">{exp()}</p>
</div>
<div>
<p class="hint">Rate</p>
<p style="margin:6px 0 0;color:#334155">{rateMin() != null ? `${rateMin()} - ₹${rateMax() ?? rateMin()}` : '—'}</p>
</div>
<div>
<p class="hint">Location</p>
<p style="margin:6px 0 0;color:#334155">{job()!.location || '—'}</p>
</div>
<div>
<p class="hint">Availability</p>
<p style="margin:6px 0 0;color:#334155">{job()!.availability || '—'}</p>
</div>
<div>
<p class="hint">Duration</p>
<p style="margin:6px 0 0;color:#334155">{duration() != null ? `${duration()} days` : '—'}</p>
</div>
<div style="grid-column:1/-1">
<p class="hint">Required Skills</p>
<p style="margin:6px 0 0;color:#334155">{skills().length > 0 ? skills().join(', ') : '—'}</p>
</div>
</div> </div>
<div style="margin-top:18px"> </Show>
<p class="hint">Description</p>
<p style="margin:6px 0 0;color:#334155;white-space:pre-wrap">{job()!.description || '—'}</p> <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>
</section> </Show>
</Show>
</div> <Show when={job()}>
<section class="rounded-xl border border-gray-200 bg-white shadow-sm">
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<p class="hint">Title</p>
<p style="margin:6px 0 0;font-weight:700;color:#0f172a">{job()!.title || "—"}</p>
</div>
<div>
<p class="hint">Status</p>
<p style="margin:6px 0 0;color:#334155">{job()!.status || "—"}</p>
</div>
<div>
<p class="hint">Client</p>
<p style="margin:6px 0 0;color:#334155">{client()}</p>
</div>
<div>
<p class="hint">Experience</p>
<p style="margin:6px 0 0;color:#334155">{exp()}</p>
</div>
<div>
<p class="hint">Rate</p>
<p style="margin:6px 0 0;color:#334155">
{rateMin() != null ? `${rateMin()} - ₹${rateMax() ?? rateMin()}` : "—"}
</p>
</div>
<div>
<p class="hint">Location</p>
<p style="margin:6px 0 0;color:#334155">{job()!.location || "—"}</p>
</div>
<div>
<p class="hint">Availability</p>
<p style="margin:6px 0 0;color:#334155">{job()!.availability || "—"}</p>
</div>
<div>
<p class="hint">Duration</p>
<p style="margin:6px 0 0;color:#334155">
{duration() != null ? `${duration()} days` : "—"}
</p>
</div>
<div style="grid-column:1/-1">
<p class="hint">Required Skills</p>
<p style="margin:6px 0 0;color:#334155">
{skills().length > 0 ? skills().join(", ") : "—"}
</p>
</div>
</div>
<div style="margin-top:18px">
<p class="hint">Description</p>
<p style="margin:6px 0 0;color:#334155;white-space:pre-wrap">
{job()!.description || "—"}
</p>
</div>
</section>
</Show>
</div> </div>
</div>
); );
} }

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,95 +69,134 @@ 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">
<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 KB Article</h1> <h1 class="text-xl font-semibold text-gray-900">Edit KB Article</h1>
<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">
</Show> <p class="py-10 text-center text-sm text-slate-400">Loading article</p>
<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>
</Show>
<Show when={article() && loaded()}>
<form class="rounded-xl border border-gray-200 bg-white shadow-sm p-6" onSubmit={save}>
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div>
<label class={labelCls}>Title</label>
<input class={inputCls} value={title()} onInput={(e) => setTitle(e.currentTarget.value)} required />
</div>
<div>
<label class={labelCls}>Slug</label>
<input class={inputCls} value={slug()} onInput={(e) => setSlug(e.currentTarget.value)} />
</div>
<div>
<label class={labelCls}>Category ID</label>
<input class={inputCls} value={categoryId()} onInput={(e) => setCategoryId(e.currentTarget.value)} />
</div>
<div>
<label class={labelCls}>Status</label>
<select class={inputCls} value={status()} onChange={(e) => setStatus(e.currentTarget.value)}>
<option value="DRAFT">DRAFT</option>
<option value="PUBLISHED">PUBLISHED</option>
</select>
</div>
<div class="sm:col-span-2">
<label class={labelCls}>Content</label>
<textarea rows="16" class={inputCls} value={content()} onInput={(e) => setContent(e.currentTarget.value)} />
</div>
</div> </div>
</Show>
<Show when={error()}> <Show when={!article.loading && !article()}>
<p class="mt-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error()}</p> <div class="table-card">
</Show> <p class="py-10 text-center text-sm text-slate-400">Article not found.</p>
<div class="mt-6 flex justify-end border-t border-gray-100 pt-5">
<button class="btn-primary" type="submit" disabled={saving()}>
{saving() ? 'Saving…' : 'Save Article'}
</button>
</div> </div>
</form> </Show>
</Show>
</div> <Show when={article() && loaded()}>
<form class="rounded-xl border border-gray-200 bg-white shadow-sm p-6" onSubmit={save}>
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div>
<label class={labelCls}>Title</label>
<input
class={inputCls}
value={title()}
onInput={(e) => setTitle(e.currentTarget.value)}
required
/>
</div>
<div>
<label class={labelCls}>Slug</label>
<input
class={inputCls}
value={slug()}
onInput={(e) => setSlug(e.currentTarget.value)}
/>
</div>
<div>
<label class={labelCls}>Category ID</label>
<input
class={inputCls}
value={categoryId()}
onInput={(e) => setCategoryId(e.currentTarget.value)}
/>
</div>
<div>
<label class={labelCls}>Status</label>
<select
class={inputCls}
value={status()}
onChange={(e) => setStatus(e.currentTarget.value)}
>
<option value="DRAFT">DRAFT</option>
<option value="PUBLISHED">PUBLISHED</option>
</select>
</div>
<div class="sm:col-span-2">
<label class={labelCls}>Content</label>
<textarea
rows="16"
class={inputCls}
value={content()}
onInput={(e) => setContent(e.currentTarget.value)}
/>
</div>
</div>
<Show when={error()}>
<p class="mt-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error()}
</p>
</Show>
<div class="mt-6 flex justify-end border-t border-gray-100 pt-5">
<button class="btn-primary" type="submit" disabled={saving()}>
{saving() ? "Saving…" : "Save Article"}
</button>
</div>
</form>
</Show>
</div> </div>
</div>
); );
} }

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,317 +165,357 @@ 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);
} }
}; };
return ( return (
<div class="w-full space-y-8 pb-8"> <div class="w-full space-y-8 pb-8">
{/* Page header */}
<div class="flex items-end justify-between">
<div>
<p class="text-[12px] font-semibold uppercase tracking-widest text-[#FF5E13]">
Access Control
</p>
<h1 class="mt-1 text-[28px] font-bold leading-tight text-[#111827]">
Create Internal Role
</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">
Dashboard / Internal Role Management / Create Role
</p>
</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"
>
Back to Roles
</A>
</div>
{/* Page header */} <div class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm overflow-hidden">
<div class="flex items-end justify-between"> {/* Sub-tabs */}
<div> <div class="flex items-center gap-1 border-b border-[#F3F4F6] px-6">
<p class="text-[12px] font-semibold uppercase tracking-widest text-[#FF5E13]">Access Control</p> {(
<h1 class="mt-1 text-[28px] font-bold leading-tight text-[#111827]">Create Internal Role</h1> [
<p class="mt-1 text-[14px] text-[#6B7280]">Dashboard / Internal Role Management / Create Role</p> { key: "general", label: "General Information" },
</div> { key: "module", label: "Module Access" },
<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"> { key: "settings", label: "Role Settings" },
Back to Roles ] as const
</A> ).map((t) => (
<button
type="button"
onClick={() => setSubTab(t.key)}
class={`relative px-4 py-4 text-[13px] font-semibold transition-colors ${
subTab() === t.key ? "text-[#111827]" : "text-[#9CA3AF] hover:text-[#6B7280]"
}`}
>
{t.label}
<Show when={subTab() === t.key}>
<span class="absolute inset-x-0 bottom-0 h-[2px] rounded-t-full bg-[#FF5E13]" />
</Show>
</button>
))}
</div> </div>
<div class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm overflow-hidden"> {/* Error banner */}
<Show when={error()}>
<div class="mx-6 mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700">
{error()}
</div>
</Show>
{/* Sub-tabs */} {/* ── Tab: General Information ── */}
<div class="flex items-center gap-1 border-b border-[#F3F4F6] px-6"> <Show when={subTab() === "general"}>
{( <div class="p-6 space-y-5">
[ <div class="grid grid-cols-2 gap-5">
{ key: 'general', label: 'General Information' }, <div>
{ key: 'module', label: 'Module Access' }, <label class="block text-[13px] font-medium text-[#0D0D2A] mb-1.5">
{ key: 'settings', label: 'Role Settings' }, Role Name <span class="text-red-500">*</span>
] as const </label>
).map((t) => ( <input
<button type="text"
type="button" placeholder="Enter role name"
onClick={() => setSubTab(t.key)} value={roleName()}
class={`relative px-4 py-4 text-[13px] font-semibold transition-colors ${ onInput={(e) => setRoleName(e.currentTarget.value)}
subTab() === t.key ? 'text-[#111827]' : 'text-[#9CA3AF] hover:text-[#6B7280]' class="w-full px-3 py-2.5 text-[13px] border border-[#e5e7eb] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#0D0D2A] placeholder-[rgba(13,13,42,0.3)]"
}`} />
>
{t.label}
<Show when={subTab() === t.key}>
<span class="absolute inset-x-0 bottom-0 h-[2px] rounded-t-full bg-[#FF5E13]" />
</Show>
</button>
))}
</div>
{/* Error banner */}
<Show when={error()}>
<div class="mx-6 mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700">
{error()}
</div> </div>
</Show> <div>
<label class="block text-[13px] font-medium text-[#0D0D2A] mb-1.5">
{/* ── Tab: General Information ── */} Role Code <span class="text-red-500">*</span>
<Show when={subTab() === 'general'}> </label>
<div class="p-6 space-y-5"> <input
<div class="grid grid-cols-2 gap-5"> type="text"
<div> placeholder="Auto-generated from role name"
<label class="block text-[13px] font-medium text-[#0D0D2A] mb-1.5"> value={roleCode()}
Role Name <span class="text-red-500">*</span> readOnly
</label> class="w-full px-3 py-2.5 text-[13px] border border-[#e5e7eb] rounded-lg bg-[#F9FAFB] text-[#0D0D2A]"
<input />
type="text" <p class="mt-1 text-[11px] text-[rgba(13,13,42,0.5)]">
placeholder="Enter role name" This value is generated automatically (example: HR_MANAGER).
value={roleName()}
onInput={(e) => setRoleName(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#e5e7eb] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#0D0D2A] placeholder-[rgba(13,13,42,0.3)]"
/>
</div>
<div>
<label class="block text-[13px] font-medium text-[#0D0D2A] mb-1.5">
Role Code <span class="text-red-500">*</span>
</label>
<input
type="text"
placeholder="Auto-generated from role name"
value={roleCode()}
readOnly
class="w-full px-3 py-2.5 text-[13px] border border-[#e5e7eb] rounded-lg bg-[#F9FAFB] text-[#0D0D2A]"
/>
<p class="mt-1 text-[11px] text-[rgba(13,13,42,0.5)]">
This value is generated automatically (example: HR_MANAGER).
</p>
</div>
</div>
<div>
<label class="block text-[13px] font-medium text-[#0D0D2A] mb-1.5">
Department <span class="text-red-500">*</span>
</label>
<select
value={departmentId()}
onChange={(e) => setDepartmentId(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-[#0D0D2A]"
>
<option value="">Select department</option>
<For each={departments() ?? []}>
{(dept) => <option value={dept.id}>{dept.name}</option>}
</For>
</select>
</div>
<div>
<label class="block text-[13px] font-medium text-[#0D0D2A] mb-1.5">Description</label>
<textarea
placeholder="Enter role description"
value={description()}
onInput={(e) => setDescription(e.currentTarget.value)}
rows={4}
class="w-full px-3 py-2.5 text-[13px] border border-[#e5e7eb] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#0D0D2A] placeholder-[rgba(13,13,42,0.3)] resize-none"
/>
</div>
</div>
</Show>
{/* ── Tab: Module Access ── */}
<Show when={subTab() === 'module'}>
<div class="p-6">
<p class="text-[13px] text-[rgba(13,13,42,0.5)] mb-4">
Configure module access permissions for this role.
</p> </p>
<div class="overflow-x-auto rounded-lg border border-[#e5e7eb]"> </div>
<table class="w-full"> </div>
<thead> <div>
<tr class="border-b border-[#F3F4F6] bg-[#FAFAFA] text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]"> <label class="block text-[13px] font-medium text-[#0D0D2A] mb-1.5">
<th class="px-5 py-3.5 text-left w-[40%]">Module</th> Department <span class="text-red-500">*</span>
<th class="px-4 py-3.5 text-center">View</th> </label>
<th class="px-4 py-3.5 text-center">Create</th> <select
<th class="px-4 py-3.5 text-center">Update</th> value={departmentId()}
<th class="px-4 py-3.5 text-center">Delete</th> onChange={(e) => setDepartmentId(e.currentTarget.value)}
<th class="px-4 py-3.5 text-center"> 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-[#0D0D2A]"
<button >
type="button" <option value="">Select department</option>
onClick={() => (allSelected() ? deselectAll() : selectAll())} <For each={departments() ?? []}>
class="text-[11px] font-semibold text-[#FF5E13] hover:text-[#e04d0a] transition-colors whitespace-nowrap" {(dept) => <option value={dept.id}>{dept.name}</option>}
> </For>
{allSelected() ? 'Deselect All' : 'Select All'} </select>
</button> </div>
</th> <div>
</tr> <label class="block text-[13px] font-medium text-[#0D0D2A] mb-1.5">Description</label>
</thead> <textarea
<tbody class="divide-y divide-[#e5e7eb]"> placeholder="Enter role description"
<Show when={permissions.loading}> value={description()}
<tr> onInput={(e) => setDescription(e.currentTarget.value)}
<td colspan="6" class="px-5 py-6 text-center text-[13px] text-[rgba(13,13,42,0.4)]"> rows={4}
Loading modules class="w-full px-3 py-2.5 text-[13px] border border-[#e5e7eb] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#0D0D2A] placeholder-[rgba(13,13,42,0.3)] resize-none"
/>
</div>
</div>
</Show>
{/* ── Tab: Module Access ── */}
<Show when={subTab() === "module"}>
<div class="p-6">
<p class="text-[13px] text-[rgba(13,13,42,0.5)] mb-4">
Configure module access permissions for this role.
</p>
<div class="overflow-x-auto rounded-lg border border-[#e5e7eb]">
<table class="w-full">
<thead>
<tr class="border-b border-[#F3F4F6] bg-[#FAFAFA] text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">
<th class="px-5 py-3.5 text-left w-[40%]">Module</th>
<th class="px-4 py-3.5 text-center">View</th>
<th class="px-4 py-3.5 text-center">Create</th>
<th class="px-4 py-3.5 text-center">Update</th>
<th class="px-4 py-3.5 text-center">Delete</th>
<th class="px-4 py-3.5 text-center">
<button
type="button"
onClick={() => (allSelected() ? deselectAll() : selectAll())}
class="text-[11px] font-semibold text-[#FF5E13] hover:text-[#e04d0a] transition-colors whitespace-nowrap"
>
{allSelected() ? "Deselect All" : "Select All"}
</button>
</th>
</tr>
</thead>
<tbody class="divide-y divide-[#e5e7eb]">
<Show when={permissions.loading}>
<tr>
<td
colspan="6"
class="px-5 py-6 text-center text-[13px] text-[rgba(13,13,42,0.4)]"
>
Loading modules
</td>
</tr>
</Show>
<For each={allModules()}>
{(module) => {
const perms = () => permsByModule()[module] ?? [];
const byAction = () => {
const m: Record<string, Permission> = {};
perms().forEach((p) => {
m[p.action] = p;
});
return m;
};
const rowAllSelected = () => perms().every((p) => selectedKeys().has(p.key));
return (
<tr class="hover:bg-[#fafafa]">
<td class="px-5 py-3.5 text-[13px] font-medium text-[#0D0D2A]">
{module}
</td>
{ACTIONS.map((action) => {
const p = () => byAction()[action];
return (
<td class="px-4 py-3.5 text-center">
<Show
when={p()}
fallback={<span class="text-[#d1d5db] text-xs"></span>}
>
<input
type="checkbox"
checked={selectedKeys().has(p()!.key)}
onChange={() => toggleKey(p()!.key)}
class="h-4 w-4 accent-[#FF5E13] cursor-pointer"
/>
</Show>
</td>
);
})}
<td class="px-4 py-3.5 text-center">
<input
type="checkbox"
checked={rowAllSelected()}
onChange={() => toggleRow(module)}
class="h-4 w-4 accent-[#FF5E13] cursor-pointer"
/>
</td> </td>
</tr> </tr>
</Show> );
<For each={allModules()}> }}
{(module) => { </For>
const perms = () => permsByModule()[module] ?? []; </tbody>
const byAction = () => { </table>
const m: Record<string, Permission> = {};
perms().forEach((p) => { m[p.action] = p; });
return m;
};
const rowAllSelected = () => perms().every((p) => selectedKeys().has(p.key));
return (
<tr class="hover:bg-[#fafafa]">
<td class="px-5 py-3.5 text-[13px] font-medium text-[#0D0D2A]">
{module}
</td>
{ACTIONS.map((action) => {
const p = () => byAction()[action];
return (
<td class="px-4 py-3.5 text-center">
<Show
when={p()}
fallback={<span class="text-[#d1d5db] text-xs"></span>}
>
<input
type="checkbox"
checked={selectedKeys().has(p()!.key)}
onChange={() => toggleKey(p()!.key)}
class="h-4 w-4 accent-[#FF5E13] cursor-pointer"
/>
</Show>
</td>
);
})}
<td class="px-4 py-3.5 text-center">
<input
type="checkbox"
checked={rowAllSelected()}
onChange={() => toggleRow(module)}
class="h-4 w-4 accent-[#FF5E13] cursor-pointer"
/>
</td>
</tr>
);
}}
</For>
</tbody>
</table>
</div>
</div>
</Show>
{/* ── Tab: Role Settings ── */}
<Show when={subTab() === 'settings'}>
<div class="p-6 space-y-6">
{/* Status toggle */}
<div>
<p class="text-[13px] font-semibold text-[#0D0D2A] mb-3">Role Status</p>
<div class="flex items-center gap-2">
<button
type="button"
onClick={() => setIsActive(true)}
class={`h-[38px] rounded-xl border px-5 text-[13px] font-semibold transition-colors ${
isActive()
? 'border-[#059669] bg-[#ECFDF5] text-[#059669]'
: 'border-[#E5E7EB] bg-white text-[#6B7280] hover:bg-[#F9FAFB]'
}`}
>
Active
</button>
<button
type="button"
onClick={() => setIsActive(false)}
class={`h-[38px] rounded-xl border px-5 text-[13px] font-semibold transition-colors ${
!isActive()
? 'border-[#6B7280] bg-[#F3F4F6] text-[#374151]'
: 'border-[#E5E7EB] bg-white text-[#6B7280] hover:bg-[#F9FAFB]'
}`}
>
Inactive
</button>
</div>
</div>
{/* Setting toggles */}
<div class="space-y-3">
<SettingToggle
label="Allow Role to Approve Requests"
description="Enable this role to approve various requests"
value={canApprove()}
onChange={setCanApprove}
/>
<SettingToggle
label="Allow Role to Manage System Settings"
description="Enable this role to manage system settings and configurations"
value={canManage()}
onChange={setCanManage}
/>
</div>
</div>
</Show>
{/* Footer actions */}
<div class="flex items-center justify-end gap-3 border-t border-[#F3F4F6] px-6 py-4">
<A
href="/admin/roles"
class="h-[40px] inline-flex items-center rounded-xl border border-[#E5E7EB] bg-white px-5 text-[13px] font-semibold text-[#374151] hover:bg-[#F9FAFB] transition-colors"
>
Cancel
</A>
<button
type="button"
onClick={handleSave}
disabled={saving()}
class="h-[40px] rounded-xl bg-[#0D0D2A] px-6 text-[13px] font-semibold text-white hover:bg-[#1a1a3e] transition-colors disabled:opacity-60"
>
{saving() ? 'Creating…' : 'Create Role'}
</button>
</div> </div>
</div> </div>
</Show>
{/* ── Tab: Role Settings ── */}
<Show when={subTab() === "settings"}>
<div class="p-6 space-y-6">
{/* Status toggle */}
<div>
<p class="text-[13px] font-semibold text-[#0D0D2A] mb-3">Role Status</p>
<div class="flex items-center gap-2">
<button
type="button"
onClick={() => setIsActive(true)}
class={`h-[38px] rounded-xl border px-5 text-[13px] font-semibold transition-colors ${
isActive()
? "border-[#059669] bg-[#ECFDF5] text-[#059669]"
: "border-[#E5E7EB] bg-white text-[#6B7280] hover:bg-[#F9FAFB]"
}`}
>
Active
</button>
<button
type="button"
onClick={() => setIsActive(false)}
class={`h-[38px] rounded-xl border px-5 text-[13px] font-semibold transition-colors ${
!isActive()
? "border-[#6B7280] bg-[#F3F4F6] text-[#374151]"
: "border-[#E5E7EB] bg-white text-[#6B7280] hover:bg-[#F9FAFB]"
}`}
>
Inactive
</button>
</div>
</div>
{/* Setting toggles */}
<div class="space-y-3">
<SettingToggle
label="Allow Role to Approve Requests"
description="Enable this role to approve various requests"
value={canApprove()}
onChange={setCanApprove}
/>
<SettingToggle
label="Allow Role to Manage System Settings"
description="Enable this role to manage system settings and configurations"
value={canManage()}
onChange={setCanManage}
/>
</div>
</div>
</Show>
{/* Footer actions */}
<div class="flex items-center justify-end gap-3 border-t border-[#F3F4F6] px-6 py-4">
<A
href="/admin/roles"
class="h-[40px] inline-flex items-center rounded-xl border border-[#E5E7EB] bg-white px-5 text-[13px] font-semibold text-[#374151] hover:bg-[#F9FAFB] transition-colors"
>
Cancel
</A>
<button
type="button"
onClick={handleSave}
disabled={saving()}
class="h-[40px] rounded-xl bg-[#0D0D2A] px-6 text-[13px] font-semibold text-white hover:bg-[#1a1a3e] transition-colors disabled:opacity-60"
>
{saving() ? "Creating…" : "Create Role"}
</button>
</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>

File diff suppressed because it is too large Load diff

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,132 +52,178 @@ 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);
} }
}; };
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">
<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">
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error()}</div> {error()}
</Show>
<Show when={user.loading}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm"><p class="notice">Loading user...</p></div>
</Show>
<Show when={!user.loading && !user()}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm"><p class="notice">User not found.</p></div>
</Show>
<Show when={user()}>
<section class="rounded-xl border border-gray-200 bg-white shadow-sm p-6 max-w-3xl">
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Full Name</label>
<input class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]" value={name()} onInput={(e) => setName(e.currentTarget.value)} />
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Email</label>
<input type="email" class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]" value={email()} onInput={(e) => setEmail(e.currentTarget.value)} />
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Role</label>
<select class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]" value={roleId()} onChange={(e) => setRoleId(e.currentTarget.value)}>
<option value="">Select role</option>
<Show when={!roles.loading}>
{roles()?.map((r) => (
<option value={r.id}>{r.name}</option>
))}
</Show>
</select>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Status</label>
<select class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]" value={status()} onChange={(e) => setStatus(e.currentTarget.value as 'ACTIVE' | 'INACTIVE' | 'PENDING')}>
<option value="ACTIVE">Active</option>
<option value="PENDING">Pending</option>
<option value="INACTIVE">Inactive</option>
</select>
</div>
</div> </div>
</Show>
<div class="mt-6 flex justify-end gap-3 border-t border-gray-100 pt-5"> <Show when={user.loading}>
<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> <div class="rounded-xl border border-gray-200 bg-white shadow-sm">
<button class="btn-primary" type="button" onClick={save} disabled={submitting()}> <p class="notice">Loading user...</p>
{submitting() ? 'Saving…' : 'Save Changes'}
</button>
</div> </div>
</section> </Show>
</Show>
</div> <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>
</Show>
<Show when={user()}>
<section class="rounded-xl border border-gray-200 bg-white shadow-sm p-6 max-w-3xl">
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Full Name</label>
<input
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]"
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Email</label>
<input
type="email"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Role</label>
<select
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]"
value={roleId()}
onChange={(e) => setRoleId(e.currentTarget.value)}
>
<option value="">Select role</option>
<Show when={!roles.loading}>
{roles()?.map((r) => (
<option value={r.id}>{r.name}</option>
))}
</Show>
</select>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Status</label>
<select
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]"
value={status()}
onChange={(e) =>
setStatus(e.currentTarget.value as "ACTIVE" | "INACTIVE" | "PENDING")
}
>
<option value="ACTIVE">Active</option>
<option value="PENDING">Pending</option>
<option value="INACTIVE">Inactive</option>
</select>
</div>
</div>
<div class="mt-6 flex justify-end gap-3 border-t border-gray-100 pt-5">
<button
class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
type="button"
onClick={() => navigate("/admin/users")}
>
Cancel
</button>
<button class="btn-primary" type="button" onClick={save} disabled={submitting()}>
{submitting() ? "Saving…" : "Save Changes"}
</button>
</div>
</section>
</Show>
</div> </div>
</div>
); );
} }