nxtgauge-admin-solid/src/routes/admin/roles/index.tsx

884 lines
48 KiB
TypeScript
Raw Normal View History

import { For, Show, createEffect, createMemo, createResource, createSignal, onMount } from 'solid-js';
import type { CrudRecord } from '~/lib/admin/types';
const API = '';
const ACTIONS = ['View', 'Create', 'Update', 'Delete'] as const;
const STATIC_MODULES = [
'Department Management', 'Designation Management', 'Internal Role Management',
'Employee Management', 'External Role Management', 'External Onboarding 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',
] as const;
function formatRoleKey(input: string): string {
return input
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.replace(/_{2,}/g, '_');
}
type RoleRecord = CrudRecord & {
key?: string;
department?: string;
departmentId?: string;
description?: string;
usersAssigned?: number;
permissionsCount?: number;
canApproveRequests?: boolean;
canManageSystemSettings?: boolean;
createdDate?: string;
};
type DepartmentOption = { id: string; name: string };
type Permission = { key: string; module: string; action: string };
function makeKey(module: string, action: string) {
return `${module.replace(/ /g, '_').toLowerCase()}:${action.toLowerCase()}`;
}
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_MODULES.flatMap((module) =>
ACTIONS.map((action) => ({
key: makeKey(module, action),
module,
action,
})),
);
}
}
function normalizeRole(item: any, idx: number): RoleRecord {
return {
id: String(item.id ?? `role-${idx + 1}`),
name: String(item.name ?? ''),
key: item.key ? String(item.key) : undefined,
department: item.department_name ? String(item.department_name) : undefined,
departmentId: item.department_id ? String(item.department_id) : undefined,
description: item.description ? String(item.description) : undefined,
usersAssigned: Number(item.users_assigned ?? 0),
permissionsCount: Number(item.permissions_count ?? 0),
canApproveRequests: Boolean(item.can_approve_requests ?? false),
canManageSystemSettings: Boolean(item.can_manage_system_settings ?? false),
status: item.is_active === false ? 'INACTIVE' : 'ACTIVE',
updatedAt: String(item.updated_at ?? item.created_at ?? ''),
createdDate: String(item.created_at ?? ''),
};
}
function StatusBadge(props: { status: string }) {
const active = () => props.status === 'ACTIVE';
return (
<span style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${active() ? '#FFD8C2' : '#D1D5DB'};background:${active() ? '#FFF1EB' : '#F3F4F6'};color:${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:${active() ? '#FF5E13' : '#9CA3AF'};margin-right:5px;flex-shrink:0`} />
{active() ? 'Active' : 'Inactive'}
</span>
);
}
function FormInput(props: { label: string; required?: boolean; value: string; onInput: (v: string) => void; placeholder?: string }) {
return (
<label style="display:block">
<span style="font-size:13px;font-weight:600;color:#374151">
{props.label}{props.required && <span style="margin-left:2px;color:#FF5E13">*</span>}
</span>
<input
type="text"
value={props.value}
onInput={(e) => props.onInput(e.currentTarget.value)}
placeholder={props.placeholder}
style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 14px;font-size:13px;color:#111827;outline:none;box-sizing:border-box"
/>
</label>
);
}
export default function RoleManagementPage() {
const [view, setView] = createSignal<'list' | 'form' | 'detail'>('list');
const [permissions] = createResource(loadPermissions);
const [listTab, setListTab] = createSignal<'all' | 'create' | 'view'>('all');
const [formTab, setFormTab] = createSignal<'general' | 'permissions' | 'settings'>('general');
const [detailTab, setDetailTab] = createSignal<'permissions' | 'users' | 'logs'>('permissions');
const [search, setSearch] = createSignal('');
const [rows, setRows] = createSignal<RoleRecord[]>([]);
const [viewingRole, setViewingRole] = createSignal<RoleRecord | null>(null);
const [viewingPermissions, setViewingPermissions] = createSignal<string[]>([]);
const [editingId, setEditingId] = createSignal<string | null>(null);
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
const [isLoading, setIsLoading] = createSignal(false);
const [error, setError] = createSignal('');
const [statusFilter, setStatusFilter] = createSignal<'all' | 'ACTIVE' | 'INACTIVE'>('all');
const [sortBy, setSortBy] = createSignal<'name_asc' | 'name_desc' | 'users_desc' | 'users_asc'>('name_asc');
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
// Form state
const [name, setName] = createSignal('');
const [roleKey, setRoleKey] = createSignal('');
const [description, setDescription] = createSignal('');
const [departmentId, setDepartmentId] = createSignal('');
const [status, setStatus] = createSignal<'ACTIVE' | 'INACTIVE'>('ACTIVE');
const [canApproveRequests, setCanApproveRequests] = createSignal(false);
const [canManageSystemSettings, setCanManageSystemSettings] = createSignal(false);
const [selectedPermissions, setSelectedPermissions] = createSignal<Set<string>>(new Set());
const [isSaving, setIsSaving] = createSignal(false);
const [formError, setFormError] = createSignal('');
const [departments, setDepartments] = createSignal<DepartmentOption[]>([]);
const isViewingSuperAdmin = createMemo(() => (viewingRole()?.key || '').toUpperCase() === 'SUPER_ADMIN');
const load = async () => {
setIsLoading(true);
setError('');
try {
const params = new URLSearchParams({ audience: 'INTERNAL', per_page: '100', q: search().trim() });
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
const res = await fetch(`${API}/api/admin/roles?${params}`, {
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
});
if (!res.ok) throw new Error(`Request failed (${res.status})`);
const payload = await res.json().catch(() => null);
const list: any[] = Array.isArray(payload)
? payload
: Array.isArray(payload?.roles)
? payload.roles
: [];
setRows(list.map(normalizeRole));
} catch (err: any) {
setError(err?.message || 'Could not reach roles API.');
setRows([]);
} finally {
setIsLoading(false);
}
};
const loadDepartments = async () => {
try {
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
const res = await fetch(`${API}/api/admin/departments?per_page=100`, {
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
});
if (!res.ok) return;
const payload = await res.json().catch(() => null);
const list: any[] = Array.isArray(payload?.departments) ? payload.departments : [];
setDepartments(list.map((d: any) => ({ id: String(d.id), name: String(d.name) })));
} catch { /* dropdown just empty */ }
};
onMount(() => { void load(); void loadDepartments(); });
const filteredRows = createMemo(() => {
const q = search().toLowerCase();
let list = rows();
if (statusFilter() !== 'all') {
list = list.filter((r) => r.status === statusFilter());
}
if (q) {
list = list.filter(r =>
r.name.toLowerCase().includes(q) || (r.key || '').toLowerCase().includes(q)
);
}
const sorted = [...list];
sorted.sort((a, b) => {
if (sortBy() === 'name_desc') return b.name.localeCompare(a.name);
if (sortBy() === 'users_desc') return Number(b.usersAssigned || 0) - Number(a.usersAssigned || 0);
if (sortBy() === 'users_asc') return Number(a.usersAssigned || 0) - Number(b.usersAssigned || 0);
return a.name.localeCompare(b.name);
});
return sorted;
});
const exportCsv = () => {
const headers = ['Role Name', 'Role Key', 'Department', 'Users', 'Permissions', 'Status'];
const rowsData = filteredRows().map((row) => [
row.name || '',
row.key || '',
row.department || '',
String(row.usersAssigned ?? 0),
(row.key || '').toUpperCase() === 'SUPER_ADMIN' ? 'All' : String(row.permissionsCount ?? 0),
row.status || '',
]);
const csv = [headers, ...rowsData]
.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 a = document.createElement('a');
a.href = url;
a.download = `internal-roles-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const permissionKeyByModuleAction = createMemo(() => {
const map = new Map<string, string>();
const src = permissions() ?? [];
src.forEach((p) => {
const moduleKey = String(p.module || '').trim().toUpperCase();
const actionKey = String(p.action || '').trim().toUpperCase();
if (!moduleKey || !actionKey) return;
map.set(`${moduleKey}::${actionKey}`, String(p.key || '').trim());
});
return map;
});
const orderedModules = createMemo(() => {
const fromApi = Array.from(
new Set((permissions() ?? []).map((p) => String(p.module || '').trim()).filter(Boolean)),
);
const ordered = [...STATIC_MODULES.filter((m) => fromApi.includes(m))];
const extras = fromApi.filter((m) => !ordered.includes(m)).sort();
return [...ordered, ...extras];
});
const permissionKeyFor = (module: string, action: string) =>
permissionKeyByModuleAction().get(`${module.toUpperCase()}::${action.toUpperCase()}`) || '';
const togglePermission = (key: string) => {
setSelectedPermissions(prev => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const resetForm = () => {
setEditingId(null); setName(''); setRoleKey(''); setDescription('');
setDepartmentId(''); setStatus('ACTIVE'); setCanApproveRequests(false);
setCanManageSystemSettings(false); setSelectedPermissions(new Set());
setFormTab('general'); setFormError('');
};
const openCreate = () => { resetForm(); setView('form'); };
const openEdit = (row: RoleRecord) => {
setEditingId(row.id);
setName(row.name); setRoleKey(row.key || '');
setDescription(row.description || '');
setDepartmentId(row.departmentId || '');
setStatus(row.status === 'INACTIVE' ? 'INACTIVE' : 'ACTIVE');
setCanApproveRequests(Boolean(row.canApproveRequests));
setCanManageSystemSettings(Boolean(row.canManageSystemSettings));
setSelectedPermissions(new Set());
setFormTab('general'); setView('form'); setOpenMenuId(null);
// Fetch permission_keys for this role
fetch(`${API}/api/admin/roles/${row.id}`).then(r => r.json()).then((detail) => {
if (Array.isArray(detail?.permission_keys)) {
const keys = (detail.permission_keys as any[]).map((k) => String(k));
setSelectedPermissions(new Set<string>(keys));
}
}).catch(() => {});
};
const openDetail = async (row: RoleRecord) => {
setViewingRole(row); setView('detail'); setListTab('view'); setOpenMenuId(null);
setViewingPermissions([]);
try {
const res = await fetch(`${API}/api/admin/roles/${row.id}`);
if (res.ok) {
const detail = await res.json();
setViewingPermissions(Array.isArray(detail?.permission_keys) ? (detail.permission_keys as any[]).map((k: any) => String(k)) : []);
}
} catch { /* ignore */ }
};
const save = async () => {
if (isSaving()) return;
const normalizedRoleKey = editingId() ? formatRoleKey(roleKey()) : formatRoleKey(name());
if (!name().trim() || !normalizedRoleKey) {
setFormError('Role name and role key are required.');
setFormTab('general');
return;
}
setIsSaving(true);
setFormError('');
try {
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const isCreate = !editingId();
const endpoint = isCreate
? `${API}/api/admin/roles`
: `${API}/api/admin/roles/${editingId()}`;
const method = isCreate ? 'POST' : 'PATCH';
const body: Record<string, unknown> = {
name: name().trim(),
description: description().trim() || null,
is_active: status() === 'ACTIVE',
can_approve_requests: canApproveRequests(),
can_manage_system_settings: canManageSystemSettings(),
permission_keys: Array.from(selectedPermissions()) as string[],
};
if (departmentId().trim()) body.department_id = departmentId().trim();
if (isCreate) {
body.key = normalizedRoleKey;
body.audience = 'INTERNAL';
}
const res = await fetch(endpoint, {
method,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
body: JSON.stringify(body),
});
const raw = await res.text();
let message = '';
if (raw) {
try {
const parsed = JSON.parse(raw) as { message?: string; error?: string };
message = parsed?.message || parsed?.error || '';
} catch {
message = raw;
}
}
if (!res.ok) throw new Error(message || `Request failed (${res.status})`);
setView('list');
resetForm();
await load();
} catch (err: any) {
setFormError(String(err?.message || '').trim() || 'Failed to save role.');
} finally {
setIsSaving(false);
}
};
createEffect(() => {
if (editingId()) return;
setRoleKey(formatRoleKey(name()));
});
const deleteRole = async (id: string, roleName: string) => {
if (!window.confirm(`Delete role "${roleName}"?`)) return;
setOpenMenuId(null);
try {
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const res = await fetch(`${API}/api/admin/roles/${id}`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
});
if (!res.ok) throw new Error(`Request failed (${res.status})`);
await load();
} catch (err: any) {
setError(err?.message || 'Failed to delete role.');
}
};
const toggleStatus = async (row: RoleRecord) => {
setOpenMenuId(null);
try {
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const res = await fetch(`${API}/api/admin/roles/${row.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
body: JSON.stringify({ is_active: row.status !== 'ACTIVE' }),
});
if (!res.ok) throw new Error(`Request failed (${res.status})`);
await load();
} catch (err: any) {
setError(err?.message || 'Failed to update status.');
}
};
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]">Internal Role Management</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">Define and manage organizational access levels with granular permission control</p>
</div>
{/* ── LIST VIEW ── */}
<Show when={view() === 'list'}>
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
{([
{ key: 'all', label: 'All Roles', action: () => { setListTab('all'); void load(); } },
{ key: 'create', label: 'Create Role', action: () => { setListTab('create'); openCreate(); } },
{ key: 'view', label: 'View Role', action: () => setListTab('view') },
] as const).map((tab) => (
<button
type="button"
onClick={tab.action}
style={`padding-bottom:12px;font-size:14px;font-weight:500;background:none;border:none;cursor:pointer;${listTab() === tab.key ? 'color:#FF5E13;border-bottom:2px solid #FF5E13;margin-bottom:-1px' : 'color:#6B7280'}`}
>
{tab.label}
</button>
))}
</div>
<Show when={error()}>
<div style="margin-top:16px;border-radius:10px;border:1px solid #FECACA;background:#FEF2F2;padding:12px 16px;font-size:13px;color:#B91C1C">{error()}</div>
</Show>
<div style="margin-top:1.5rem;margin-left:-24px;margin-right:-24px;overflow:hidden;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">
<input
value={search()}
onInput={(e) => { setSearch(e.currentTarget.value); void load(); }}
placeholder="Search roles..."
style="height:34px;flex:1;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none"
/>
<div style="position:relative">
<button
type="button"
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>
Sort
</button>
<Show when={sortMenuOpen()}>
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:200px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
{([
['name_asc', 'Name (A-Z)'],
['name_desc', 'Name (Z-A)'],
['users_desc', 'Users (High-Low)'],
['users_asc', 'Users (Low-High)'],
] as const).map(([key, label]) => (
<button type="button" onClick={() => { setSortBy(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() === key ? '#FF5E13' : '#374151'};background:${sortBy() === key ? '#FFF1EB' : 'transparent'}`}>{label}</button>
))}
</div>
</Show>
</div>
<div style="position:relative">
<button
type="button"
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>
Filters
</button>
<Show when={filterMenuOpen()}>
<div style="position:absolute;left:0;top:38px;z-index:30;min-width:160px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.1)">
{([
['all', 'All Status'],
['ACTIVE', 'Active'],
['INACTIVE', 'Inactive'],
] as const).map(([key, label]) => (
<button type="button" onClick={() => { setStatusFilter(key as any); 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() === key ? '#FF5E13' : '#374151'};background:${statusFilter() === key ? '#FFF1EB' : 'transparent'}`}>{label}</button>
))}
</div>
</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>
Export
</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full">
<thead>
<tr style="background:#0D0D2A;text-align:left">
{['Role Name', 'Role Key', 'Department', 'Users', 'Permissions', 'Status', 'Actions'].map(h => (
<th style="padding:10px 20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#FFFFFF;white-space:nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
<Show
when={filteredRows().length > 0}
fallback={
<tr>
<td colspan="7" style="padding:48px 24px;text-align:center">
<Show when={isLoading()} fallback={
<>
<p style="font-size:15px;font-weight:600;color:#111827">No internal roles found</p>
<p style="margin-top:6px;font-size:13px;color:#6B7280">Create your first role to get started.</p>
<button type="button" onClick={openCreate} style="margin-top:16px;display:inline-flex;align-items:center;gap:6px;border-radius:10px;background:#0D0D2A;padding:8px 16px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer">Create Role</button>
</>
}>
<p style="font-size:13px;color:#6B7280">Loading...</p>
</Show>
</td>
</tr>
}
>
<For each={filteredRows()}>
{(row) => (
<tr style="border-bottom:1px solid #F3F4F6" class="hover:bg-[#FAFAFA] transition-colors">
<td style="padding:12px 20px">
<p style="font-size:14px;font-weight:600;color:#111827">{row.name}</p>
<Show when={row.description}>
<p style="font-size:12px;color:#9CA3AF;margin-top:1px">{row.description}</p>
</Show>
</td>
<td style="padding:12px 20px;font-size:12px;font-family:monospace;color:#6B7280">{row.key || '—'}</td>
<td style="padding:12px 20px;font-size:13px;color:#374151">{row.department || '—'}</td>
<td style="padding:12px 20px;font-size:13px;color:#374151">{Number(row.usersAssigned || 0)} users</td>
<td style="padding:12px 20px;font-size:13px;color:#374151">
{(row.key || '').toUpperCase() === 'SUPER_ADMIN' ? 'All' : Number(row.permissionsCount || 0)}
</td>
<td style="padding:12px 20px"><StatusBadge status={row.status} /></td>
<td style="padding:12px 20px;position:relative">
<button type="button" onClick={() => setOpenMenuId(openMenuId() === row.id ? null : row.id)} style="display:inline-flex;height:32px;width:32px;align-items:center;justify-content:center;border-radius:8px;color:#9CA3AF;background:none;border:none;cursor:pointer">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
</button>
<Show when={openMenuId() === row.id}>
<div style="position:absolute;right:20px;top:44px;z-index:20;width:200px;border-radius:12px;border:1px solid #E5E7EB;background:white;padding:6px;box-shadow:0 4px 20px rgba(0,0,0,0.12)">
<button type="button" onClick={() => void openDetail(row)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:10px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">
<svg style="width:15px;height:15px;color:#FF5E13;flex-shrink:0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
View Role
</button>
<button type="button" onClick={() => openEdit(row)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:10px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">
<svg style="width:15px;height:15px;color:#FF5E13;flex-shrink:0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="m16.5 3.5 4 4L7 21H3v-4L16.5 3.5Z"/></svg>
Edit Role
</button>
<button type="button" onClick={() => void toggleStatus(row)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:10px 12px;font-size:13px;color:#374151;background:none;border:none;cursor:pointer;text-align:left">
<svg style="width:15px;height:15px;color:#FF5E13;flex-shrink:0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M9 12l2 2 4-4"/></svg>
{row.status === 'ACTIVE' ? 'Deactivate' : 'Activate'}
</button>
<div style="height:1px;background:#F3F4F6;margin:4px 0" />
<button type="button" onClick={() => void deleteRole(row.id, row.name)} style="display:flex;width:100%;align-items:center;gap:10px;border-radius:8px;padding:10px 12px;font-size:13px;color:#DC2626;background:none;border:none;cursor:pointer;text-align:left">
<svg style="width:15px;height:15px;flex-shrink:0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6M10 11v6M14 11v6"/></svg>
Delete Role
</button>
</div>
</Show>
</td>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
</div>
</Show>
{/* ── FORM VIEW ── */}
<Show when={view() === 'form'}>
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
<button type="button" onClick={() => { setView('list'); resetForm(); }} style="padding-bottom:12px;font-size:14px;font-weight:500;color:#6B7280;background:none;border:none;cursor:pointer">All Roles</button>
<button type="button" style="padding-bottom:12px;font-size:14px;font-weight:500;color:#FF5E13;border-bottom:2px solid #FF5E13;background:none;cursor:pointer;margin-bottom:-1px">{editingId() ? 'Edit Role' : 'Create Role'}</button>
</div>
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;box-shadow:0 1px 4px rgba(0,0,0,0.06);overflow:hidden">
<div style="display:flex;align-items:center;gap:4px;border-bottom:1px solid #E5E7EB;padding:0 24px">
{(['general', 'permissions', 'settings'] as const).map((tab, i) => {
const labels = ['General Information', 'Module Access', 'Role Settings'];
const active = () => formTab() === tab;
return (
<button type="button" onClick={() => setFormTab(tab)} style={`position:relative;padding:14px 8px;font-size:13px;font-weight:500;background:none;border:none;cursor:pointer;color:${active() ? '#FF5E13' : '#6B7280'}`}>
{labels[i]}
<Show when={active()}><span style="position:absolute;left:0;right:0;bottom:0;height:2px;background:#FF5E13;border-radius:2px 2px 0 0" /></Show>
</button>
);
})}
</div>
<div style="padding:24px">
<Show when={formError()}>
<div style="margin-bottom:20px;border-radius:10px;border:1px solid #FECACA;background:#FEF2F2;padding:12px 16px;font-size:13px;color:#B91C1C">{formError()}</div>
</Show>
{/* General */}
<Show when={formTab() === 'general'}>
<div style="display:flex;flex-direction:column;gap:20px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
<FormInput label="Role Name" required value={name()} onInput={setName} placeholder="e.g. HR Manager" />
<label style="display:block">
<span style="font-size:13px;font-weight:600;color:#374151">
Role Key
<span style="margin-left:2px;color:#FF5E13">*</span>
</span>
<input
type="text"
placeholder="Auto-generated from role name"
value={roleKey()}
readOnly
style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;background:#F9FAFB;padding:0 14px;font-size:13px;color:#111827;outline:none;box-sizing:border-box"
/>
<p style="margin-top:6px;font-size:11px;color:#6B7280">
Generated automatically from Role Name.
</p>
</label>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
<label style="display:block">
<span style="font-size:13px;font-weight:600;color:#374151">Department</span>
<select
value={departmentId()}
onChange={(e) => setDepartmentId(e.currentTarget.value)}
style="display:block;margin-top:6px;height:40px;width:100%;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 14px;font-size:13px;color:#111827;outline:none;box-sizing:border-box;appearance:none"
>
<option value="">Select department</option>
<For each={departments()}>{(d) => <option value={d.id}>{d.name}</option>}</For>
</select>
</label>
<label style="display:block">
<span style="font-size:13px;font-weight:600;color:#374151">Status</span>
<div style="margin-top:6px;display:flex;gap:8px">
{(['ACTIVE', 'INACTIVE'] as const).map((s) => (
<button
type="button"
onClick={() => setStatus(s)}
style={`flex:1;height:40px;border-radius:10px;font-size:13px;font-weight:600;cursor:pointer;border:1px solid ${status() === s ? '#FF5E13' : '#E5E7EB'};background:${status() === s ? '#FFF3EE' : 'white'};color:${status() === s ? '#FF5E13' : '#6B7280'}`}
>
{s === 'ACTIVE' ? 'Active' : 'Inactive'}
</button>
))}
</div>
</label>
</div>
<label style="display:block">
<span style="font-size:13px;font-weight:600;color:#374151">Description</span>
<textarea
value={description()}
onInput={(e) => setDescription(e.currentTarget.value)}
placeholder="Brief description of this role's responsibilities..."
rows="3"
style="display:block;margin-top:6px;width:100%;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:10px 14px;font-size:13px;color:#111827;outline:none;resize:none;box-sizing:border-box;font-family:inherit"
/>
</label>
</div>
</Show>
{/* Permissions */}
<Show when={formTab() === 'permissions'}>
<div>
<p style="margin-bottom:16px;font-size:13px;color:#6B7280">Select the module access permissions for this role.</p>
<div style="border:1px solid #E5E7EB;border-radius:12px;overflow:hidden">
<table style="width:100%;border-collapse:collapse">
<thead style="background:#F9FAFB">
<tr style="text-align:left">
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase">Module</th>
<For each={ACTIONS}>
{(action) => (
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase;text-align:center">{action}</th>
)}
</For>
</tr>
</thead>
<tbody>
<For each={orderedModules()}>
{(module) => (
<tr style="border-top:1px solid #E5E7EB">
<td style="padding:12px 16px;font-size:13px;font-weight:600;color:#111827">{module}</td>
<For each={ACTIONS}>
{(action) => {
const pk = permissionKeyFor(module, action);
const disabled = !pk;
return (
<td style="padding:12px 16px;text-align:center">
<input
type="checkbox"
checked={pk ? selectedPermissions().has(pk) : false}
disabled={disabled}
onChange={() => pk && togglePermission(pk)}
style="width:16px;height:16px;accent-color:#FF5E13;cursor:pointer"
/>
</td>
);
}}
</For>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
</Show>
{/* Settings */}
<Show when={formTab() === 'settings'}>
<div style="display:flex;flex-direction:column;gap:20px">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-radius:12px;background:#F9FAFB;border:1px solid #E5E7EB">
<div>
<p style="font-size:13px;font-weight:600;color:#111827">Can Approve Requests</p>
<p style="font-size:12px;color:#6B7280;margin-top:2px">Users with this role can make final decisions in Approval Management.</p>
</div>
<button
type="button"
onClick={() => setCanApproveRequests(v => !v)}
style={`position:relative;width:44px;height:24px;border-radius:12px;border:none;cursor:pointer;background:${canApproveRequests() ? '#FF5E13' : '#E5E7EB'};transition:background 0.2s;flex-shrink:0`}
>
<span style={`position:absolute;top:2px;width:20px;height:20px;border-radius:50%;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.2);transition:left 0.2s;left:${canApproveRequests() ? '22px' : '2px'}`} />
</button>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-radius:12px;background:#F9FAFB;border:1px solid #E5E7EB">
<div>
<p style="font-size:13px;font-weight:600;color:#111827">Can Manage System Settings</p>
<p style="font-size:12px;color:#6B7280;margin-top:2px">Users with this role can access and modify system-level configurations.</p>
</div>
<button
type="button"
onClick={() => setCanManageSystemSettings(v => !v)}
style={`position:relative;width:44px;height:24px;border-radius:12px;border:none;cursor:pointer;background:${canManageSystemSettings() ? '#FF5E13' : '#E5E7EB'};transition:background 0.2s;flex-shrink:0`}
>
<span style={`position:absolute;top:2px;width:20px;height:20px;border-radius:50%;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.2);transition:left 0.2s;left:${canManageSystemSettings() ? '22px' : '2px'}`} />
</button>
</div>
</div>
</Show>
</div>
<div style="display:flex;align-items:center;justify-content:flex-end;gap:10px;border-top:1px solid #E5E7EB;padding:14px 24px">
<button type="button" onClick={() => { setView('list'); resetForm(); }} style="height:38px;border-radius:10px;border:1px solid #E5E7EB;background:white;padding:0 20px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Cancel</button>
<button type="button" onClick={() => void save()} disabled={isSaving()} style="height:38px;border-radius:10px;background:#0D0D2A;padding:0 24px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer">
{isSaving() ? 'Saving...' : editingId() ? 'Update Role' : 'Create Role'}
</button>
</div>
</div>
</Show>
{/* ── DETAIL VIEW ── */}
<Show when={view() === 'detail' && viewingRole()}>
<div style="margin-top:24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #E5E7EB">
<button type="button" onClick={() => { setView('list'); setListTab('all'); }} style="padding-bottom:12px;font-size:14px;font-weight:500;color:#6B7280;background:none;border:none;cursor:pointer">All Roles</button>
<button type="button" style="padding-bottom:12px;font-size:14px;font-weight:500;color:#FF5E13;border-bottom:2px solid #FF5E13;background:none;cursor:pointer;margin-bottom:-1px">View Role</button>
</div>
<div style="margin-top:24px;border-radius:16px;border:1px solid #E5E7EB;background:white;box-shadow:0 1px 4px rgba(0,0,0,0.06);overflow:hidden">
<div style="padding:24px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between">
<div>
<div style="display:flex;align-items:center;gap:12px">
<h2 style="font-size:20px;font-weight:700;color:#111827">{viewingRole()!.name}</h2>
<StatusBadge status={viewingRole()!.status} />
</div>
<p style="font-size:13px;color:#6B7280;margin-top:4px">
Key: <code style="font-family:monospace;background:#F3F4F6;padding:1px 6px;border-radius:4px">{viewingRole()!.key || '—'}</code>
{viewingRole()!.department ? `${viewingRole()!.department}` : ''}
{`${Number(viewingRole()!.usersAssigned || 0)} users assigned`}
</p>
<Show when={viewingRole()!.description}>
<p style="font-size:13px;color:#6B7280;margin-top:2px">{viewingRole()!.description}</p>
</Show>
</div>
<button type="button" onClick={() => openEdit(viewingRole()!)} style="height:36px;border-radius:8px;background:#0D0D2A;padding:0 16px;font-size:13px;font-weight:600;color:white;border:none;cursor:pointer">Edit Role</button>
</div>
<div style="display:flex;align-items:center;gap:4px;border-bottom:1px solid #E5E7EB;padding:0 24px;background:#FAFAFA">
{(['permissions', 'users', 'logs'] as const).map((tab, i) => {
const labels = ['Permissions', 'Assigned Users', 'Activity Logs'];
const active = () => detailTab() === tab;
return (
<button type="button" onClick={() => setDetailTab(tab)} style={`position:relative;padding:14px 12px;font-size:13px;font-weight:600;background:none;border:none;cursor:pointer;color:${active() ? '#FF5E13' : '#6B7280'}`}>
{labels[i]}
<Show when={active()}><span style="position:absolute;left:0;right:0;bottom:0;height:2px;background:#FF5E13;border-radius:2px 2px 0 0" /></Show>
</button>
);
})}
</div>
<div style="padding:24px">
<Show when={detailTab() === 'permissions'}>
<div style="border:1px solid #E5E7EB;border-radius:12px;overflow:hidden">
<table style="width:100%;border-collapse:collapse">
<thead style="background:#F9FAFB">
<tr style="text-align:left">
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase">Module</th>
<For each={ACTIONS}>
{(action) => (
<th style="padding:12px 16px;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase;text-align:center">{action}</th>
)}
</For>
</tr>
</thead>
<tbody>
<For each={orderedModules()}>
{(module) => (
<tr style="border-top:1px solid #E5E7EB">
<td style="padding:12px 16px;font-size:13px;font-weight:600;color:#111827">{module}</td>
<For each={ACTIONS}>
{(action) => {
const pk = permissionKeyFor(module, action);
const has = () => isViewingSuperAdmin() || viewingPermissions().includes(pk);
return (
<td style="padding:12px 16px;text-align:center">
<input type="checkbox" checked={has()} disabled style={`width:16px;height:16px;accent-color:#FF5E13;cursor:default;opacity:${has() ? '1' : '0.4'}`} />
</td>
);
}}
</For>
</tr>
)}
</For>
</tbody>
</table>
</div>
<div style="margin-top:16px;display:flex;gap:16px">
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:10px;background:#F9FAFB;border:1px solid #E5E7EB">
<p style="font-size:13px;font-weight:600;color:#111827">Can Approve Requests:</p>
<span style={`font-size:13px;font-weight:600;color:${viewingRole()!.canApproveRequests ? '#FF5E13' : '#9CA3AF'}`}>{viewingRole()!.canApproveRequests ? 'Yes' : 'No'}</span>
</div>
<div style="display:flex;align-items:center;gap:6px;padding:10px 14px;border-radius:10px;background:#F9FAFB;border:1px solid #E5E7EB">
<p style="font-size:13px;font-weight:600;color:#111827">Can Manage Settings:</p>
<span style={`font-size:13px;font-weight:600;color:${viewingRole()!.canManageSystemSettings ? '#FF5E13' : '#9CA3AF'}`}>{viewingRole()!.canManageSystemSettings ? 'Yes' : 'No'}</span>
</div>
</div>
</Show>
<Show when={detailTab() === 'users'}>
<div style="padding:32px;text-align:center">
<p style="font-size:14px;font-weight:600;color:#111827">{Number(viewingRole()!.usersAssigned || 0)} users assigned</p>
<p style="margin-top:6px;font-size:13px;color:#6B7280">Detailed user list will be available when employee management is connected.</p>
</div>
</Show>
<Show when={detailTab() === 'logs'}>
<div style="padding:32px;text-align:center">
<p style="font-size:13px;color:#6B7280">Activity logs will appear here.</p>
</div>
</Show>
</div>
<div style="padding:16px 24px;border-top:1px solid #E5E7EB;display:flex;justify-content:flex-end">
<button type="button" onClick={() => { setView('list'); setListTab('all'); }} style="height:36px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 16px;font-size:13px;font-weight:600;color:#374151;cursor:pointer">Back to List</button>
</div>
</div>
</Show>
</div>
);
}