nxtgauge-admin-solid/src/routes/admin/roles/create.tsx
Tracewebstudio Dev 164bbaf99b
Some checks failed
build-and-release / build (push) Failing after 30s
feat: role registration wizard + shared identity doc pre-fill
- 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>
2026-08-12 14:55:25 +02:00

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>
);
}