fix: update admin forms to match DB schema
This commit is contained in:
parent
3265b1a16a
commit
232309e353
12 changed files with 4520 additions and 2820 deletions
0
admin-solid.dev.log
Normal file
0
admin-solid.dev.log
Normal file
1
admin-solid.dev.pid
Normal file
1
admin-solid.dev.pid
Normal file
|
|
@ -0,0 +1 @@
|
|||
7741
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue