fix: update admin forms to match DB schema

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

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

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

@ -0,0 +1 @@
7741

View file

@ -1,42 +1,42 @@
import { createSignal, createMemo, onMount, Show, For } from 'solid-js';
import { createSignal, createMemo, onMount, Show, For } from "solid-js";
const API = '';
const API = "";
function getToken(): string {
return typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
return typeof sessionStorage !== "undefined"
? sessionStorage.getItem("nxtgauge_admin_access_token") || ""
: "";
}
function authHeaders(): Record<string, string> {
const token = getToken();
return {
Accept: 'application/json',
'Content-Type': 'application/json',
Accept: "application/json",
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
const ROLE_OPTIONS = [
'company',
'customer',
'job_seeker',
'photographer',
'video_editor',
'graphic_designer',
'social_media_manager',
'fitness_trainer',
'catering_services',
'makeup_artist',
'tutor',
'developer',
"company",
"customer",
"job_seeker",
"photographer",
"video_editor",
"graphic_designer",
"social_media_manager",
"fitness_trainer",
"catering_services",
"makeup_artist",
"tutor",
"developer",
];
type Coupon = {
id: string;
code: string;
title: string;
type: 'PERCENT' | 'FIXED';
type: "PERCENT" | "FIXED";
value: number;
min_order_amount: number;
used_count: number;
@ -45,44 +45,50 @@ type Coupon = {
role_keys: string[];
};
const defaultForm = () => ({
id: '',
code: '',
title: '',
type: 'PERCENT' as 'PERCENT' | 'FIXED',
id: "",
code: "",
title: "",
type: "PERCENT" as "PERCENT" | "FIXED",
value: 10,
min_order_amount: 0,
max_uses: '',
role_keys: ['company', 'customer'] as string[],
max_uses: "",
applies_to: "ALL" as "ALL" | "ROLE",
role_keys: ["company", "customer"] as string[],
});
export default function CouponPage() {
const [coupons, setCoupons] = createSignal<Coupon[]>([]);
const [loading, setLoading] = createSignal(true);
const [loadError, setLoadError] = createSignal('');
const [activeTab, setActiveTab] = createSignal<'list' | 'create'>('list');
const [loadError, setLoadError] = createSignal("");
const [activeTab, setActiveTab] = createSignal<"list" | "create">("list");
const [form, setForm] = createSignal(defaultForm());
const [saving, setSaving] = createSignal(false);
const [toggling, setToggling] = createSignal('');
const [formError, setFormError] = createSignal('');
const [toggling, setToggling] = createSignal("");
const [formError, setFormError] = createSignal("");
// Filters
const [search, setSearch] = createSignal('');
const [statusFilter, setStatusFilter] = createSignal('all');
const [sortBy, setSortBy] = createSignal<'newest' | 'oldest' | 'code_asc' | 'code_desc'>('newest');
const [search, setSearch] = createSignal("");
const [statusFilter, setStatusFilter] = createSignal("all");
const [sortBy, setSortBy] = createSignal<"newest" | "oldest" | "code_asc" | "code_desc">(
"newest"
);
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
const load = async () => {
setLoading(true); setLoadError('');
setLoading(true);
setLoadError("");
try {
const res = await fetch(`${API}/api/admin/coupons`, { headers: authHeaders(), credentials: 'include' });
const res = await fetch(`${API}/api/admin/coupons`, {
headers: authHeaders(),
credentials: "include",
});
if (!res.ok) throw new Error(`Request failed (${res.status})`);
const data = await res.json();
setCoupons(Array.isArray(data) ? data : (data.coupons ?? []));
} catch (err: any) {
setLoadError(err.message || 'Could not load coupons.');
setLoadError(err.message || "Could not load coupons.");
setCoupons([]);
} finally {
setLoading(false);
@ -94,59 +100,62 @@ export default function CouponPage() {
const filteredCoupons = createMemo(() => {
let r = coupons();
const q = search().toLowerCase();
if (q) r = r.filter((c) => c.code.toLowerCase().includes(q) || (c.title || '').toLowerCase().includes(q));
if (statusFilter() === 'active') r = r.filter((c) => c.is_active);
if (statusFilter() === 'inactive') r = r.filter((c) => !c.is_active);
if (q)
r = r.filter(
(c) => c.code.toLowerCase().includes(q) || (c.title || "").toLowerCase().includes(q)
);
if (statusFilter() === "active") r = r.filter((c) => c.is_active);
if (statusFilter() === "inactive") r = r.filter((c) => !c.is_active);
const sorted = [...r];
sorted.sort((a, b) => {
if (sortBy() === 'oldest') return String(a.id || '').localeCompare(String(b.id || ''));
if (sortBy() === 'code_asc') return String(a.code || '').localeCompare(String(b.code || ''));
if (sortBy() === 'code_desc') return String(b.code || '').localeCompare(String(a.code || ''));
return String(b.id || '').localeCompare(String(a.id || ''));
if (sortBy() === "oldest") return String(a.id || "").localeCompare(String(b.id || ""));
if (sortBy() === "code_asc") return String(a.code || "").localeCompare(String(b.code || ""));
if (sortBy() === "code_desc") return String(b.code || "").localeCompare(String(a.code || ""));
return String(b.id || "").localeCompare(String(a.id || ""));
});
r = sorted;
return r;
});
const exportCsv = () => {
const headers = ['Code', 'Title', 'Type', 'Value', 'Max Uses', 'Status'];
const headers = ["Code", "Title", "Type", "Value", "Max Uses", "Status"];
const rows = filteredCoupons().map((item) => [
item.code,
item.title || '',
item.title || "",
item.type,
item.type === 'PERCENT' ? `${item.value}%` : `${item.value}`,
item.usage_limit != null ? String(item.usage_limit) : '—',
item.is_active ? 'Active' : 'Inactive',
item.type === "PERCENT" ? `${item.value}%` : `${item.value}`,
item.usage_limit != null ? String(item.usage_limit) : "—",
item.is_active ? "Active" : "Inactive",
]);
const csv = [headers, ...rows]
.map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
.map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","))
.join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const link = document.createElement("a");
link.href = url;
link.download = 'coupon-management.csv';
link.download = "coupon-management.csv";
link.click();
URL.revokeObjectURL(url);
};
const resetForm = () => {
setForm(defaultForm());
setFormError('');
setFormError("");
};
const startEdit = (coupon: Coupon) => {
setForm({
id: coupon.id,
code: coupon.code,
title: coupon.title || '',
title: coupon.title || "",
type: coupon.type,
value: coupon.value,
min_order_amount: coupon.min_order_amount || 0,
max_uses: coupon.usage_limit != null ? String(coupon.usage_limit) : '',
max_uses: coupon.usage_limit != null ? String(coupon.usage_limit) : "",
role_keys: Array.isArray(coupon.role_keys) ? coupon.role_keys : [],
});
setActiveTab('create');
setActiveTab("create");
};
const toggleRole = (role: string) => {
@ -162,32 +171,33 @@ export default function CouponPage() {
e.preventDefault();
try {
setSaving(true);
setFormError('');
setFormError("");
const f = form();
const body: Record<string, unknown> = {
code: f.code.toUpperCase(),
title: f.title,
type: f.type,
value: Number(f.value),
discount_type: f.type,
discount_value: Number(f.value),
applies_to: f.applies_to,
min_order_amount: Number(f.min_order_amount),
role_keys: f.role_keys,
};
if (f.max_uses) body.max_uses = Number(f.max_uses);
const url = f.id ? `${API}/api/admin/coupons/${f.id}` : `${API}/api/admin/coupons`;
const method = f.id ? 'PATCH' : 'POST';
const method = f.id ? "PATCH" : "POST";
const res = await fetch(url, {
method,
headers: authHeaders(),
credentials: 'include',
credentials: "include",
body: JSON.stringify(body),
});
if (!res.ok) throw new Error('Failed to save coupon');
if (!res.ok) throw new Error("Failed to save coupon");
resetForm();
await load();
setActiveTab('list');
setActiveTab("list");
} catch (err: unknown) {
setFormError(err instanceof Error ? err.message : 'Failed to save');
setFormError(err instanceof Error ? err.message : "Failed to save");
} finally {
setSaving(false);
}
@ -197,48 +207,59 @@ export default function CouponPage() {
try {
setToggling(coupon.id);
const res = await fetch(`${API}/api/admin/coupons/${coupon.id}`, {
method: 'PATCH',
method: "PATCH",
headers: authHeaders(),
credentials: 'include',
credentials: "include",
body: JSON.stringify({ is_active: !coupon.is_active }),
});
if (!res.ok) throw new Error('Failed to toggle');
if (!res.ok) throw new Error("Failed to toggle");
await load();
} catch {
// ignore
} finally {
setToggling('');
setToggling("");
}
};
return (
<div class="w-full space-y-6 pb-8">
<div style="margin-bottom:1.5rem">
<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>
</div>
<div class="w-full space-y-6 pb-8">
<div style="margin-bottom:1.5rem">
<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>
</div>
{/* Tabs */}
<div class="bg-white border-b border-gray-200 px-6 flex items-center gap-8 sticky top-0 z-10">
<button
type="button"
class={activeTab() === 'list' ? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium' : 'py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors'}
onClick={() => setActiveTab('list')}
>
Coupons
</button>
<button
type="button"
class={activeTab() === 'create' ? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium' : 'py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors'}
onClick={() => { resetForm(); setActiveTab('create'); }}
>
{form().id ? 'Edit Coupon' : 'Create Coupon'}
</button>
</div>
{/* Tabs */}
<div class="bg-white border-b border-gray-200 px-6 flex items-center gap-8 sticky top-0 z-10">
<button
type="button"
class={
activeTab() === "list"
? "py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium"
: "py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors"
}
onClick={() => setActiveTab("list")}
>
Coupons
</button>
<button
type="button"
class={
activeTab() === "create"
? "py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium"
: "py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors"
}
onClick={() => {
resetForm();
setActiveTab("create");
}}
>
{form().id ? "Edit Coupon" : "Create Coupon"}
</button>
</div>
<div>
<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>
<Show when={activeTab() === "list"}>
<div style="position:relative;margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:visible;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6;position:relative;z-index:20;margin-bottom:0">
<input
type="text"
@ -251,22 +272,51 @@ export default function CouponPage() {
<div style="position:relative;">
<button
type="button"
onClick={() => { setSortMenuOpen((v) => !v); setFilterMenuOpen(false); }}
onClick={() => {
setSortMenuOpen((v) => !v);
setFilterMenuOpen(false);
}}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 4v13"/><path d="m3 13 4 4 4-4"/><path d="M17 20V7"/><path d="m21 11-4-4-4 4"/></svg>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M7 4v13" />
<path d="m3 13 4 4 4-4" />
<path d="M17 20V7" />
<path d="m21 11-4-4-4 4" />
</svg>
Sort
</button>
<Show when={sortMenuOpen()}>
<div style="position:absolute;right:0;top:38px;z-index:40;min-width:180px;border:1px solid #E5E7EB;border-radius:12px;background:#fff;box-shadow:0 12px 24px rgba(2,6,23,0.12);padding:8px">
<For each={[
{ key: 'newest', label: 'Newest First' },
{ key: 'oldest', label: 'Oldest First' },
{ key: 'code_asc', label: 'Code A-Z' },
{ key: 'code_desc', label: 'Code Z-A' },
] as { key: 'newest' | 'oldest' | 'code_asc' | 'code_desc'; label: string }[]}>
<For
each={
[
{ key: "newest", label: "Newest First" },
{ key: "oldest", label: "Oldest First" },
{ key: "code_asc", label: "Code A-Z" },
{ key: "code_desc", label: "Code Z-A" },
] as {
key: "newest" | "oldest" | "code_asc" | "code_desc";
label: string;
}[]
}
>
{(item) => (
<button type="button" onClick={() => { setSortBy(item.key); setSortMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === item.key ? '#FF5E13' : '#374151'};background:${sortBy() === item.key ? '#FFF1EB' : 'transparent'}`}>
<button
type="button"
onClick={() => {
setSortBy(item.key);
setSortMenuOpen(false);
}}
style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === item.key ? "#FF5E13" : "#374151"};background:${sortBy() === item.key ? "#FFF1EB" : "transparent"}`}
>
{item.label}
</button>
)}
@ -278,21 +328,44 @@ export default function CouponPage() {
<div style="position:relative;">
<button
type="button"
onClick={() => { setFilterMenuOpen((v) => !v); setSortMenuOpen(false); }}
onClick={() => {
setFilterMenuOpen((v) => !v);
setSortMenuOpen(false);
}}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h18M6 12h12M10 19h4"/></svg>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 5h18M6 12h12M10 19h4" />
</svg>
Filters
</button>
<Show when={filterMenuOpen()}>
<div style="position:absolute;right:0;top:38px;z-index:40;min-width:180px;border:1px solid #E5E7EB;border-radius:12px;background:#fff;box-shadow:0 12px 24px rgba(2,6,23,0.12);padding:8px">
<For each={[
{ key: 'all', label: 'All Status' },
{ key: 'active', label: 'Active' },
{ key: 'inactive', label: 'Inactive' },
] as { key: string; label: string }[]}>
<For
each={
[
{ key: "all", label: "All Status" },
{ key: "active", label: "Active" },
{ key: "inactive", label: "Inactive" },
] as { key: string; label: string }[]
}
>
{(item) => (
<button type="button" onClick={() => { setStatusFilter(item.key); setFilterMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === item.key ? '#FF5E13' : '#374151'};background:${statusFilter() === item.key ? '#FFF1EB' : 'transparent'}`}>
<button
type="button"
onClick={() => {
setStatusFilter(item.key);
setFilterMenuOpen(false);
}}
style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === item.key ? "#FF5E13" : "#374151"};background:${statusFilter() === item.key ? "#FFF1EB" : "transparent"}`}
>
{item.label}
</button>
)}
@ -301,13 +374,30 @@ export default function CouponPage() {
</Show>
</div>
<button type="button" onClick={exportCsv} style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:600;color:white;border:none;cursor:pointer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<button
type="button"
onClick={exportCsv}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:600;color:white;border:none;cursor:pointer"
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Export
</button>
</div>
<Show when={loadError()}>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{loadError()}</div>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{loadError()}
</div>
</Show>
<div class="table-card">
<div class="overflow-x-auto">
@ -325,35 +415,62 @@ export default function CouponPage() {
</thead>
<tbody>
<Show when={loading()}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
<tr>
<td colspan="7" style="text-align:center;padding:32px;color:#64748b">
Loading...
</td>
</tr>
</Show>
<Show when={!loading() && filteredCoupons().length === 0}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">No coupons found.</td></tr>
<tr>
<td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">
No coupons found.
</td>
</tr>
</Show>
<Show when={!loading() && filteredCoupons().length > 0}>
<For each={filteredCoupons()}>
{(item) => (
<tr class="hover:bg-slate-50">
<td class="font-semibold text-slate-900" style="font-family:monospace">{item.code}</td>
<td class="text-slate-500">{item.title || '—'}</td>
<td class="font-semibold text-slate-900" style="font-family:monospace">
{item.code}
</td>
<td class="text-slate-500">{item.title || "—"}</td>
<td class="text-slate-500">{item.type}</td>
<td class="text-slate-500">{item.type === 'PERCENT' ? `${item.value}%` : `${item.value}`}</td>
<td class="text-slate-500">{item.usage_limit != null ? item.usage_limit : '—'}</td>
<td class="text-slate-500">
{item.type === "PERCENT" ? `${item.value}%` : `${item.value}`}
</td>
<td class="text-slate-500">
{item.usage_limit != null ? item.usage_limit : "—"}
</td>
<td>
<span style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${item.is_active ? '#FFD8C2' : '#D1D5DB'};background:${item.is_active ? '#FFF1EB' : '#F3F4F6'};color:${item.is_active ? '#FF5E13' : '#4B5563'};padding:2px 10px;font-size:12px;font-weight:500`}>
<span style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${item.is_active ? '#FF5E13' : '#9CA3AF'};margin-right:5px;flex-shrink:0`} />
{item.is_active ? 'Active' : 'Inactive'}
<span
style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${item.is_active ? "#FFD8C2" : "#D1D5DB"};background:${item.is_active ? "#FFF1EB" : "#F3F4F6"};color:${item.is_active ? "#FF5E13" : "#4B5563"};padding:2px 10px;font-size:12px;font-weight:500`}
>
<span
style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${item.is_active ? "#FF5E13" : "#9CA3AF"};margin-right:5px;flex-shrink:0`}
/>
{item.is_active ? "Active" : "Inactive"}
</span>
</td>
<td>
<div class="flex items-center justify-end gap-1">
<button class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" onClick={() => startEdit(item)}>Edit</button>
<button
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
onClick={() => startEdit(item)}
>
Edit
</button>
<button
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
disabled={toggling() === item.id}
onClick={() => handleToggle(item)}
>
{toggling() === item.id ? '...' : (item.is_active ? 'Disable' : 'Enable')}
{toggling() === item.id
? "..."
: item.is_active
? "Disable"
: "Enable"}
</button>
</div>
</td>
@ -370,112 +487,146 @@ export default function CouponPage() {
</div>
</Show>
</div>
</div>
</Show>
</div>
</Show>
<Show when={activeTab() === 'create'}>
<section class="rounded-xl border border-gray-200 bg-white shadow-sm" style="max-width:520px">
<h2 style="margin:0 0 20px;font-size:16px;font-weight:700">{form().id ? 'Edit Coupon' : 'Create Coupon'}</h2>
<Show when={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">
<Show when={activeTab() === "create"}>
<section
class="rounded-xl border border-gray-200 bg-white shadow-sm"
style="max-width:520px"
>
<h2 style="margin:0 0 20px;font-size:16px;font-weight:700">
{form().id ? "Edit Coupon" : "Create Coupon"}
</h2>
<Show when={formError()}>
<div
class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
style="margin-bottom:12px"
>
{formError()}
</div>
</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">
<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
type="text"
value={form().code}
onInput={(e) => setForm({ ...form(), code: e.currentTarget.value.toUpperCase() })}
type="number"
value={form().value}
onInput={(e) => setForm({ ...form(), value: Number(e.currentTarget.value) })}
required
placeholder="e.g. SAVE10"
style="text-transform:uppercase"
min="1"
/>
</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 class="field">
<label>Title</label>
<label>Max Uses (blank = unlimited)</label>
<input
type="text"
value={form().title}
onInput={(e) => setForm({ ...form(), title: e.currentTarget.value })}
required
placeholder="e.g. 10% off for companies"
type="number"
value={form().max_uses}
onInput={(e) => setForm({ ...form(), max_uses: e.currentTarget.value })}
min="1"
placeholder="Unlimited"
/>
</div>
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="field">
<label>Type</label>
<select
value={form().type}
onChange={(e) => setForm({ ...form(), type: e.currentTarget.value as 'PERCENT' | 'FIXED' })}
>
<option value="PERCENT">Percent (%)</option>
<option value="FIXED">Fixed ()</option>
</select>
</div>
<div class="field">
<label>Value</label>
<input
type="number"
value={form().value}
onInput={(e) => setForm({ ...form(), value: Number(e.currentTarget.value) })}
required
min="1"
/>
</div>
<div class="field">
<label>Applies To</label>
<select
value={form().applies_to}
onChange={(e) =>
setForm({ ...form(), applies_to: e.currentTarget.value as "ALL" | "ROLE" })
}
>
<option value="ALL">All</option>
<option value="ROLE">Specific Roles</option>
</select>
</div>
<div 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 class="field">
<label>Max Uses (blank = unlimited)</label>
<input
type="number"
value={form().max_uses}
onInput={(e) => setForm({ ...form(), max_uses: e.currentTarget.value })}
min="1"
placeholder="Unlimited"
/>
</div>
</div>
<div>
<p style="font-size:13px;font-weight:600;margin:0 0 8px;color:#1e293b">
Applicable Roles
</p>
<div style="display:flex;flex-wrap:wrap;gap:8px">
<For each={ROLE_OPTIONS}>
{(role) => {
const active = () => form().role_keys.includes(role);
return (
<button
type="button"
onClick={() => toggleRole(role)}
style={`border-radius:999px;padding:4px 14px;font-size:13px;cursor:pointer;border:1px solid ${active() ? "#fdba74" : "#cbd5e1"};background:${active() ? "#fff7ed" : "#fff"};color:${active() ? "#c2410c" : "#475569"}`}
>
{role}
</button>
);
}}
</For>
</div>
<div>
<p style="font-size:13px;font-weight:600;margin:0 0 8px;color:#1e293b">Applicable Roles</p>
<div style="display:flex;flex-wrap:wrap;gap:8px">
<For each={ROLE_OPTIONS}>
{(role) => {
const active = () => form().role_keys.includes(role);
return (
<button
type="button"
onClick={() => toggleRole(role)}
style={`border-radius:999px;padding:4px 14px;font-size:13px;cursor:pointer;border:1px solid ${active() ? '#fdba74' : '#cbd5e1'};background:${active() ? '#fff7ed' : '#fff'};color:${active() ? '#c2410c' : '#475569'}`}
>
{role}
</button>
);
}}
</For>
</div>
</div>
<div class="actions">
<button class="btn-primary" type="submit" disabled={saving()}>
{saving() ? 'Saving...' : (form().id ? 'Update Coupon' : 'Save Coupon')}
</div>
<div class="actions">
<button class="btn-primary" type="submit" disabled={saving()}>
{saving() ? "Saving..." : form().id ? "Update Coupon" : "Save Coupon"}
</button>
<Show when={form().id}>
<button
type="button"
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
onClick={resetForm}
>
Cancel Edit
</button>
<Show when={form().id}>
<button type="button" class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" onClick={resetForm}>Cancel Edit</button>
</Show>
</div>
</form>
</section>
</Show>
</div>
</Show>
</div>
</form>
</section>
</Show>
</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 { createResource, createSignal, For, onMount, Show } from 'solid-js';
import { A, useNavigate } from "@solidjs/router";
import { createResource, createSignal, For, onMount, Show } from "solid-js";
const API = '';
const API = "";
type Role = { id: string; name: string };
type Dept = { id: string; name: string };
type Desig = { id: string; name: string };
function parseEmployeeCodeNumber(code: string): number | null {
const normalized = String(code || '').trim().toUpperCase();
const normalized = String(code || "")
.trim()
.toUpperCase();
if (!normalized) return null;
const explicit = normalized.match(/^EMP[-_]?0*(\d+)$/);
if (explicit) return Number(explicit[1]);
@ -18,59 +20,68 @@ function parseEmployeeCodeNumber(code: string): number | null {
}
function formatEmployeeCode(value: number): string {
return `EMP-${String(Math.max(1, value)).padStart(4, '0')}`;
return `EMP-${String(Math.max(1, value)).padStart(4, "0")}`;
}
async function fetchRoles(): Promise<Role[]> {
try {
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const accessToken =
typeof sessionStorage !== "undefined"
? (sessionStorage.getItem("nxtgauge_admin_access_token") || "").trim()
: "";
const res = await fetch(`${API}/api/admin/roles?audience=INTERNAL&per_page=100`, {
headers: {
Accept: 'application/json',
Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
credentials: "include",
});
if (!res.ok) throw new Error();
const data = await res.json();
return Array.isArray(data) ? data : (data.roles ?? []);
} catch { return []; }
} catch {
return [];
}
}
async function fetchDepts(): Promise<Dept[]> {
try {
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const accessToken =
typeof sessionStorage !== "undefined"
? (sessionStorage.getItem("nxtgauge_admin_access_token") || "").trim()
: "";
const res = await fetch(`${API}/api/admin/departments?per_page=100`, {
headers: {
Accept: 'application/json',
Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
credentials: "include",
});
if (!res.ok) throw new Error();
const data = await res.json();
return Array.isArray(data) ? data : (data.departments ?? []);
} catch { return []; }
} catch {
return [];
}
}
async function fetchDesigs(): Promise<Desig[]> {
try {
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const accessToken =
typeof sessionStorage !== "undefined"
? (sessionStorage.getItem("nxtgauge_admin_access_token") || "").trim()
: "";
const res = await fetch(`${API}/api/admin/designations?per_page=100`, {
headers: {
Accept: 'application/json',
Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
credentials: "include",
});
if (!res.ok) throw new Error();
const data = await res.json();
return Array.isArray(data) ? data : (data.designations ?? []);
} catch { return []; }
} catch {
return [];
}
}
export default function CreateEmployeePage() {
@ -79,33 +90,37 @@ export default function CreateEmployeePage() {
const [depts] = createResource(fetchDepts);
const [desigs] = createResource(fetchDesigs);
const [fullName, setFullName] = createSignal('');
const [email, setEmail] = createSignal('');
const [employeeCode, setEmployeeCode] = createSignal('');
const [fullName, setFullName] = createSignal("");
const [email, setEmail] = createSignal("");
const [employeeCode, setEmployeeCode] = createSignal("");
const [createLoginCreds, setCreateLoginCreds] = createSignal(true);
const [loginPassword, setLoginPassword] = createSignal('');
const [confirmLoginPassword, setConfirmLoginPassword] = createSignal('');
const [roleId, setRoleId] = createSignal('');
const [deptId, setDeptId] = createSignal('');
const [desigId, setDesigId] = createSignal('');
const [loginPassword, setLoginPassword] = createSignal("");
const [confirmLoginPassword, setConfirmLoginPassword] = createSignal("");
const [roleId, setRoleId] = createSignal("");
const [deptId, setDeptId] = createSignal("");
const [desigId, setDesigId] = createSignal("");
const [saving, setSaving] = createSignal(false);
const [generatingCode, setGeneratingCode] = createSignal(false);
const [error, setError] = createSignal('');
const [error, setError] = createSignal("");
const fetchNextEmployeeCode = async (): Promise<string> => {
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const accessToken =
typeof sessionStorage !== "undefined"
? (sessionStorage.getItem("nxtgauge_admin_access_token") || "").trim()
: "";
let page = 1;
let maxNum = 0;
while (page <= 100) {
const res = await fetch(`${API}/api/admin/employees?page=${page}&per_page=100&sort=joined_desc`, {
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
}).catch(() => null);
const res = await fetch(
`${API}/api/admin/employees?page=${page}&per_page=100&sort=joined_desc`,
{
headers: {
Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: "include",
}
).catch(() => null);
if (!res?.ok) break;
const payload = await res.json().catch(() => null);
const list: any[] = Array.isArray(payload)
@ -117,7 +132,7 @@ export default function CreateEmployeePage() {
: [];
if (!Array.isArray(list) || list.length === 0) break;
for (const item of list) {
const raw = String(item?.employee_id ?? item?.employeeId ?? item?.employee_code ?? '');
const raw = String(item?.employee_id ?? item?.employeeId ?? item?.employee_code ?? "");
const parsed = parseEmployeeCodeNumber(raw);
if (parsed && parsed > maxNum) maxNum = parsed;
}
@ -133,7 +148,7 @@ export default function CreateEmployeePage() {
try {
setEmployeeCode(await fetchNextEmployeeCode());
} catch {
setEmployeeCode('');
setEmployeeCode("");
} finally {
setGeneratingCode(false);
}
@ -142,238 +157,276 @@ export default function CreateEmployeePage() {
const handleSave = async (e: Event) => {
e.preventDefault();
if (!fullName().trim()) { setError('Full name is required'); return; }
if (!email().trim()) { setError('Email is required'); return; }
if (!roleId()) { setError('Internal role is required'); return; }
if (!deptId()) { setError('Department is required'); return; }
if (!desigId()) { setError('Designation is required'); return; }
if (createLoginCreds()) {
if (loginPassword().trim().length < 8) { setError('Password must be at least 8 characters'); return; }
if (loginPassword().trim() !== confirmLoginPassword().trim()) { setError('Password and confirm password do not match'); return; }
if (!fullName().trim()) {
setError("Full name is required");
return;
}
setError(''); setSaving(true);
if (!email().trim()) {
setError("Email is required");
return;
}
if (!roleId()) {
setError("Internal role is required");
return;
}
if (!deptId()) {
setError("Department is required");
return;
}
if (!desigId()) {
setError("Designation is required");
return;
}
if (createLoginCreds()) {
if (loginPassword().trim().length < 8) {
setError("Password must be at least 8 characters");
return;
}
if (loginPassword().trim() !== confirmLoginPassword().trim()) {
setError("Password and confirm password do not match");
return;
}
}
setError("");
setSaving(true);
try {
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const accessToken =
typeof sessionStorage !== "undefined"
? (sessionStorage.getItem("nxtgauge_admin_access_token") || "").trim()
: "";
const res = await fetch(`${API}/api/admin/employees/provision`, {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
"Content-Type": "application/json",
Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
method: 'POST',
credentials: 'include',
method: "POST",
credentials: "include",
body: JSON.stringify({
email: email().trim(),
full_name: fullName().trim(),
role_id: roleId(),
department_id: deptId(),
designation_id: desigId(),
employee_code: employeeCode() || undefined,
first_name: fullName().trim().split(" ")[0] || "",
last_name: fullName().trim().split(" ").slice(1).join(" ") || "",
role_code: roleId(),
department_id: deptId().trim(),
designation_id: desigId().trim(),
employee_code: employeeCode().trim() || undefined,
generate_login: createLoginCreds(),
password: createLoginCreds() ? loginPassword().trim() : undefined,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error((body as any).error || (body as any).message || 'Failed to create employee');
throw new Error(
(body as any).error || (body as any).message || "Failed to create employee"
);
}
navigate('/admin/employees');
navigate("/admin/employees");
} catch (err: any) {
setError(err.message || 'Failed to create employee');
setError(err.message || "Failed to create employee");
} finally {
setSaving(false);
}
};
return (
<div class="w-full space-y-8 pb-8">
{/* Page header */}
<div class="flex items-end justify-between">
<div>
<p class="text-[12px] font-semibold uppercase tracking-widest text-[#FF5E13]">Internal Team</p>
<h1 class="mt-1 text-[28px] font-bold leading-tight text-[#111827]">Add Employee</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">Dashboard / Employee Management / Add Employee</p>
</div>
<A
href="/admin/employees"
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 class="w-full space-y-8 pb-8">
{/* Page header */}
<div class="flex items-end justify-between">
<div>
<p class="text-[12px] font-semibold uppercase tracking-widest text-[#FF5E13]">
Internal Team
</p>
<h1 class="mt-1 text-[28px] font-bold leading-tight text-[#111827]">Add Employee</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">
Dashboard / Employee Management / Add Employee
</p>
</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>
<A
href="/admin/employees"
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>
{/* 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 { createMemo, createResource, Show } from 'solid-js';
import { A, useParams } from "@solidjs/router";
import { createMemo, createResource, Show } from "solid-js";
const API = '';
const API = "";
type Job = {
id: string;
@ -28,7 +28,7 @@ type Job = {
async function fetchJob(id: string): Promise<Job | null> {
try {
const res = await fetch(`${API}/api/jobs/${id}`);
const res = await fetch(`${API}/api/admin/jobs/${id}`);
if (!res.ok) return null;
const data = await res.json();
return data.job || data;
@ -42,78 +42,99 @@ export default function JobDetailPage() {
const [job] = createResource(() => params.id, fetchJob);
const skills = createMemo(() => job()?.requiredSkills || job()?.required_skills || []);
const client = createMemo(() => job()?.clientName || job()?.client_name || job()?.companyName || job()?.company_name || '—');
const exp = createMemo(() => job()?.experienceLevel || job()?.experience_level || '—');
const client = createMemo(
() =>
job()?.clientName || job()?.client_name || job()?.companyName || job()?.company_name || "—"
);
const exp = createMemo(() => job()?.experienceLevel || job()?.experience_level || "—");
const rateMin = createMemo(() => job()?.hourlyRateMin ?? job()?.hourly_rate_min);
const rateMax = createMemo(() => job()?.hourlyRateMax ?? job()?.hourly_rate_max);
const duration = createMemo(() => job()?.durationDays ?? job()?.duration_days);
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>
<h1 class="text-xl font-semibold text-gray-900">Job Management</h1>
<p class="text-sm text-gray-500 mt-0.5">Review one live backend job in the same detail-first style as other admin modules.</p>
<p class="text-sm text-gray-500 mt-0.5">
Review one live backend job in the same detail-first style as other admin modules.
</p>
</div>
<A class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href="/admin/jobs">Back to Jobs</A>
<A
class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
href="/admin/jobs"
>
Back to Jobs
</A>
</div>
<div class="p-6 flex-1">
<Show when={job.loading}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm"><p class="notice">Loading job...</p></div>
</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>
<Show when={job.loading}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm">
<p class="notice">Loading job...</p>
</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>
</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>
</section>
</Show>
</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 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>
);
}

View file

@ -1,12 +1,12 @@
import { A, useNavigate, useParams } from '@solidjs/router';
import { createEffect, createResource, createSignal, Show } from 'solid-js';
import { A, useNavigate, useParams } from "@solidjs/router";
import { createEffect, createResource, createSignal, Show } from "solid-js";
const API = '';
const API = "";
function getToken(): string {
return typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
return typeof sessionStorage !== "undefined"
? sessionStorage.getItem("nxtgauge_admin_access_token") || ""
: "";
}
type KbArticle = {
@ -24,17 +24,17 @@ async function loadArticle(id: string): Promise<KbArticle | null> {
const token = getToken();
const res = await fetch(`${API}/api/admin/kb/articles/${id}`, {
headers: {
Accept: 'application/json',
Accept: "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
credentials: 'include',
credentials: "include",
});
if (!res.ok) return null;
const data = await res.json();
return {
...data,
content: data?.content ?? data?.body ?? '',
body: data?.body ?? data?.content ?? '',
content: data?.content ?? data?.body ?? "",
body: data?.body ?? data?.content ?? "",
};
} catch {
return null;
@ -45,23 +45,23 @@ export default function KbArticleEditPage() {
const navigate = useNavigate();
const params = useParams();
const [article] = createResource(() => params.id, loadArticle);
const [title, setTitle] = createSignal('');
const [slug, setSlug] = createSignal('');
const [categoryId, setCategoryId] = createSignal('');
const [status, setStatus] = createSignal('DRAFT');
const [content, setContent] = createSignal('');
const [title, setTitle] = createSignal("");
const [slug, setSlug] = createSignal("");
const [categoryId, setCategoryId] = createSignal("");
const [status, setStatus] = createSignal("DRAFT");
const [content, setContent] = createSignal("");
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal('');
const [error, setError] = createSignal("");
const [loaded, setLoaded] = createSignal(false);
createEffect(() => {
const value = article();
if (!value || loaded()) return;
setTitle(value.title || '');
setSlug(value.slug || '');
setCategoryId(value.category_id || '');
setStatus(value.status || 'DRAFT');
setContent(value.content || value.body || '');
setTitle(value.title || "");
setSlug(value.slug || "");
setCategoryId(value.category_id || "");
setStatus(value.status || "DRAFT");
setContent(value.content || value.body || "");
setLoaded(true);
});
@ -69,95 +69,134 @@ export default function KbArticleEditPage() {
e.preventDefault();
try {
setSaving(true);
setError('');
setError("");
const res = await fetch(`${API}/api/admin/kb/articles/${params.id}`, {
method: 'PATCH',
method: "PATCH",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}),
},
credentials: 'include',
credentials: "include",
body: JSON.stringify({
title: title(),
slug: slug(),
category_id: categoryId() || null,
status: status(),
content: content(),
body: content(),
}),
});
if (!res.ok) throw new Error('Failed to save article');
if (!res.ok) throw new Error("Failed to save article");
navigate(`/admin/kb/articles/${params.id}`);
} catch (err: any) {
setError(err.message || 'Failed to save article');
setError(err.message || "Failed to save article");
} finally {
setSaving(false);
}
};
const inputCls = 'w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]';
const labelCls = 'mb-1.5 block text-sm font-medium text-gray-700';
const inputCls =
"w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]";
const labelCls = "mb-1.5 block text-sm font-medium text-gray-700";
return (
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
<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>
<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>
</div>
<div class="flex items-center gap-2">
<A class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href={`/admin/kb/articles/${params.id}`}>Back to Detail</A>
<A class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href="/admin/kb/articles">Back to Articles</A>
<A
class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
href={`/admin/kb/articles/${params.id}`}
>
Back to Detail
</A>
<A
class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
href="/admin/kb/articles"
>
Back to Articles
</A>
</div>
</div>
<div class="p-6 flex-1">
<Show when={article.loading}>
<div class="table-card"><p class="py-10 text-center text-sm text-slate-400">Loading article</p></div>
</Show>
<Show when={!article.loading && !article()}>
<div class="table-card"><p class="py-10 text-center text-sm text-slate-400">Article not found.</p></div>
</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>
<Show when={article.loading}>
<div class="table-card">
<p class="py-10 text-center text-sm text-slate-400">Loading article</p>
</div>
</Show>
<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>
<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>
</form>
</Show>
</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>
<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>
);
}

View file

@ -1,7 +1,7 @@
import { A, useNavigate } from '@solidjs/router';
import { createEffect, createMemo, createResource, createSignal, For, Show } from 'solid-js';
import { A, useNavigate } from "@solidjs/router";
import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js";
const API = '';
const API = "";
type Permission = { key: string; module: string; action: string };
type Department = { id: string; name: string };
@ -10,9 +10,9 @@ function formatRoleKey(input: string): string {
return input
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.replace(/_{2,}/g, '_');
.replace(/[^A-Z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.replace(/_{2,}/g, "_");
}
async function loadPermissions(): Promise<Permission[]> {
@ -39,42 +39,69 @@ async function loadDepartments(): Promise<Department[]> {
// Fallback static permissions matching backend MODULES
const STATIC_MODULES = [
'Department Management', 'Designation Management', 'Internal Role Management',
'Employee Management', 'External Role Management',
'Internal Dashboard Management', 'External Dashboard Management', 'Verification Management',
'Approval Management', 'Users Management', 'Company Management', 'Candidate Management',
'Customer Management', 'Photographer Management', 'Makeup Artist Management',
'Tutor Management', 'Developer Management', 'Fitness Trainer Management',
'Graphic Designer Management', 'Social Media Management', 'Video Editor Management',
'Catering Services Management', 'Jobs Management', 'Leads Management',
'Applications Management', 'Responses Management', 'Review Management',
'Pricing Management', 'Credit Management', 'Coupon Management', 'Discount Management',
'Tax Management', 'Order Management', 'Invoice Management', 'Ledger Management',
'Knowledge Base Management', 'Support Management', 'Report Management', 'Notifications',
"Department Management",
"Designation Management",
"Internal Role Management",
"Employee Management",
"External Role Management",
"Internal Dashboard Management",
"External Dashboard Management",
"Verification Management",
"Approval Management",
"Users Management",
"Company Management",
"Candidate Management",
"Customer Management",
"Photographer Management",
"Makeup Artist Management",
"Tutor Management",
"Developer Management",
"Fitness Trainer Management",
"Graphic Designer Management",
"Social Media Management",
"Video Editor Management",
"Catering Services Management",
"Jobs Management",
"Leads Management",
"Applications Management",
"Responses Management",
"Review Management",
"Pricing Management",
"Credit Management",
"Coupon Management",
"Discount Management",
"Tax Management",
"Order Management",
"Invoice Management",
"Ledger Management",
"Knowledge Base Management",
"Support Management",
"Report Management",
"Notifications",
];
const ACTIONS = ['View', 'Create', 'Update', 'Delete'] as const;
const ACTIONS = ["View", "Create", "Update", "Delete"] as const;
const STATIC_PERMISSIONS: Permission[] = STATIC_MODULES.flatMap((module) =>
ACTIONS.map((action) => ({
key: `${module.replace(/ /g, '_').toLowerCase()}:${action.toLowerCase()}`,
key: `${module.replace(/ /g, "_").toLowerCase()}:${action.toLowerCase()}`,
module,
action,
})),
}))
);
type SubTab = 'general' | 'module' | 'settings';
type SubTab = "general" | "module" | "settings";
export default function CreateInternalRolePage() {
const navigate = useNavigate();
const [permissions] = createResource(loadPermissions);
const [departments] = createResource(loadDepartments);
const [subTab, setSubTab] = createSignal<SubTab>('general');
const [subTab, setSubTab] = createSignal<SubTab>("general");
// General Information
const [roleName, setRoleName] = createSignal('');
const [roleCode, setRoleCode] = createSignal('');
const [departmentId, setDepartmentId] = createSignal('');
const [description, setDescription] = createSignal('');
const [roleName, setRoleName] = createSignal("");
const [roleCode, setRoleCode] = createSignal("");
const [departmentId, setDepartmentId] = createSignal("");
const [description, setDescription] = createSignal("");
// Module Access: selected permission keys
const [selectedKeys, setSelectedKeys] = createSignal<Set<string>>(new Set());
@ -85,7 +112,7 @@ export default function CreateInternalRolePage() {
const [canManage, setCanManage] = createSignal(false);
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal('');
const [error, setError] = createSignal("");
createEffect(() => {
setRoleCode(formatRoleKey(roleName()));
@ -138,317 +165,357 @@ export default function CreateInternalRolePage() {
const handleSave = async () => {
if (saving()) return;
if (!roleName().trim()) { setError('Role name is required'); setSubTab('general'); return; }
if (!roleName().trim()) {
setError("Role name is required");
setSubTab("general");
return;
}
const normalizedRoleCode = formatRoleKey(roleName());
if (!normalizedRoleCode) { setError('Role code is required'); setSubTab('general'); return; }
setError('');
if (!normalizedRoleCode) {
setError("Role code is required");
setSubTab("general");
return;
}
setError("");
try {
setSaving(true);
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const accessToken =
typeof sessionStorage !== "undefined"
? (sessionStorage.getItem("nxtgauge_admin_access_token") || "").trim()
: "";
const res = await fetch(`${API}/api/admin/roles`, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
"Content-Type": "application/json",
Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
credentials: "include",
body: JSON.stringify({
key: normalizedRoleCode,
name: roleName().trim(),
audience: 'INTERNAL',
audience: "INTERNAL",
is_active: isActive(),
description: description().trim() || null,
department_id: departmentId() || null,
is_active: isActive(),
can_approve_requests: canApprove(),
can_manage_system_settings: canManage(),
permission_keys: [...selectedKeys()],
}),
});
const raw = await res.text();
let message = '';
let message = "";
if (raw) {
try {
const parsed = JSON.parse(raw) as { message?: string; error?: string };
message = parsed?.message || parsed?.error || '';
const parsed = JSON.parse(raw) as { message?: string; error?: string; id?: string };
message = parsed?.message || parsed?.error || "";
} catch {
message = raw;
}
}
if (!res.ok) throw new Error(message || `Failed to create role (${res.status})`);
navigate('/admin/roles');
const roleData = JSON.parse(raw) as { id?: string };
if (roleData.id) {
await fetch(`${API}/api/admin/internal-roles`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: "include",
body: JSON.stringify({
role_id: roleData.id,
description: description().trim() || null,
department_id: departmentId() || null,
can_approve_requests: canApprove(),
can_manage_system_settings: canManage(),
}),
});
}
navigate("/admin/roles");
} catch (err: any) {
setError(String(err?.message || '').trim() || 'Failed to create role');
setError(String(err?.message || "").trim() || "Failed to create role");
} finally {
setSaving(false);
}
};
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="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 class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm overflow-hidden">
{/* Sub-tabs */}
<div class="flex items-center gap-1 border-b border-[#F3F4F6] px-6">
{(
[
{ key: "general", label: "General Information" },
{ key: "module", label: "Module Access" },
{ key: "settings", label: "Role Settings" },
] as const
).map((t) => (
<button
type="button"
onClick={() => setSubTab(t.key)}
class={`relative px-4 py-4 text-[13px] font-semibold transition-colors ${
subTab() === t.key ? "text-[#111827]" : "text-[#9CA3AF] hover:text-[#6B7280]"
}`}
>
{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 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 */}
<div class="flex items-center gap-1 border-b border-[#F3F4F6] px-6">
{(
[
{ key: 'general', label: 'General Information' },
{ key: 'module', label: 'Module Access' },
{ key: 'settings', label: 'Role Settings' },
] as const
).map((t) => (
<button
type="button"
onClick={() => setSubTab(t.key)}
class={`relative px-4 py-4 text-[13px] font-semibold transition-colors ${
subTab() === t.key ? 'text-[#111827]' : 'text-[#9CA3AF] hover:text-[#6B7280]'
}`}
>
{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()}
{/* ── Tab: General Information ── */}
<Show when={subTab() === "general"}>
<div class="p-6 space-y-5">
<div class="grid grid-cols-2 gap-5">
<div>
<label class="block text-[13px] font-medium text-[#0D0D2A] mb-1.5">
Role Name <span class="text-red-500">*</span>
</label>
<input
type="text"
placeholder="Enter role name"
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>
</Show>
{/* ── Tab: General Information ── */}
<Show when={subTab() === 'general'}>
<div class="p-6 space-y-5">
<div class="grid grid-cols-2 gap-5">
<div>
<label class="block text-[13px] font-medium text-[#0D0D2A] mb-1.5">
Role Name <span class="text-red-500">*</span>
</label>
<input
type="text"
placeholder="Enter role name"
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.
<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 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
</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>
<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>
</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>
</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>
);
}}
</For>
</tbody>
</table>
</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>
);
}
@ -471,12 +538,12 @@ function SettingToggle(props: {
aria-checked={props.value}
onClick={() => props.onChange(!props.value)}
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${
props.value ? 'bg-[#FF5E13]' : 'bg-[#d1d5db]'
props.value ? "bg-[#FF5E13]" : "bg-[#d1d5db]"
}`}
>
<span
class={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
props.value ? 'translate-x-6' : 'translate-x-1'
props.value ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
import { A, useNavigate, useParams } from '@solidjs/router';
import { createMemo, createResource, createSignal, Show } from 'solid-js';
import { A, useNavigate, useParams } from "@solidjs/router";
import { createMemo, createResource, createSignal, Show } from "solid-js";
const API = '';
const API = "";
type Role = {
id: string;
@ -16,7 +16,7 @@ type User = {
roleId?: string;
role_id?: string;
role?: Role;
status?: 'ACTIVE' | 'INACTIVE' | 'PENDING';
status?: "ACTIVE" | "INACTIVE" | "PENDING";
createdAt?: string;
created_at?: string;
};
@ -26,7 +26,7 @@ async function fetchRoles(): Promise<Role[]> {
const res = await fetch(`${API}/api/admin/roles?audience=INTERNAL`);
if (!res.ok) return [];
const data = await res.json();
const rows = Array.isArray(data) ? data : (data.roles || []);
const rows = Array.isArray(data) ? data : data.roles || [];
return rows.map((r: any) => ({ id: r.id, name: r.name }));
} catch {
return [];
@ -52,132 +52,178 @@ export default function EditUserPage() {
const [user] = createResource(() => params.id, fetchUser);
const [roles] = createResource(fetchRoles);
const [name, setName] = createSignal('');
const [email, setEmail] = createSignal('');
const [roleId, setRoleId] = createSignal('');
const [status, setStatus] = createSignal<'ACTIVE' | 'INACTIVE' | 'PENDING'>('ACTIVE');
const [name, setName] = createSignal("");
const [email, setEmail] = createSignal("");
const [phone, setPhone] = createSignal("");
const [password, setPassword] = createSignal("");
const [roleId, setRoleId] = createSignal("");
const [status, setStatus] = createSignal<"ACTIVE" | "INACTIVE" | "PENDING">("ACTIVE");
const [submitting, setSubmitting] = createSignal(false);
const [error, setError] = createSignal('');
const [error, setError] = createSignal("");
createMemo(() => {
const u = user();
if (!u) return null;
setName(u.name || u.full_name || '');
setEmail(u.email || '');
setRoleId(u.roleId || u.role_id || u.role?.id || '');
setStatus((u.status || 'ACTIVE').toUpperCase() as 'ACTIVE' | 'INACTIVE' | 'PENDING');
setName(u.name || u.full_name || "");
setEmail(u.email || "");
setPhone(u.phone || "");
setStatus((u.status || "ACTIVE").toUpperCase() as "ACTIVE" | "INACTIVE" | "PENDING");
return null;
});
const save = async () => {
if (!name().trim() || !email().trim() || !roleId()) {
setError('Please fill in name, email, and role.');
setError("Please fill in name, email, and role.");
return;
}
try {
setSubmitting(true);
setError('');
setError("");
const body = {
name: name().trim(),
first_name: name().trim(),
email: email().trim(),
roleId: roleId(),
phone: phone().trim(),
password: password() || "",
role_id: roleId(),
status: status().toLowerCase(),
};
let res = await fetch(`${API}/api/admin/users/${params.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
res = await fetch(`${API}/api/users/${params.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
throw new Error(payload.message || 'Failed to update user');
throw new Error(payload.message || "Failed to update user");
}
navigate('/admin/users');
navigate("/admin/users");
} catch (err: any) {
setError(err.message || 'Failed to update user');
setError(err.message || "Failed to update user");
} finally {
setSubmitting(false);
}
};
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>
<h1 class="text-xl font-semibold text-gray-900">Edit User</h1>
<p class="text-sm text-gray-500 mt-0.5">Update user profile, role assignment, and account status.</p>
<p class="text-sm text-gray-500 mt-0.5">
Update user profile, role assignment, and account status.
</p>
</div>
<div class="flex items-center gap-2">
<A class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href={`/admin/users/details/${params.id}`}>View Details</A>
<A class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href="/admin/users">Back to Users</A>
<A
class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
href={`/admin/users/details/${params.id}`}
>
View Details
</A>
<A
class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
href="/admin/users"
>
Back to Users
</A>
</div>
</div>
<div class="p-6">
<Show when={error()}>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error()}</div>
</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>
<Show when={error()}>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error()}
</div>
</Show>
<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>
<Show when={user.loading}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm">
<p class="notice">Loading user...</p>
</div>
</section>
</Show>
</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 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>
);
}