Some checks failed
build-and-release / build (push) Failing after 30s
- Add RoleWizard inline to SwitchServicesPage after successful role registration — same wizard flow as ProfilePage, no separate step - Pre-fill identity documents (Aadhaar, PAN, selfie, etc.) from the user's active role profile so they don't re-upload on second role - Add identity_shared flag to RuntimeOnboardingField for admin- configurable per-field opt-in; CONVENTION_IDENTITY_FIELD_IDS covers common IDs without schema changes - Extract roleKeyToPrefix() into src/lib/role-utils.ts (shared util) - Fix solid/reactivity: snapshot portfolioForm()/form()/docUrls() synchronously before the first await in handleSubmit - Reuse badge (♻ purple) distinguishes pre-filled docs from new uploads; Change link clears the pre-fill and lets user re-upload - Pending roles now show 'Under Review' status badge instead of Switch - ArrowLeft back-button in header exits wizard without full page reload Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
555 lines
20 KiB
TypeScript
555 lines
20 KiB
TypeScript
import { A, useNavigate } from "@solidjs/router";
|
|
import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js";
|
|
|
|
const API = "";
|
|
|
|
type Permission = { key: string; module: string; action: string };
|
|
type Department = { id: string; name: string };
|
|
|
|
function formatRoleKey(input: string): string {
|
|
return input
|
|
.trim()
|
|
.toUpperCase()
|
|
.replace(/[^A-Z0-9]+/g, "_")
|
|
.replace(/^_+|_+$/g, "")
|
|
.replace(/_{2,}/g, "_");
|
|
}
|
|
|
|
async function loadPermissions(): Promise<Permission[]> {
|
|
try {
|
|
const res = await fetch(`${API}/api/admin/permissions`);
|
|
if (!res.ok) throw new Error();
|
|
const data = await res.json();
|
|
return Array.isArray(data) ? data : [];
|
|
} catch {
|
|
return STATIC_PERMISSIONS;
|
|
}
|
|
}
|
|
|
|
async function loadDepartments(): Promise<Department[]> {
|
|
try {
|
|
const res = await fetch(`${API}/api/admin/departments`);
|
|
if (!res.ok) return [];
|
|
const data = await res.json();
|
|
return Array.isArray(data) ? data : (data.departments ?? []);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// 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",
|
|
];
|
|
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()}`,
|
|
module,
|
|
action,
|
|
}))
|
|
);
|
|
|
|
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");
|
|
|
|
// General Information
|
|
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());
|
|
|
|
// Role Settings
|
|
const [isActive, setIsActive] = createSignal(true);
|
|
const [canApprove, setCanApprove] = createSignal(false);
|
|
const [canManage, setCanManage] = createSignal(false);
|
|
|
|
const [saving, setSaving] = createSignal(false);
|
|
const [error, setError] = createSignal("");
|
|
|
|
createEffect(() => {
|
|
setRoleCode(formatRoleKey(roleName()));
|
|
});
|
|
|
|
// Group permissions by module
|
|
const permsByModule = createMemo(() => {
|
|
const src = permissions() ?? STATIC_PERMISSIONS;
|
|
const map: Record<string, Permission[]> = {};
|
|
src.forEach((p) => {
|
|
if (!map[p.module]) map[p.module] = [];
|
|
map[p.module].push(p);
|
|
});
|
|
return map;
|
|
});
|
|
|
|
const allModules = createMemo(() => Object.keys(permsByModule()));
|
|
|
|
// Toggle a single permission key
|
|
const toggleKey = (key: string) => {
|
|
const next = new Set(selectedKeys());
|
|
if (next.has(key)) next.delete(key);
|
|
else next.add(key);
|
|
setSelectedKeys(next);
|
|
};
|
|
|
|
// Toggle entire row (all actions for a module)
|
|
const toggleRow = (module: string) => {
|
|
const perms = permsByModule()[module] ?? [];
|
|
const allSelected = perms.every((p) => selectedKeys().has(p.key));
|
|
const next = new Set(selectedKeys());
|
|
if (allSelected) {
|
|
perms.forEach((p) => next.delete(p.key));
|
|
} else {
|
|
perms.forEach((p) => next.add(p.key));
|
|
}
|
|
setSelectedKeys(next);
|
|
};
|
|
|
|
// Select all / deselect all
|
|
const selectAll = () => {
|
|
const all = (permissions() ?? STATIC_PERMISSIONS).map((p) => p.key);
|
|
setSelectedKeys(new Set(all as string[]));
|
|
};
|
|
const deselectAll = () => setSelectedKeys(new Set<string>());
|
|
const allSelected = () => {
|
|
const src = permissions() ?? STATIC_PERMISSIONS;
|
|
return src.length > 0 && src.every((p) => selectedKeys().has(p.key));
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (saving()) 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("");
|
|
|
|
try {
|
|
setSaving(true);
|
|
const accessToken =
|
|
typeof sessionStorage !== "undefined"
|
|
? (sessionStorage.getItem("nxtgauge_admin_access_token") || "").trim()
|
|
: "";
|
|
const res = await fetch(`${API}/api/admin/roles`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json",
|
|
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
|
},
|
|
credentials: "include",
|
|
body: JSON.stringify({
|
|
key: normalizedRoleCode,
|
|
name: roleName().trim(),
|
|
audience: "INTERNAL",
|
|
is_active: isActive(),
|
|
description: description().trim() || null,
|
|
department_id: departmentId() || null,
|
|
can_approve_requests: canApprove(),
|
|
can_manage_system_settings: canManage(),
|
|
permission_keys: [...selectedKeys()],
|
|
}),
|
|
});
|
|
const raw = await res.text();
|
|
let message = "";
|
|
if (raw) {
|
|
try {
|
|
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})`);
|
|
|
|
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");
|
|
} 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]">
|
|
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>
|
|
|
|
<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">
|
|
<For each={[
|
|
{ key: "general", label: "General Information" },
|
|
{ key: "module", label: "Module Access" },
|
|
{ key: "settings", label: "Role Settings" },
|
|
] as const}>
|
|
{(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>
|
|
)}
|
|
</For>
|
|
</div>
|
|
|
|
{/* Error banner */}
|
|
<Show when={error()}>
|
|
<div class="mx-6 mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700">
|
|
{error()}
|
|
</div>
|
|
</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.
|
|
</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>
|
|
<For each={ACTIONS}>
|
|
{(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>
|
|
);
|
|
}}
|
|
</For>
|
|
<td class="px-4 py-3.5 text-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={rowAllSelected()}
|
|
onChange={() => toggleRow(module)}
|
|
class="h-4 w-4 accent-[#FF5E13] cursor-pointer"
|
|
/>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}}
|
|
</For>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
{/* ── Tab: Role Settings ── */}
|
|
<Show when={subTab() === "settings"}>
|
|
<div class="p-6 space-y-6">
|
|
{/* Status toggle */}
|
|
<div>
|
|
<p class="text-[13px] font-semibold text-[#0D0D2A] mb-3">Role Status</p>
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsActive(true)}
|
|
class={`h-[38px] rounded-xl border px-5 text-[13px] font-semibold transition-colors ${
|
|
isActive()
|
|
? "border-[#059669] bg-[#ECFDF5] text-[#059669]"
|
|
: "border-[#E5E7EB] bg-white text-[#6B7280] hover:bg-[#F9FAFB]"
|
|
}`}
|
|
>
|
|
Active
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsActive(false)}
|
|
class={`h-[38px] rounded-xl border px-5 text-[13px] font-semibold transition-colors ${
|
|
!isActive()
|
|
? "border-[#6B7280] bg-[#F3F4F6] text-[#374151]"
|
|
: "border-[#E5E7EB] bg-white text-[#6B7280] hover:bg-[#F9FAFB]"
|
|
}`}
|
|
>
|
|
Inactive
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Setting toggles */}
|
|
<div class="space-y-3">
|
|
<SettingToggle
|
|
label="Allow Role to Approve Requests"
|
|
description="Enable this role to approve various requests"
|
|
value={canApprove()}
|
|
onChange={setCanApprove}
|
|
/>
|
|
<SettingToggle
|
|
label="Allow Role to Manage System Settings"
|
|
description="Enable this role to manage system settings and configurations"
|
|
value={canManage()}
|
|
onChange={setCanManage}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
{/* Footer actions */}
|
|
<div class="flex items-center justify-end gap-3 border-t border-[#F3F4F6] px-6 py-4">
|
|
<A
|
|
href="/admin/roles"
|
|
class="h-[40px] inline-flex items-center rounded-xl border border-[#E5E7EB] bg-white px-5 text-[13px] font-semibold text-[#374151] hover:bg-[#F9FAFB] transition-colors"
|
|
>
|
|
Cancel
|
|
</A>
|
|
<button
|
|
type="button"
|
|
onClick={handleSave}
|
|
disabled={saving()}
|
|
class="h-[40px] rounded-xl bg-[#0D0D2A] px-6 text-[13px] font-semibold text-white hover:bg-[#1a1a3e] transition-colors disabled:opacity-60"
|
|
>
|
|
{saving() ? "Creating…" : "Create Role"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Setting toggle row ────────────────────────────────────────────────────────
|
|
function SettingToggle(props: {
|
|
label: string;
|
|
description: string;
|
|
value: boolean;
|
|
onChange: (v: boolean) => void;
|
|
}) {
|
|
return (
|
|
<div class="flex items-center justify-between rounded-xl border border-[#e5e7eb] px-5 py-4">
|
|
<div>
|
|
<p class="text-[13px] font-semibold text-[#0D0D2A]">{props.label}</p>
|
|
<p class="text-[12px] text-[rgba(13,13,42,0.5)] mt-0.5">{props.description}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
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]"
|
|
}`}
|
|
>
|
|
<span
|
|
class={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
|
|
props.value ? "translate-x-6" : "translate-x-1"
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|