ui(step-1): match reference layout for dept/designation/employees/roles pages
- All pages: white sticky page header + tab bar with orange underline, -mx-6 -mt-6 negative margin to stretch headers edge-to-edge - department: full columns (ID, Name, Description, Created By, etc.), icon-only action buttons, navy Add Department button - designation: Designations List / Add Designation tabs, status filter dropdown, inline create/edit form, full columns with status badge - employees: View/Add tabs, icon-only action buttons, status badges - roles/index: clean table with Name+code subtext, Description, actions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c44152154f
commit
d6405b55f6
5 changed files with 908 additions and 987 deletions
|
|
@ -675,34 +675,39 @@ export default function ApprovalPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell>
|
<AdminShell>
|
||||||
{/* Page header */}
|
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
|
||||||
<div class="mb-6 flex items-start justify-between gap-4">
|
|
||||||
<div>
|
{/* ── Page header ── */}
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Approval Management</h1>
|
<div class="bg-white border-b border-gray-200 px-6 py-4">
|
||||||
<p class="mt-1 text-sm text-gray-500">Review, approve, reject and configure approval workflows.</p>
|
<h1 class="text-xl font-semibold text-gray-900">Approval Management</h1>
|
||||||
</div>
|
<p class="text-sm text-gray-500 mt-0.5">Review, approve, reject and configure approval workflows.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status tabs */}
|
{/* ── Status tabs ── */}
|
||||||
<div style="display:flex;border-bottom:2px solid #e2e8f0;margin-bottom:16px;gap:0;overflow-x:auto">
|
<div class="bg-white border-b border-gray-200 px-6 flex items-center gap-6 sticky top-0 z-10 overflow-x-auto">
|
||||||
<For each={STATUS_TABS}>
|
<For each={STATUS_TABS}>
|
||||||
{(t) => {
|
{(t) => {
|
||||||
const count = countFor(t.key);
|
const count = countFor(t.key);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={`admin-tab${activeTab() === t.key ? ' active' : ''}`}
|
onClick={() => {
|
||||||
onClick={() => {
|
setActiveTab(t.key);
|
||||||
setActiveTab(t.key);
|
setShowDetail(false);
|
||||||
setShowDetail(false);
|
setCurrentPage(1);
|
||||||
setCurrentPage(1);
|
setDocRequestedOnly(false);
|
||||||
setDocRequestedOnly(false);
|
}}
|
||||||
}}
|
class={`flex shrink-0 items-center gap-1.5 py-3 border-b-2 text-sm font-medium transition-colors ${
|
||||||
style="white-space:nowrap;display:flex;align-items:center;gap:6px"
|
activeTab() === t.key
|
||||||
>
|
? 'border-orange-500 text-orange-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{t.label}
|
{t.label}
|
||||||
<Show when={!approvals.loading && count > 0}>
|
<Show when={!approvals.loading && count > 0}>
|
||||||
<span style={`display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;border-radius:999px;font-size:10px;font-weight:700;padding:0 5px;background:${activeTab() === t.key ? t.color : '#e2e8f0'};color:${activeTab() === t.key ? '#fff' : '#64748b'}`}>
|
<span class={`inline-flex items-center justify-center min-w-[18px] h-[18px] rounded-full text-[10px] font-bold px-1 ${
|
||||||
|
activeTab() === t.key ? 'bg-orange-500 text-white' : 'bg-slate-200 text-slate-600'
|
||||||
|
}`}>
|
||||||
{count}
|
{count}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -712,6 +717,8 @@ export default function ApprovalPage() {
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 p-6">
|
||||||
|
|
||||||
<Show when={actionError()}>
|
<Show when={actionError()}>
|
||||||
<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">{actionError()}</div>
|
<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">{actionError()}</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -1058,6 +1065,8 @@ export default function ApprovalPage() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</Show>
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,36 @@
|
||||||
import { A } from '@solidjs/router';
|
|
||||||
import { createResource, createSignal, createMemo, Show, For } from 'solid-js';
|
import { createResource, createSignal, createMemo, Show, For } from 'solid-js';
|
||||||
|
import { Plus, Pencil, Archive, RotateCcw, Trash2, ChevronLeft, ChevronRight } from 'lucide-solid';
|
||||||
import AdminShell from '~/components/AdminShell';
|
import AdminShell from '~/components/AdminShell';
|
||||||
|
|
||||||
const API = '/api/gateway';
|
const API = '/api/gateway';
|
||||||
|
|
||||||
type Department = {
|
type Department = {
|
||||||
id: string;
|
id: string;
|
||||||
|
departmentId?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
departmentName?: string;
|
departmentName?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
createdBy?: string;
|
||||||
|
updatedBy?: string;
|
||||||
is_archived?: boolean;
|
is_archived?: boolean;
|
||||||
status?: string | number;
|
status?: string | number;
|
||||||
created_at?: string;
|
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function loadDepartments(): Promise<Department[]> {
|
type ViewMode = 'list' | 'create' | 'update';
|
||||||
|
|
||||||
|
async function loadDepartments(params: { page: number; limit: number; status: string }): Promise<{ items: Department[]; total: number }> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/api/admin/departments`);
|
const res = await fetch(`${API}/api/admin/departments?page=${params.page}&limit=${params.limit}&status=${params.status}`);
|
||||||
if (!res.ok) throw new Error('Failed to load');
|
if (!res.ok) throw new Error('Failed to load');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return Array.isArray(data) ? data : (data.departments ?? []);
|
const items = Array.isArray(data) ? data : (data.departments ?? []);
|
||||||
|
const total = data.total ?? items.length;
|
||||||
|
return { items, total };
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return { items: [], total: 0 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,21 +49,24 @@ function deptLabel(item: Department): string {
|
||||||
|
|
||||||
function fmtDate(val?: string): string {
|
function fmtDate(val?: string): string {
|
||||||
if (!val) return '—';
|
if (!val) return '—';
|
||||||
try {
|
try { return new Date(val).toLocaleDateString(); } catch { return val; }
|
||||||
return new Date(val).toLocaleDateString();
|
|
||||||
} catch {
|
|
||||||
return val;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DepartmentPage() {
|
export default function DepartmentPage() {
|
||||||
const [departments, { refetch }] = createResource(loadDepartments);
|
const [view, setView] = createSignal<ViewMode>('list');
|
||||||
|
const [statusFilter, setStatusFilter] = createSignal<'active' | 'archived'>('active');
|
||||||
|
const [page, setPage] = createSignal(1);
|
||||||
|
const limit = 10;
|
||||||
|
|
||||||
// tabs
|
const fetchParams = createMemo(() => ({
|
||||||
const [tab, setTab] = createSignal<'active' | 'archived'>('active');
|
page: page(),
|
||||||
|
limit,
|
||||||
|
status: statusFilter() === 'archived' ? '2' : '1',
|
||||||
|
}));
|
||||||
|
|
||||||
// create form
|
const [data, { refetch }] = createResource(fetchParams, loadDepartments);
|
||||||
const [showCreate, setShowCreate] = createSignal(false);
|
|
||||||
|
// form state
|
||||||
const [createName, setCreateName] = createSignal('');
|
const [createName, setCreateName] = createSignal('');
|
||||||
const [createDesc, setCreateDesc] = createSignal('');
|
const [createDesc, setCreateDesc] = createSignal('');
|
||||||
const [creating, setCreating] = createSignal(false);
|
const [creating, setCreating] = createSignal(false);
|
||||||
|
|
@ -68,18 +79,20 @@ export default function DepartmentPage() {
|
||||||
const [saving, setSaving] = createSignal(false);
|
const [saving, setSaving] = createSignal(false);
|
||||||
const [editError, setEditError] = createSignal('');
|
const [editError, setEditError] = createSignal('');
|
||||||
|
|
||||||
// row-level busy
|
|
||||||
const [busy, setBusy] = createSignal('');
|
const [busy, setBusy] = createSignal('');
|
||||||
const [actionError, setActionError] = createSignal('');
|
const [actionError, setActionError] = createSignal('');
|
||||||
|
|
||||||
|
const items = () => data()?.items ?? [];
|
||||||
|
const total = () => data()?.total ?? 0;
|
||||||
|
const totalPages = () => Math.ceil(total() / limit);
|
||||||
|
|
||||||
const filtered = createMemo(() => {
|
const filtered = createMemo(() => {
|
||||||
const all = departments() ?? [];
|
const all = items();
|
||||||
return tab() === 'archived'
|
return statusFilter() === 'archived'
|
||||||
? all.filter((d) => isArchived(d))
|
? all.filter((d) => isArchived(d))
|
||||||
: all.filter((d) => !isArchived(d));
|
: all.filter((d) => !isArchived(d));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- CREATE ----------
|
|
||||||
const handleCreate = async (e: Event) => {
|
const handleCreate = async (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!createName().trim()) return;
|
if (!createName().trim()) return;
|
||||||
|
|
@ -89,19 +102,14 @@ export default function DepartmentPage() {
|
||||||
const res = await fetch(`${API}/api/admin/departments`, {
|
const res = await fetch(`${API}/api/admin/departments`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ name: createName().trim(), description: createDesc().trim() }),
|
||||||
name: createName().trim(),
|
|
||||||
description: createDesc().trim(),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
throw new Error((err as any).message || 'Failed to create');
|
throw new Error((err as any).message || 'Failed to create');
|
||||||
}
|
}
|
||||||
setCreateName('');
|
setCreateName(''); setCreateDesc('');
|
||||||
setCreateDesc('');
|
setView('list'); setStatusFilter('active'); setPage(1);
|
||||||
setShowCreate(false);
|
|
||||||
setTab('active');
|
|
||||||
refetch();
|
refetch();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setCreateError(err.message || 'Failed to create department');
|
setCreateError(err.message || 'Failed to create department');
|
||||||
|
|
@ -110,315 +118,304 @@ export default function DepartmentPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- EDIT ----------
|
|
||||||
const startEdit = (item: Department) => {
|
const startEdit = (item: Department) => {
|
||||||
setEditingId(item.id);
|
setEditingId(item.id); setEditName(deptLabel(item)); setEditDesc(item.description ?? ''); setEditError('');
|
||||||
setEditName(deptLabel(item));
|
|
||||||
setEditDesc(item.description ?? '');
|
|
||||||
setEditError('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelEdit = () => {
|
|
||||||
setEditingId('');
|
|
||||||
setEditError('');
|
|
||||||
};
|
};
|
||||||
|
const cancelEdit = () => { setEditingId(''); setEditError(''); };
|
||||||
|
|
||||||
const handleUpdate = async (id: string) => {
|
const handleUpdate = async (id: string) => {
|
||||||
if (!editName().trim()) return;
|
if (!editName().trim()) return;
|
||||||
setSaving(true);
|
setSaving(true); setEditError('');
|
||||||
setEditError('');
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/api/admin/departments/${id}`, {
|
const res = await fetch(`${API}/api/admin/departments/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ name: editName().trim(), description: editDesc().trim() }),
|
||||||
name: editName().trim(),
|
|
||||||
description: editDesc().trim(),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error((err as any).message || 'Failed to update'); }
|
||||||
const err = await res.json().catch(() => ({}));
|
setEditingId(''); refetch();
|
||||||
throw new Error((err as any).message || 'Failed to update');
|
} catch (err: any) { setEditError(err.message || 'Failed to update department'); }
|
||||||
}
|
finally { setSaving(false); }
|
||||||
setEditingId('');
|
|
||||||
refetch();
|
|
||||||
} catch (err: any) {
|
|
||||||
setEditError(err.message || 'Failed to update department');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- ARCHIVE / RESTORE ----------
|
|
||||||
const handleArchive = async (id: string) => {
|
const handleArchive = async (id: string) => {
|
||||||
setBusy(id);
|
setBusy(id); setActionError('');
|
||||||
setActionError('');
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/api/admin/departments/${id}`, {
|
const res = await fetch(`${API}/api/admin/departments/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ is_archived: true }),
|
body: JSON.stringify({ is_archived: true }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to archive');
|
if (!res.ok) throw new Error('Failed to archive');
|
||||||
refetch();
|
refetch();
|
||||||
} catch (err: any) {
|
} catch (err: any) { setActionError(err.message || 'Failed to archive department'); }
|
||||||
setActionError(err.message || 'Failed to archive department');
|
finally { setBusy(''); }
|
||||||
} finally {
|
|
||||||
setBusy('');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRestore = async (id: string) => {
|
const handleRestore = async (id: string) => {
|
||||||
setBusy(id);
|
setBusy(id); setActionError('');
|
||||||
setActionError('');
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/api/admin/departments/${id}`, {
|
const res = await fetch(`${API}/api/admin/departments/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ is_archived: false }),
|
body: JSON.stringify({ is_archived: false }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to restore');
|
if (!res.ok) throw new Error('Failed to restore');
|
||||||
refetch();
|
refetch();
|
||||||
} catch (err: any) {
|
} catch (err: any) { setActionError(err.message || 'Failed to restore department'); }
|
||||||
setActionError(err.message || 'Failed to restore department');
|
finally { setBusy(''); }
|
||||||
} finally {
|
|
||||||
setBusy('');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- DELETE ----------
|
|
||||||
const handleDelete = async (id: string, name: string) => {
|
const handleDelete = async (id: string, name: string) => {
|
||||||
if (!confirm(`Delete department "${name}"?`)) return;
|
if (!confirm(`Delete department "${name}"?`)) return;
|
||||||
setBusy(id);
|
setBusy(id); setActionError('');
|
||||||
setActionError('');
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/api/admin/departments/${id}`, {
|
const res = await fetch(`${API}/api/admin/departments/${id}`, { method: 'DELETE' });
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error('Failed to delete');
|
if (!res.ok) throw new Error('Failed to delete');
|
||||||
refetch();
|
refetch();
|
||||||
} catch (err: any) {
|
} catch (err: any) { setActionError(err.message || 'Failed to delete department'); }
|
||||||
setActionError(err.message || 'Failed to delete department');
|
finally { setBusy(''); }
|
||||||
} finally {
|
};
|
||||||
setBusy('');
|
|
||||||
}
|
const switchTab = (t: 'active' | 'archived') => {
|
||||||
|
setView('list'); setStatusFilter(t); setPage(1); setEditingId('');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell>
|
<AdminShell>
|
||||||
{/* Header */}
|
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
|
||||||
<div class="mb-6 flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Departments</h1>
|
|
||||||
<p class="mt-1 text-sm text-gray-500">Manage organization departments</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center rounded-lg bg-[#fd6216] px-4 py-2 text-sm font-semibold text-white hover:bg-orange-600 transition-colors"
|
|
||||||
onClick={() => {
|
|
||||||
setShowCreate((v) => !v);
|
|
||||||
setCreateError('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showCreate() ? 'Cancel' : 'Add Department'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Create form */}
|
{/* ── Page header ── */}
|
||||||
<Show when={showCreate()}>
|
<div class="bg-white border-b border-gray-200 px-6 py-4">
|
||||||
<section class="rounded-xl border border-gray-200 bg-white shadow-sm mb-6 rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
<h1 class="text-xl font-semibold text-gray-900">Departments</h1>
|
||||||
<form onSubmit={handleCreate}>
|
<p class="text-sm text-gray-500 mt-0.5">Manage organization structure and units.</p>
|
||||||
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
</div>
|
||||||
<div class="field">
|
|
||||||
<label>Name *</label>
|
{/* ── Tab + action bar ── */}
|
||||||
<input
|
<div class="bg-white border-b border-gray-200 px-6 flex items-center justify-between sticky top-0 z-10">
|
||||||
type="text"
|
<div class="flex gap-8">
|
||||||
required
|
<For each={(['active', 'archived'] as const)}>
|
||||||
placeholder="e.g. Engineering"
|
{(t) => (
|
||||||
value={createName()}
|
<button
|
||||||
onInput={(e) => setCreateName(e.currentTarget.value)}
|
onClick={() => switchTab(t)}
|
||||||
/>
|
class={`py-3 border-b-2 text-sm font-medium capitalize transition-colors ${
|
||||||
</div>
|
view() === 'list' && statusFilter() === t
|
||||||
<div class="field">
|
? 'border-orange-500 text-orange-600'
|
||||||
<label>Description</label>
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
<input
|
}`}
|
||||||
type="text"
|
>
|
||||||
placeholder="Optional description"
|
{t} Departments
|
||||||
value={createDesc()}
|
</button>
|
||||||
onInput={(e) => setCreateDesc(e.currentTarget.value)}
|
)}
|
||||||
/>
|
</For>
|
||||||
</div>
|
<Show when={view() === 'create'}>
|
||||||
</div>
|
<button class="py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium">
|
||||||
<Show when={createError()}>
|
Create Department
|
||||||
<p class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-top:10px">{createError()}</p>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="actions" style="margin-top:16px">
|
<Show when={view() === 'update'}>
|
||||||
<button
|
<button class="py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium">
|
||||||
type="button"
|
Update Department
|
||||||
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={() => {
|
|
||||||
setShowCreate(false);
|
|
||||||
setCreateError('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="inline-flex items-center rounded-lg bg-[#fd6216] px-4 py-2 text-sm font-semibold text-white hover:bg-orange-600 transition-colors" disabled={creating()}>
|
</Show>
|
||||||
{creating() ? 'Saving...' : 'Save'}
|
</div>
|
||||||
</button>
|
<Show when={view() === 'list'}>
|
||||||
</div>
|
<button
|
||||||
</form>
|
onClick={() => { setView('create'); setCreateError(''); setCreateName(''); setCreateDesc(''); }}
|
||||||
</section>
|
class="flex items-center gap-2 rounded-lg bg-[#0a1d37] px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-[#0f2a4e] shadow-sm"
|
||||||
</Show>
|
>
|
||||||
|
<Plus size={16} />
|
||||||
{/* Tabs */}
|
Add Department
|
||||||
<div style="display:flex;gap:24px;border-bottom:1px solid #e2e8f0;margin-bottom:16px">
|
</button>
|
||||||
<button
|
</Show>
|
||||||
class={`admin-tab${tab() === 'active' ? ' active' : ''}`}
|
|
||||||
onClick={() => setTab('active')}
|
|
||||||
>
|
|
||||||
Active
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class={`admin-tab${tab() === 'archived' ? ' active' : ''}`}
|
|
||||||
onClick={() => setTab('archived')}
|
|
||||||
>
|
|
||||||
Archived
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action error */}
|
|
||||||
<Show when={actionError()}>
|
|
||||||
<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">{actionError()}</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<section class="rounded-xl border border-gray-200 bg-white shadow-sm" style="padding:0;overflow:hidden">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Description</th>
|
|
||||||
<th>Created At</th>
|
|
||||||
<th class="text-right">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<Show when={departments.loading}>
|
|
||||||
<tr>
|
|
||||||
<td colspan="4" style="text-align:center;padding:32px;color:#64748b">
|
|
||||||
Loading...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</Show>
|
|
||||||
<Show when={!departments.loading && departments.error}>
|
|
||||||
<tr>
|
|
||||||
<td colspan="4" style="text-align:center;padding:32px;color:#b91c1c">
|
|
||||||
Failed to load. Is the backend running?
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</Show>
|
|
||||||
<Show when={!departments.loading && !departments.error && filtered().length === 0}>
|
|
||||||
<tr>
|
|
||||||
<td colspan="4" style="text-align:center;padding:32px;color:#94a3b8">
|
|
||||||
No departments found.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</Show>
|
|
||||||
<Show when={!departments.loading && !departments.error && filtered().length > 0}>
|
|
||||||
<For each={filtered()}>
|
|
||||||
{(item) => (
|
|
||||||
<>
|
|
||||||
<tr>
|
|
||||||
<td style="font-weight:600;color:#0f172a">{deptLabel(item)}</td>
|
|
||||||
<td style="color:#475569">{item.description || '—'}</td>
|
|
||||||
<td style="color:#475569">{fmtDate(item.createdAt || item.created_at)}</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>
|
|
||||||
<Show when={tab() === 'active'}>
|
|
||||||
<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={busy() === item.id}
|
|
||||||
onClick={() => handleArchive(item.id)}
|
|
||||||
>
|
|
||||||
{busy() === item.id ? '...' : 'Archive'}
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
<Show when={tab() === 'archived'}>
|
|
||||||
<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={busy() === item.id}
|
|
||||||
onClick={() => handleRestore(item.id)}
|
|
||||||
>
|
|
||||||
{busy() === item.id ? '...' : 'Restore'}
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 transition-colors"
|
|
||||||
disabled={busy() === item.id}
|
|
||||||
onClick={() => handleDelete(item.id, deptLabel(item))}
|
|
||||||
>
|
|
||||||
{busy() === item.id ? '...' : 'Delete'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/* Inline edit row */}
|
|
||||||
<Show when={editingId() === item.id}>
|
|
||||||
<tr>
|
|
||||||
<td colspan="4" style="background:#f8fafc;padding:16px">
|
|
||||||
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2" style="margin-bottom:10px">
|
|
||||||
<div class="field">
|
|
||||||
<label>Name *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={editName()}
|
|
||||||
onInput={(e) => setEditName(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>Description</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editDesc()}
|
|
||||||
onInput={(e) => setEditDesc(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={editError()}>
|
|
||||||
<p class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-bottom:8px">{editError()}</p>
|
|
||||||
</Show>
|
|
||||||
<div class="actions">
|
|
||||||
<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" type="button" onClick={cancelEdit}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center rounded-lg bg-[#fd6216] px-4 py-2 text-sm font-semibold text-white hover:bg-orange-600 transition-colors"
|
|
||||||
type="button"
|
|
||||||
disabled={saving()}
|
|
||||||
onClick={() => handleUpdate(item.id)}
|
|
||||||
>
|
|
||||||
{saving() ? 'Saving...' : 'Save'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</Show>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
{/* ── Content ── */}
|
||||||
|
<div class="flex-1">
|
||||||
|
|
||||||
|
{/* Create form */}
|
||||||
|
<Show when={view() === 'create'}>
|
||||||
|
<div class="bg-white border-b border-gray-200 px-6 py-6">
|
||||||
|
<form onSubmit={handleCreate} class="grid grid-cols-1 gap-6 md:grid-cols-2 max-w-4xl">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1.5 block text-sm font-medium text-gray-700">Department Name *</label>
|
||||||
|
<input
|
||||||
|
type="text" required
|
||||||
|
placeholder="e.g. Engineering"
|
||||||
|
value={createName()}
|
||||||
|
onInput={(e) => setCreateName(e.currentTarget.value)}
|
||||||
|
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]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1.5 block text-sm font-medium text-gray-700">Description</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Optional description"
|
||||||
|
value={createDesc()}
|
||||||
|
onInput={(e) => setCreateDesc(e.currentTarget.value)}
|
||||||
|
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]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Show when={createError()}>
|
||||||
|
<p class="md:col-span-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{createError()}</p>
|
||||||
|
</Show>
|
||||||
|
<div class="md:col-span-2 flex justify-end gap-3 pt-2 border-t border-gray-100">
|
||||||
|
<button type="button" onClick={() => setView('list')} class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={creating()} class="rounded-lg bg-[#0a1d37] px-6 py-2 text-sm font-medium text-white hover:bg-[#0f2a4e] transition-colors disabled:opacity-60">
|
||||||
|
{creating() ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* List view */}
|
||||||
|
<Show when={view() === 'list'}>
|
||||||
|
<div class="p-6">
|
||||||
|
<Show when={actionError()}>
|
||||||
|
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{actionError()}</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="table-card">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="data-table w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Created By</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Last Updated By</th>
|
||||||
|
<th>Last Updated At</th>
|
||||||
|
<th class="text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<Show when={data.loading}>
|
||||||
|
<tr><td colspan="8" class="py-10 text-center text-sm text-slate-400">Loading…</td></tr>
|
||||||
|
</Show>
|
||||||
|
<Show when={!data.loading && data.error}>
|
||||||
|
<tr><td colspan="8" class="py-10 text-center text-sm text-red-500">Failed to load. Is the backend running?</td></tr>
|
||||||
|
</Show>
|
||||||
|
<Show when={!data.loading && !data.error && filtered().length === 0}>
|
||||||
|
<tr><td colspan="8" class="py-10 text-center text-sm text-slate-400">No departments found.</td></tr>
|
||||||
|
</Show>
|
||||||
|
<For each={filtered()}>
|
||||||
|
{(item) => (
|
||||||
|
<>
|
||||||
|
<tr class="group hover:bg-slate-50">
|
||||||
|
<td class="font-mono text-xs text-slate-500">{item.departmentId || item.id.slice(0, 8)}</td>
|
||||||
|
<td class="font-semibold text-slate-900">{deptLabel(item)}</td>
|
||||||
|
<td class="text-slate-500">{item.description || '—'}</td>
|
||||||
|
<td class="text-blue-600 hover:underline"><a href={`mailto:${item.createdBy}`}>{item.createdBy || '—'}</a></td>
|
||||||
|
<td class="text-slate-500">{fmtDate(item.createdAt || item.created_at)}</td>
|
||||||
|
<td class="text-blue-600 hover:underline"><a href={`mailto:${item.updatedBy || item.createdBy}`}>{item.updatedBy || item.createdBy || '—'}</a></td>
|
||||||
|
<td class="text-slate-500">{fmtDate(item.updatedAt)}</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center justify-end gap-1.5">
|
||||||
|
<Show when={!isArchived(item)}>
|
||||||
|
<button
|
||||||
|
title="Edit"
|
||||||
|
onClick={() => startEdit(item)}
|
||||||
|
class="action-btn flex items-center justify-center hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Pencil size={14} class="text-gray-600" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
title="Archive"
|
||||||
|
disabled={busy() === item.id}
|
||||||
|
onClick={() => handleArchive(item.id)}
|
||||||
|
class="action-btn flex items-center justify-center hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Archive size={14} class="text-gray-600" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<Show when={isArchived(item)}>
|
||||||
|
<button
|
||||||
|
title="Restore"
|
||||||
|
disabled={busy() === item.id}
|
||||||
|
onClick={() => handleRestore(item.id)}
|
||||||
|
class="action-btn flex items-center justify-center hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} class="text-green-600" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<button
|
||||||
|
title="Delete"
|
||||||
|
disabled={busy() === item.id}
|
||||||
|
onClick={() => handleDelete(item.id, deptLabel(item))}
|
||||||
|
class="action-btn flex items-center justify-center border-red-100 bg-red-50 hover:bg-red-100 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} class="text-red-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/* Inline edit row */}
|
||||||
|
<Show when={editingId() === item.id}>
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="bg-slate-50 px-6 py-4">
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 max-w-2xl">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-medium text-gray-700">Name *</label>
|
||||||
|
<input type="text" required value={editName()} onInput={(e) => setEditName(e.currentTarget.value)}
|
||||||
|
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]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-medium text-gray-700">Description</label>
|
||||||
|
<input type="text" value={editDesc()} onInput={(e) => setEditDesc(e.currentTarget.value)}
|
||||||
|
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]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={editError()}>
|
||||||
|
<p class="mt-3 rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">{editError()}</p>
|
||||||
|
</Show>
|
||||||
|
<div class="mt-3 flex gap-2">
|
||||||
|
<button type="button" onClick={cancelEdit} class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">Cancel</button>
|
||||||
|
<button type="button" disabled={saving()} onClick={() => handleUpdate(item.id)}
|
||||||
|
class="rounded-lg bg-[#0a1d37] px-4 py-2 text-sm font-medium text-white hover:bg-[#0f2a4e] transition-colors disabled:opacity-60">
|
||||||
|
{saving() ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<Show when={totalPages() > 1}>
|
||||||
|
<div class="mt-4 flex items-center justify-between border-t border-gray-200 pt-4">
|
||||||
|
<span class="text-sm text-gray-500">Page {page()} of {totalPages()}</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
disabled={page() === 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
class="rounded-lg border border-gray-200 p-2 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={page() >= totalPages()}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
class="rounded-lg border border-gray-200 p-2 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { A } from '@solidjs/router';
|
|
||||||
import { createResource, createSignal, createMemo, Show, For } from 'solid-js';
|
import { createResource, createSignal, createMemo, Show, For } from 'solid-js';
|
||||||
|
import { Pencil, Archive, RotateCcw, ChevronLeft, ChevronRight } from 'lucide-solid';
|
||||||
import AdminShell from '~/components/AdminShell';
|
import AdminShell from '~/components/AdminShell';
|
||||||
|
|
||||||
const API = '/api/gateway';
|
const API = '/api/gateway';
|
||||||
|
|
@ -12,30 +12,40 @@ type Department = {
|
||||||
|
|
||||||
type Designation = {
|
type Designation = {
|
||||||
id: string;
|
id: string;
|
||||||
|
designationId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
departmentId?: string;
|
departmentId?: string;
|
||||||
departmentName?: string;
|
departmentName?: string;
|
||||||
department?: string;
|
department?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
activeUsersCount?: number;
|
||||||
|
activeJobsCount?: number;
|
||||||
|
createdBy?: string;
|
||||||
|
updatedBy?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
is_archived?: boolean;
|
is_archived?: boolean;
|
||||||
status?: string;
|
status?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function loadDesignations(): Promise<Designation[]> {
|
type ViewMode = 'list' | 'create' | 'edit';
|
||||||
|
|
||||||
|
async function loadDesignations(params: { page: number; limit: number; status: string }): Promise<{ items: Designation[]; total: number }> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/api/admin/designations`);
|
const res = await fetch(`${API}/api/admin/designations?page=${params.page}&limit=${params.limit}&status=${params.status}`);
|
||||||
if (!res.ok) throw new Error('Failed to load');
|
if (!res.ok) throw new Error('Failed to load');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return Array.isArray(data) ? data : (data.designations ?? []);
|
const items = Array.isArray(data) ? data : (data.designations ?? []);
|
||||||
|
return { items, total: data.total ?? items.length };
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return { items: [], total: 0 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDepartments(): Promise<Department[]> {
|
async function loadDepartments(): Promise<Department[]> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/api/admin/departments`);
|
const res = await fetch(`${API}/api/admin/departments?status=1&limit=200`);
|
||||||
if (!res.ok) throw new Error('Failed to load');
|
if (!res.ok) throw new Error('Failed');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return Array.isArray(data) ? data : (data.departments ?? []);
|
return Array.isArray(data) ? data : (data.departments ?? []);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -43,14 +53,14 @@ async function loadDepartments(): Promise<Department[]> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function deptDisplay(item: Designation): string {
|
|
||||||
return item.departmentName || item.department || '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
function deptName(d: Department): string {
|
function deptName(d: Department): string {
|
||||||
return d.departmentName || d.name || d.id;
|
return d.departmentName || d.name || d.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deptDisplay(item: Designation): string {
|
||||||
|
return item.departmentName || item.department || '—';
|
||||||
|
}
|
||||||
|
|
||||||
function isArchived(item: Designation): boolean {
|
function isArchived(item: Designation): boolean {
|
||||||
if (item.is_archived !== undefined) return item.is_archived;
|
if (item.is_archived !== undefined) return item.is_archived;
|
||||||
if (item.status !== undefined) {
|
if (item.status !== undefined) {
|
||||||
|
|
@ -60,366 +70,344 @@ function isArchived(item: Designation): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtDate(val?: string): string {
|
||||||
|
if (!val) return '—';
|
||||||
|
try { return new Date(val).toLocaleDateString(); } catch { return val; }
|
||||||
|
}
|
||||||
|
|
||||||
export default function DesignationPage() {
|
export default function DesignationPage() {
|
||||||
const [designations, { refetch }] = createResource(loadDesignations);
|
const [view, setView] = createSignal<ViewMode>('list');
|
||||||
|
const [statusFilter, setStatusFilter] = createSignal<'active' | 'archived'>('active');
|
||||||
|
const [page, setPage] = createSignal(1);
|
||||||
|
const limit = 10;
|
||||||
|
|
||||||
|
const fetchParams = createMemo(() => ({
|
||||||
|
page: page(),
|
||||||
|
limit,
|
||||||
|
status: statusFilter() === 'archived' ? 'ARCHIVED' : 'ACTIVE',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const [data, { refetch }] = createResource(fetchParams, loadDesignations);
|
||||||
const [departments] = createResource(loadDepartments);
|
const [departments] = createResource(loadDepartments);
|
||||||
|
|
||||||
// tabs
|
// editing
|
||||||
const [tab, setTab] = createSignal<'active' | 'archived'>('active');
|
const [editingDesignation, setEditingDesignation] = createSignal<Designation | null>(null);
|
||||||
|
|
||||||
// create form
|
// form state (shared create/edit)
|
||||||
const [showCreate, setShowCreate] = createSignal(false);
|
const [formName, setFormName] = createSignal('');
|
||||||
const [createName, setCreateName] = createSignal('');
|
const [formDeptId, setFormDeptId] = createSignal('');
|
||||||
const [createDeptId, setCreateDeptId] = createSignal('');
|
const [formDesc, setFormDesc] = createSignal('');
|
||||||
const [createDesc, setCreateDesc] = createSignal('');
|
const [formLoading, setFormLoading] = createSignal(false);
|
||||||
const [creating, setCreating] = createSignal(false);
|
const [formError, setFormError] = createSignal('');
|
||||||
const [createError, setCreateError] = createSignal('');
|
|
||||||
|
|
||||||
// inline edit
|
const [busy, setBusy] = createSignal('');
|
||||||
const [editingId, setEditingId] = createSignal('');
|
|
||||||
const [editName, setEditName] = createSignal('');
|
|
||||||
const [editDeptId, setEditDeptId] = createSignal('');
|
|
||||||
const [editDesc, setEditDesc] = createSignal('');
|
|
||||||
const [saving, setSaving] = createSignal(false);
|
|
||||||
const [editError, setEditError] = createSignal('');
|
|
||||||
|
|
||||||
// row busy / errors
|
|
||||||
const [deleting, setDeleting] = createSignal('');
|
|
||||||
const [actionError, setActionError] = createSignal('');
|
const [actionError, setActionError] = createSignal('');
|
||||||
|
|
||||||
|
const items = () => data()?.items ?? [];
|
||||||
|
const total = () => data()?.total ?? 0;
|
||||||
|
const totalPages = () => Math.ceil(total() / limit);
|
||||||
|
|
||||||
const filtered = createMemo(() => {
|
const filtered = createMemo(() => {
|
||||||
const all = designations() ?? [];
|
const all = items();
|
||||||
return tab() === 'archived'
|
return statusFilter() === 'archived'
|
||||||
? all.filter((d) => isArchived(d))
|
? all.filter((d) => isArchived(d))
|
||||||
: all.filter((d) => !isArchived(d));
|
: all.filter((d) => !isArchived(d));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- CREATE ----------
|
const resetForm = () => {
|
||||||
|
const depts = departments() ?? [];
|
||||||
|
setFormName(''); setFormDeptId(depts[0]?.id ?? ''); setFormDesc(''); setFormError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
resetForm(); setEditingDesignation(null); setView('create');
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (item: Designation) => {
|
||||||
|
setEditingDesignation(item);
|
||||||
|
setFormName(item.name); setFormDeptId(item.departmentId ?? ''); setFormDesc(item.description ?? ''); setFormError('');
|
||||||
|
setView('edit');
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreate = async (e: Event) => {
|
const handleCreate = async (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!createName().trim() || !createDeptId()) return;
|
if (!formName().trim() || !formDeptId()) return;
|
||||||
setCreating(true);
|
setFormLoading(true); setFormError('');
|
||||||
setCreateError('');
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/api/admin/designations`, {
|
const res = await fetch(`${API}/api/admin/designations`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ name: formName().trim(), department_id: formDeptId(), description: formDesc().trim() }),
|
||||||
name: createName().trim(),
|
|
||||||
department_id: createDeptId(),
|
|
||||||
description: createDesc().trim(),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error((err as any).message || 'Failed to create'); }
|
||||||
const err = await res.json().catch(() => ({}));
|
resetForm(); setView('list'); setStatusFilter('active'); setPage(1); refetch();
|
||||||
throw new Error((err as any).message || 'Failed to create');
|
} catch (err: any) { setFormError(err.message || 'Failed to create designation'); }
|
||||||
}
|
finally { setFormLoading(false); }
|
||||||
setCreateName('');
|
|
||||||
setCreateDeptId('');
|
|
||||||
setCreateDesc('');
|
|
||||||
setShowCreate(false);
|
|
||||||
setTab('active');
|
|
||||||
refetch();
|
|
||||||
} catch (err: any) {
|
|
||||||
setCreateError(err.message || 'Failed to create designation');
|
|
||||||
} finally {
|
|
||||||
setCreating(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- INLINE EDIT ----------
|
const handleUpdate = async (e: Event) => {
|
||||||
const startEdit = (item: Designation) => {
|
e.preventDefault();
|
||||||
setEditingId(item.id);
|
const editing = editingDesignation();
|
||||||
setEditName(item.name);
|
if (!editing || !formName().trim() || !formDeptId()) return;
|
||||||
setEditDeptId(item.departmentId ?? '');
|
setFormLoading(true); setFormError('');
|
||||||
setEditDesc(item.description ?? '');
|
|
||||||
setEditError('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelEdit = () => {
|
|
||||||
setEditingId('');
|
|
||||||
setEditError('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdate = async (id: string) => {
|
|
||||||
if (!editName().trim()) return;
|
|
||||||
setSaving(true);
|
|
||||||
setEditError('');
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/api/admin/designations/${id}`, {
|
const res = await fetch(`${API}/api/admin/designations/${editing.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ name: formName().trim(), department_id: formDeptId(), description: formDesc().trim() }),
|
||||||
name: editName().trim(),
|
|
||||||
department_id: editDeptId(),
|
|
||||||
description: editDesc().trim(),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error((err as any).message || 'Failed to update'); }
|
||||||
const err = await res.json().catch(() => ({}));
|
setEditingDesignation(null); setView('list'); refetch();
|
||||||
throw new Error((err as any).message || 'Failed to update');
|
} catch (err: any) { setFormError(err.message || 'Failed to update designation'); }
|
||||||
}
|
finally { setFormLoading(false); }
|
||||||
setEditingId('');
|
|
||||||
refetch();
|
|
||||||
} catch (err: any) {
|
|
||||||
setEditError(err.message || 'Failed to update designation');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- DELETE ----------
|
const handleArchive = async (id: string) => {
|
||||||
const handleDelete = async (id: string, name: string) => {
|
setBusy(id); setActionError('');
|
||||||
if (!confirm(`Delete designation "${name}"?`)) return;
|
|
||||||
setDeleting(id);
|
|
||||||
setActionError('');
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/api/admin/designations/${id}`, {
|
const res = await fetch(`${API}/api/admin/designations/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ is_archived: true }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to delete');
|
if (!res.ok) throw new Error('Failed to archive');
|
||||||
refetch();
|
refetch();
|
||||||
} catch (err: any) {
|
} catch (err: any) { setActionError(err.message || 'Failed to archive designation'); }
|
||||||
setActionError(err.message || 'Failed to delete designation');
|
finally { setBusy(''); }
|
||||||
} finally {
|
};
|
||||||
setDeleting('');
|
|
||||||
}
|
const handleRestore = async (id: string) => {
|
||||||
|
setBusy(id); setActionError('');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/admin/designations/${id}`, {
|
||||||
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ is_archived: false }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to restore');
|
||||||
|
refetch();
|
||||||
|
} catch (err: any) { setActionError(err.message || 'Failed to restore designation'); }
|
||||||
|
finally { setBusy(''); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormContent = () => {
|
||||||
|
const depts = () => departments() ?? [];
|
||||||
|
return (
|
||||||
|
<form onSubmit={view() === 'create' ? handleCreate : handleUpdate} class="max-w-4xl space-y-6">
|
||||||
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1.5 block text-sm font-medium text-gray-700">Designation Name *</label>
|
||||||
|
<input
|
||||||
|
type="text" required
|
||||||
|
value={formName()}
|
||||||
|
onInput={(e) => setFormName(e.currentTarget.value)}
|
||||||
|
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]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1.5 block text-sm font-medium text-gray-700">Department *</label>
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
value={formDeptId()}
|
||||||
|
onChange={(e) => setFormDeptId(e.currentTarget.value)}
|
||||||
|
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]"
|
||||||
|
>
|
||||||
|
<Show when={depts().length === 0}>
|
||||||
|
<option value="" disabled>No active departments found</option>
|
||||||
|
</Show>
|
||||||
|
<For each={depts()}>
|
||||||
|
{(d) => <option value={d.id}>{deptName(d)}</option>}
|
||||||
|
</For>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="mb-1.5 block text-sm font-medium text-gray-700">Description</label>
|
||||||
|
<textarea
|
||||||
|
rows="4"
|
||||||
|
value={formDesc()}
|
||||||
|
onInput={(e) => setFormDesc(e.currentTarget.value)}
|
||||||
|
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]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={formError()}>
|
||||||
|
<p class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{formError()}</p>
|
||||||
|
</Show>
|
||||||
|
<div class="flex justify-end gap-3 border-t border-gray-100 pt-4">
|
||||||
|
<button type="button" onClick={() => { setView('list'); setEditingDesignation(null); }}
|
||||||
|
class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={formLoading() || depts().length === 0}
|
||||||
|
class="rounded-lg bg-[#0a1d37] px-6 py-2 text-sm font-medium text-white hover:bg-[#0f2a4e] transition-colors disabled:opacity-60">
|
||||||
|
{formLoading() ? (view() === 'create' ? 'Creating…' : 'Updating…') : (view() === 'create' ? 'Create Designation' : 'Update Designation')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell>
|
<AdminShell>
|
||||||
{/* Header */}
|
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
|
||||||
<div class="mb-6 flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Designations</h1>
|
|
||||||
<p class="mt-1 text-sm text-gray-500">Manage job designations</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center rounded-lg bg-[#fd6216] px-4 py-2 text-sm font-semibold text-white hover:bg-orange-600 transition-colors"
|
|
||||||
onClick={() => {
|
|
||||||
setShowCreate((v) => !v);
|
|
||||||
setCreateError('');
|
|
||||||
// Pre-select first department when opening
|
|
||||||
const depts = departments() ?? [];
|
|
||||||
if (!createDeptId() && depts.length > 0) setCreateDeptId(depts[0].id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showCreate() ? 'Cancel' : 'Add Designation'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Create form */}
|
{/* ── Page header ── */}
|
||||||
<Show when={showCreate()}>
|
<div class="bg-white border-b border-gray-200 px-6 py-4">
|
||||||
<section class="rounded-xl border border-gray-200 bg-white shadow-sm mb-6 rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
<h1 class="text-xl font-semibold text-gray-900">Designations</h1>
|
||||||
<form onSubmit={handleCreate}>
|
<p class="text-sm text-gray-500 mt-0.5">Manage job titles and roles within departments.</p>
|
||||||
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
</div>
|
||||||
<div class="field">
|
|
||||||
<label>Name *</label>
|
{/* ── Tab + action bar ── */}
|
||||||
<input
|
<div class="bg-white border-b border-gray-200 px-6 flex items-center justify-between sticky top-0 z-10">
|
||||||
type="text"
|
<div class="flex gap-8">
|
||||||
required
|
<button
|
||||||
placeholder="e.g. Senior Engineer"
|
onClick={() => { setView('list'); setEditingDesignation(null); }}
|
||||||
value={createName()}
|
class={`py-3 border-b-2 text-sm font-medium transition-colors ${
|
||||||
onInput={(e) => setCreateName(e.currentTarget.value)}
|
view() === 'list' ? 'border-orange-500 text-orange-600' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
/>
|
}`}
|
||||||
</div>
|
>
|
||||||
<div class="field">
|
Designations List
|
||||||
<label>Department *</label>
|
</button>
|
||||||
<select
|
<button
|
||||||
required
|
onClick={openCreate}
|
||||||
value={createDeptId()}
|
class={`py-3 border-b-2 text-sm font-medium transition-colors ${
|
||||||
onChange={(e) => setCreateDeptId(e.currentTarget.value)}
|
view() === 'create' ? 'border-orange-500 text-orange-600' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
>
|
}`}
|
||||||
<option value="">Select a department...</option>
|
>
|
||||||
<Show when={departments.loading}>
|
Add Designation
|
||||||
<option disabled>Loading departments...</option>
|
</button>
|
||||||
</Show>
|
<Show when={view() === 'edit' && editingDesignation()}>
|
||||||
<For each={departments() ?? []}>
|
<button class="py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium">
|
||||||
{(d) => <option value={d.id}>{deptName(d)}</option>}
|
Edit: {editingDesignation()?.name}
|
||||||
</For>
|
</button>
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="field" style="grid-column:1/-1">
|
|
||||||
<label>Description</label>
|
|
||||||
<textarea
|
|
||||||
placeholder="Optional description"
|
|
||||||
rows="3"
|
|
||||||
value={createDesc()}
|
|
||||||
onInput={(e) => setCreateDesc(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={createError()}>
|
|
||||||
<p class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-top:10px">{createError()}</p>
|
|
||||||
</Show>
|
</Show>
|
||||||
<div class="actions" style="margin-top:16px">
|
</div>
|
||||||
<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={() => {
|
|
||||||
setShowCreate(false);
|
|
||||||
setCreateError('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="inline-flex items-center rounded-lg bg-[#fd6216] px-4 py-2 text-sm font-semibold text-white hover:bg-orange-600 transition-colors"
|
|
||||||
disabled={creating() || (departments() ?? []).length === 0}
|
|
||||||
>
|
|
||||||
{creating() ? 'Saving...' : 'Save'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Status filter — only in list view */}
|
||||||
<div style="display:flex;gap:24px;border-bottom:1px solid #e2e8f0;margin-bottom:16px">
|
<Show when={view() === 'list'}>
|
||||||
<button
|
<select
|
||||||
class={`admin-tab${tab() === 'active' ? ' active' : ''}`}
|
value={statusFilter()}
|
||||||
onClick={() => setTab('active')}
|
onChange={(e) => { setStatusFilter(e.currentTarget.value as 'active' | 'archived'); setPage(1); }}
|
||||||
>
|
class="rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37]"
|
||||||
Active
|
>
|
||||||
</button>
|
<option value="active">Active</option>
|
||||||
<button
|
<option value="archived">Archived</option>
|
||||||
class={`admin-tab${tab() === 'archived' ? ' active' : ''}`}
|
</select>
|
||||||
onClick={() => setTab('archived')}
|
</Show>
|
||||||
>
|
|
||||||
Archived
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action error */}
|
|
||||||
<Show when={actionError()}>
|
|
||||||
<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">{actionError()}</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<section class="rounded-xl border border-gray-200 bg-white shadow-sm" style="padding:0;overflow:hidden">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Department</th>
|
|
||||||
<th>Description</th>
|
|
||||||
<th class="text-right">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<Show when={designations.loading}>
|
|
||||||
<tr>
|
|
||||||
<td colspan="4" style="text-align:center;padding:32px;color:#64748b">
|
|
||||||
Loading...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</Show>
|
|
||||||
<Show when={!designations.loading && designations.error}>
|
|
||||||
<tr>
|
|
||||||
<td colspan="4" style="text-align:center;padding:32px;color:#b91c1c">
|
|
||||||
Failed to load. Is the backend running?
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</Show>
|
|
||||||
<Show when={!designations.loading && !designations.error && filtered().length === 0}>
|
|
||||||
<tr>
|
|
||||||
<td colspan="4" style="text-align:center;padding:32px;color:#94a3b8">
|
|
||||||
No designations found.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</Show>
|
|
||||||
<Show when={!designations.loading && !designations.error && filtered().length > 0}>
|
|
||||||
<For each={filtered()}>
|
|
||||||
{(item) => (
|
|
||||||
<>
|
|
||||||
<tr>
|
|
||||||
<td style="font-weight:600;color:#0f172a">{item.name}</td>
|
|
||||||
<td style="color:#475569">{deptDisplay(item)}</td>
|
|
||||||
<td style="color:#475569">{item.description || '—'}</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-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 transition-colors"
|
|
||||||
disabled={deleting() === item.id}
|
|
||||||
onClick={() => handleDelete(item.id, item.name)}
|
|
||||||
>
|
|
||||||
{deleting() === item.id ? '...' : 'Delete'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/* Inline edit row */}
|
|
||||||
<Show when={editingId() === item.id}>
|
|
||||||
<tr>
|
|
||||||
<td colspan="4" style="background:#f8fafc;padding:16px">
|
|
||||||
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2" style="margin-bottom:10px">
|
|
||||||
<div class="field">
|
|
||||||
<label>Name *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={editName()}
|
|
||||||
onInput={(e) => setEditName(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>Department</label>
|
|
||||||
<select
|
|
||||||
value={editDeptId()}
|
|
||||||
onChange={(e) => setEditDeptId(e.currentTarget.value)}
|
|
||||||
>
|
|
||||||
<option value="">Select a department...</option>
|
|
||||||
<For each={departments() ?? []}>
|
|
||||||
{(d) => (
|
|
||||||
<option value={d.id}>{deptName(d)}</option>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="field" style="grid-column:1/-1">
|
|
||||||
<label>Description</label>
|
|
||||||
<textarea
|
|
||||||
rows="3"
|
|
||||||
value={editDesc()}
|
|
||||||
onInput={(e) => setEditDesc(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={editError()}>
|
|
||||||
<p class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-bottom:8px">{editError()}</p>
|
|
||||||
</Show>
|
|
||||||
<div class="actions">
|
|
||||||
<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" type="button" onClick={cancelEdit}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center rounded-lg bg-[#fd6216] px-4 py-2 text-sm font-semibold text-white hover:bg-orange-600 transition-colors"
|
|
||||||
type="button"
|
|
||||||
disabled={saving()}
|
|
||||||
onClick={() => handleUpdate(item.id)}
|
|
||||||
>
|
|
||||||
{saving() ? 'Saving...' : 'Save'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</Show>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
{/* ── Content ── */}
|
||||||
|
<div class="flex-1">
|
||||||
|
|
||||||
|
{/* Create / Edit form */}
|
||||||
|
<Show when={view() === 'create' || view() === 'edit'}>
|
||||||
|
<div class="bg-white px-6 py-6 border-b border-gray-100">
|
||||||
|
<FormContent />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<Show when={view() === 'list'}>
|
||||||
|
<div class="p-6">
|
||||||
|
<Show when={actionError()}>
|
||||||
|
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{actionError()}</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="table-card">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="data-table w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Department</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Active Users</th>
|
||||||
|
<th>Active Jobs</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created By</th>
|
||||||
|
<th>Created At</th>
|
||||||
|
<th>Last Updated By</th>
|
||||||
|
<th>Last Updated At</th>
|
||||||
|
<th class="text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<Show when={data.loading}>
|
||||||
|
<tr><td colspan="12" class="py-10 text-center text-sm text-slate-400">Loading…</td></tr>
|
||||||
|
</Show>
|
||||||
|
<Show when={!data.loading && data.error}>
|
||||||
|
<tr><td colspan="12" class="py-10 text-center text-sm text-red-500">Failed to load. Is the backend running?</td></tr>
|
||||||
|
</Show>
|
||||||
|
<Show when={!data.loading && !data.error && filtered().length === 0}>
|
||||||
|
<tr><td colspan="12" class="py-10 text-center text-sm text-slate-400">No designations found.</td></tr>
|
||||||
|
</Show>
|
||||||
|
<For each={filtered()}>
|
||||||
|
{(item) => {
|
||||||
|
const archived = () => isArchived(item);
|
||||||
|
return (
|
||||||
|
<tr class="hover:bg-slate-50">
|
||||||
|
<td class="font-mono text-xs text-slate-500">{(item.designationId || item.id).slice(0, 16)}</td>
|
||||||
|
<td class="font-semibold text-slate-900">{item.name}</td>
|
||||||
|
<td class="text-slate-500">{deptDisplay(item)}</td>
|
||||||
|
<td class="text-slate-500 max-w-xs truncate" title={item.description}>{item.description || '—'}</td>
|
||||||
|
<td class="text-slate-500">{item.activeUsersCount ?? 0}</td>
|
||||||
|
<td class="text-slate-500">{item.activeJobsCount ?? 0}</td>
|
||||||
|
<td>
|
||||||
|
<span class={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${archived() ? 'bg-yellow-100 text-yellow-800' : 'bg-green-100 text-green-800'}`}>
|
||||||
|
{archived() ? 'ARCHIVED' : 'ACTIVE'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-blue-600 hover:underline"><a href={`mailto:${item.createdBy}`}>{item.createdBy || '—'}</a></td>
|
||||||
|
<td class="text-slate-500">{fmtDate(item.createdAt)}</td>
|
||||||
|
<td class="text-blue-600 hover:underline"><a href={`mailto:${item.updatedBy}`}>{item.updatedBy || '—'}</a></td>
|
||||||
|
<td class="text-slate-500">{fmtDate(item.updatedAt)}</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center justify-end gap-1.5">
|
||||||
|
<button title="Edit" onClick={() => openEdit(item)}
|
||||||
|
class="action-btn flex items-center justify-center hover:bg-gray-50 transition-colors">
|
||||||
|
<Pencil size={14} class="text-gray-600" />
|
||||||
|
</button>
|
||||||
|
<Show when={!archived()}>
|
||||||
|
<button title="Archive" disabled={busy() === item.id} onClick={() => handleArchive(item.id)}
|
||||||
|
class="action-btn flex items-center justify-center hover:bg-gray-50 transition-colors">
|
||||||
|
<Archive size={14} class="text-gray-600" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<Show when={archived()}>
|
||||||
|
<button title="Restore" disabled={busy() === item.id} onClick={() => handleRestore(item.id)}
|
||||||
|
class="action-btn flex items-center justify-center hover:bg-gray-50 transition-colors">
|
||||||
|
<RotateCcw size={14} class="text-green-600" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={totalPages() > 1}>
|
||||||
|
<div class="mt-4 flex items-center justify-between border-t border-gray-200 pt-4">
|
||||||
|
<span class="text-sm text-gray-500">Page {page()} of {totalPages()}</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button disabled={page() === 1} onClick={() => setPage((p) => p - 1)}
|
||||||
|
class="rounded-lg border border-gray-200 p-2 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 transition-colors">
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<button disabled={page() >= totalPages()} onClick={() => setPage((p) => p + 1)}
|
||||||
|
class="rounded-lg border border-gray-200 p-2 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 transition-colors">
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { A, useNavigate, useSearchParams } from '@solidjs/router';
|
import { A } from '@solidjs/router';
|
||||||
import { createResource, createSignal, Show, For, onMount } from 'solid-js';
|
import { createResource, createSignal, Show, For, onMount } from 'solid-js';
|
||||||
|
import { Pencil, Trash2, UserCheck, UserX } from 'lucide-solid';
|
||||||
import AdminShell from '~/components/AdminShell';
|
import AdminShell from '~/components/AdminShell';
|
||||||
|
|
||||||
const API = '/api/gateway';
|
const API = '/api/gateway';
|
||||||
|
|
@ -58,307 +59,226 @@ function isActive(e: Employee): boolean {
|
||||||
return s === 'ACTIVE' || s === 'TRUE' || s === '1';
|
return s === 'ACTIVE' || s === 'TRUE' || s === '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
type EmployeesPageProps = {
|
export default function EmployeesPage() {
|
||||||
initialView?: 'list' | 'create';
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function EmployeesPage(props: EmployeesPageProps = {}) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
|
|
||||||
const [employees, { refetch }] = createResource(loadEmployees);
|
const [employees, { refetch }] = createResource(loadEmployees);
|
||||||
const [roles] = createResource(loadRoles);
|
const [roles] = createResource(loadRoles);
|
||||||
|
const [view, setView] = createSignal<'list' | 'create'>('list');
|
||||||
|
|
||||||
// tabs: list | create
|
// create form
|
||||||
const [view, setView] = createSignal<'list' | 'create'>(props.initialView || 'list');
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (searchParams.tab === 'create') {
|
|
||||||
setView('create');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// create form fields
|
|
||||||
const [formName, setFormName] = createSignal('');
|
const [formName, setFormName] = createSignal('');
|
||||||
const [formEmail, setFormEmail] = createSignal('');
|
const [formEmail, setFormEmail] = createSignal('');
|
||||||
const [formPassword, setFormPassword] = createSignal('');
|
const [formPassword, setFormPassword] = createSignal('');
|
||||||
const [formRoleId, setFormRoleId] = createSignal('');
|
const [formRoleId, setFormRoleId] = createSignal('');
|
||||||
const [creating, setCreating] = createSignal(false);
|
const [creating, setCreating] = createSignal(false);
|
||||||
const [createError, setCreateError] = createSignal('');
|
const [createError, setCreateError] = createSignal('');
|
||||||
const [createSuccess, setCreateSuccess] = createSignal('');
|
|
||||||
|
|
||||||
// row actions
|
// row actions
|
||||||
const [deleting, setDeleting] = createSignal('');
|
|
||||||
const [toggling, setToggling] = createSignal('');
|
const [toggling, setToggling] = createSignal('');
|
||||||
|
const [deleting, setDeleting] = createSignal('');
|
||||||
const [actionError, setActionError] = createSignal('');
|
const [actionError, setActionError] = createSignal('');
|
||||||
|
|
||||||
const resetCreateForm = () => {
|
const resetForm = () => {
|
||||||
setFormName('');
|
setFormName(''); setFormEmail(''); setFormPassword(''); setFormRoleId(''); setCreateError('');
|
||||||
setFormEmail('');
|
|
||||||
setFormPassword('');
|
|
||||||
setFormRoleId('');
|
|
||||||
setCreateError('');
|
|
||||||
setCreateSuccess('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- CREATE ----------
|
|
||||||
const handleCreate = async (e: Event) => {
|
const handleCreate = async (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!formName().trim() || !formEmail().trim() || !formPassword().trim()) return;
|
if (!formName().trim() || !formEmail().trim() || !formPassword().trim()) return;
|
||||||
setCreating(true);
|
setCreating(true); setCreateError('');
|
||||||
setCreateError('');
|
|
||||||
setCreateSuccess('');
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/api/admin/employees`, {
|
const res = await fetch(`${API}/api/admin/employees`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ name: formName().trim(), email: formEmail().trim(), password: formPassword(), role_id: formRoleId() || undefined }),
|
||||||
name: formName().trim(),
|
|
||||||
email: formEmail().trim(),
|
|
||||||
password: formPassword(),
|
|
||||||
role_id: formRoleId() || undefined,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error((err as any).message || 'Failed to create'); }
|
||||||
const err = await res.json().catch(() => ({}));
|
resetForm(); setView('list'); refetch();
|
||||||
throw new Error((err as any).message || 'Failed to create');
|
} catch (err: any) { setCreateError(err.message || 'Failed to create internal user'); }
|
||||||
}
|
finally { setCreating(false); }
|
||||||
setCreateSuccess('Internal user created successfully.');
|
|
||||||
resetCreateForm();
|
|
||||||
setView('list');
|
|
||||||
refetch();
|
|
||||||
} catch (err: any) {
|
|
||||||
setCreateError(err.message || 'Failed to create internal user');
|
|
||||||
} finally {
|
|
||||||
setCreating(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- TOGGLE STATUS ----------
|
|
||||||
const handleToggleStatus = async (id: string, current: boolean) => {
|
const handleToggleStatus = async (id: string, current: boolean) => {
|
||||||
setToggling(id);
|
setToggling(id); setActionError('');
|
||||||
setActionError('');
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/api/admin/employees/${id}`, {
|
const res = await fetch(`${API}/api/admin/employees/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ is_active: !current }),
|
body: JSON.stringify({ is_active: !current }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to update status');
|
if (!res.ok) throw new Error('Failed to update status');
|
||||||
refetch();
|
refetch();
|
||||||
} catch (err: any) {
|
} catch (err: any) { setActionError(err.message || 'Failed to update status'); }
|
||||||
setActionError(err.message || 'Failed to update status');
|
finally { setToggling(''); }
|
||||||
} finally {
|
|
||||||
setToggling('');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- DELETE ----------
|
|
||||||
const handleDelete = async (id: string, name: string) => {
|
const handleDelete = async (id: string, name: string) => {
|
||||||
if (!confirm(`Delete internal user "${name}"?`)) return;
|
if (!confirm(`Delete internal user "${name}"?`)) return;
|
||||||
setDeleting(id);
|
setDeleting(id); setActionError('');
|
||||||
setActionError('');
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/api/admin/employees/${id}`, {
|
const res = await fetch(`${API}/api/admin/employees/${id}`, { method: 'DELETE' });
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error('Failed to delete');
|
if (!res.ok) throw new Error('Failed to delete');
|
||||||
refetch();
|
refetch();
|
||||||
} catch (err: any) {
|
} catch (err: any) { setActionError(err.message || 'Failed to delete internal user'); }
|
||||||
setActionError(err.message || 'Failed to delete internal user');
|
finally { setDeleting(''); }
|
||||||
} finally {
|
|
||||||
setDeleting('');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell>
|
<AdminShell>
|
||||||
{/* Header */}
|
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
|
||||||
<div class="mb-6 flex items-start justify-between gap-4">
|
|
||||||
<div>
|
{/* ── Page header ── */}
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Internal User Management</h1>
|
<div class="bg-white border-b border-gray-200 px-6 py-4">
|
||||||
|
<h1 class="text-xl font-semibold text-gray-900">Internal User Management</h1>
|
||||||
|
<p class="text-sm text-gray-500 mt-0.5">Manage internal team members and their access.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Tab bar ── */}
|
||||||
|
<div class="bg-white border-b border-gray-200 px-6 flex items-center justify-between sticky top-0 z-10">
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<button
|
||||||
|
onClick={() => setView('list')}
|
||||||
|
class={`py-3 border-b-2 text-sm font-medium transition-colors ${view() === 'list' ? 'border-orange-500 text-orange-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||||||
|
>
|
||||||
|
View Internal Users
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { resetForm(); setView('create'); }}
|
||||||
|
class={`py-3 border-b-2 text-sm font-medium transition-colors ${view() === 'create' ? 'border-orange-500 text-orange-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||||||
|
>
|
||||||
|
Add Internal User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Content ── */}
|
||||||
|
<div class="flex-1">
|
||||||
|
|
||||||
|
{/* Create form */}
|
||||||
|
<Show when={view() === 'create'}>
|
||||||
|
<div class="bg-white border-b border-gray-100 px-6 py-6">
|
||||||
|
<form onSubmit={handleCreate} class="max-w-4xl space-y-6">
|
||||||
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1.5 block text-sm font-medium text-gray-700">Full Name *</label>
|
||||||
|
<input type="text" required placeholder="e.g. John Doe" value={formName()} onInput={(e) => setFormName(e.currentTarget.value)}
|
||||||
|
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]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1.5 block text-sm font-medium text-gray-700">Email *</label>
|
||||||
|
<input type="email" required placeholder="e.g. john@company.com" value={formEmail()} onInput={(e) => setFormEmail(e.currentTarget.value)}
|
||||||
|
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]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1.5 block text-sm font-medium text-gray-700">Password *</label>
|
||||||
|
<input type="password" required placeholder="Set a password" value={formPassword()} onInput={(e) => setFormPassword(e.currentTarget.value)}
|
||||||
|
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]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1.5 block text-sm font-medium text-gray-700">Role</label>
|
||||||
|
<select value={formRoleId()} onChange={(e) => setFormRoleId(e.currentTarget.value)}
|
||||||
|
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]">
|
||||||
|
<option value="">Select a role…</option>
|
||||||
|
<Show when={roles.loading}><option disabled>Loading roles…</option></Show>
|
||||||
|
<For each={roles() ?? []}>{(r) => <option value={r.id}>{r.name}</option>}</For>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={createError()}>
|
||||||
|
<p class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{createError()}</p>
|
||||||
|
</Show>
|
||||||
|
<div class="flex justify-end gap-3 border-t border-gray-100 pt-4">
|
||||||
|
<button type="button" onClick={() => { resetForm(); setView('list'); }}
|
||||||
|
class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={creating()}
|
||||||
|
class="rounded-lg bg-[#0a1d37] px-6 py-2 text-sm font-medium text-white hover:bg-[#0f2a4e] transition-colors disabled:opacity-60">
|
||||||
|
{creating() ? 'Creating…' : 'Create Internal User'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<Show when={view() === 'list'}>
|
||||||
|
<div class="p-6">
|
||||||
|
<Show when={actionError()}>
|
||||||
|
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{actionError()}</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="table-card">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="data-table w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<Show when={employees.loading}>
|
||||||
|
<tr><td colspan="5" class="py-10 text-center text-sm text-slate-400">Loading…</td></tr>
|
||||||
|
</Show>
|
||||||
|
<Show when={!employees.loading && employees.error}>
|
||||||
|
<tr><td colspan="5" class="py-10 text-center text-sm text-red-500">Failed to load. Is the backend running?</td></tr>
|
||||||
|
</Show>
|
||||||
|
<Show when={!employees.loading && !employees.error && (employees()?.length ?? 0) === 0}>
|
||||||
|
<tr><td colspan="5" class="py-10 text-center text-sm text-slate-400">No internal users found.</td></tr>
|
||||||
|
</Show>
|
||||||
|
<For each={employees()}>
|
||||||
|
{(item) => {
|
||||||
|
const active = () => isActive(item);
|
||||||
|
return (
|
||||||
|
<tr class="hover:bg-slate-50">
|
||||||
|
<td class="font-semibold text-slate-900">{employeeName(item)}</td>
|
||||||
|
<td class="text-slate-500">{item.email}</td>
|
||||||
|
<td class="text-slate-500">{roleName(item)}</td>
|
||||||
|
<td>
|
||||||
|
<span class={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${active() ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-700'}`}>
|
||||||
|
{active() ? 'ACTIVE' : 'INACTIVE'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center justify-end gap-1.5">
|
||||||
|
<A href={`/admin/employees/${item.id}/edit`}
|
||||||
|
class="action-btn flex items-center justify-center hover:bg-gray-50 transition-colors" title="Edit">
|
||||||
|
<Pencil size={14} class="text-gray-600" />
|
||||||
|
</A>
|
||||||
|
<button
|
||||||
|
title={active() ? 'Deactivate' : 'Activate'}
|
||||||
|
disabled={toggling() === item.id}
|
||||||
|
onClick={() => handleToggleStatus(item.id, active())}
|
||||||
|
class={`action-btn flex items-center justify-center transition-colors ${active() ? 'border-red-100 bg-red-50 hover:bg-red-100' : 'hover:bg-green-50 border-green-100 bg-green-50'}`}
|
||||||
|
>
|
||||||
|
<Show when={active()} fallback={<UserCheck size={14} class="text-green-600" />}>
|
||||||
|
<UserX size={14} class="text-red-600" />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
title="Delete"
|
||||||
|
disabled={deleting() === item.id}
|
||||||
|
onClick={() => handleDelete(item.id, employeeName(item))}
|
||||||
|
class="action-btn flex items-center justify-center border-red-100 bg-red-50 hover:bg-red-100 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} class="text-red-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div style="display:flex;border-bottom:2px solid #e2e8f0;margin-bottom:24px;gap:0;overflow-x:auto;">
|
|
||||||
<button
|
|
||||||
class={`admin-tab${view() === 'list' ? ' active' : ''}`}
|
|
||||||
onClick={() => setView('list')}
|
|
||||||
>
|
|
||||||
View Internal Users
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class={`admin-tab${view() === 'create' ? ' active' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
resetCreateForm();
|
|
||||||
setView('create');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add Internal User
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Success notice (shown briefly after create) */}
|
|
||||||
<Show when={createSuccess()}>
|
|
||||||
<div class="notice" style="margin-bottom:12px">{createSuccess()}</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* ===== LIST VIEW ===== */}
|
|
||||||
<Show when={view() === 'list'}>
|
|
||||||
<Show when={actionError()}>
|
|
||||||
<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">{actionError()}</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<section class="rounded-xl border border-gray-200 bg-white shadow-sm" style="padding:0;overflow:hidden">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Email</th>
|
|
||||||
<th>Role</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th class="text-right">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<Show when={employees.loading}>
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" style="text-align:center;padding:32px;color:#64748b">
|
|
||||||
Loading...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</Show>
|
|
||||||
<Show when={!employees.loading && employees.error}>
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" style="text-align:center;padding:32px;color:#b91c1c">
|
|
||||||
Failed to load. Is the backend running?
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</Show>
|
|
||||||
<Show when={!employees.loading && !employees.error && (employees()?.length ?? 0) === 0}>
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" style="text-align:center;padding:32px;color:#94a3b8">
|
|
||||||
No internal users found.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</Show>
|
|
||||||
<Show when={!employees.loading && !employees.error && (employees()?.length ?? 0) > 0}>
|
|
||||||
<For each={employees()}>
|
|
||||||
{(item) => (
|
|
||||||
<tr>
|
|
||||||
<td style="font-weight:600;color:#0f172a">{employeeName(item)}</td>
|
|
||||||
<td style="color:#475569">{item.email}</td>
|
|
||||||
<td style="color:#475569">{roleName(item)}</td>
|
|
||||||
<td>
|
|
||||||
<span class={`inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600${isActive(item) ? ' active' : ''}`}>
|
|
||||||
{isActive(item) ? 'ACTIVE' : 'INACTIVE'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="flex items-center justify-end gap-1">
|
|
||||||
<A 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" href={`/admin/employees/${item.id}/edit`}>
|
|
||||||
Edit
|
|
||||||
</A>
|
|
||||||
<button
|
|
||||||
class={isActive(item) ? 'inline-flex items-center rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 transition-colors' : 'inline-flex items-center rounded-lg bg-[#fd6216] px-4 py-2 text-sm font-semibold text-white hover:bg-orange-600 transition-colors'}
|
|
||||||
disabled={toggling() === item.id}
|
|
||||||
onClick={() => handleToggleStatus(item.id, isActive(item))}
|
|
||||||
>
|
|
||||||
{toggling() === item.id ? '...' : isActive(item) ? 'Deactivate' : 'Activate'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 transition-colors"
|
|
||||||
disabled={deleting() === item.id}
|
|
||||||
onClick={() => handleDelete(item.id, employeeName(item))}
|
|
||||||
>
|
|
||||||
{deleting() === item.id ? '...' : 'Delete'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* ===== CREATE VIEW ===== */}
|
|
||||||
<Show when={view() === 'create'}>
|
|
||||||
<section class="rounded-xl border border-gray-200 bg-white shadow-sm mb-6 rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
|
||||||
<form onSubmit={handleCreate}>
|
|
||||||
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<div class="field">
|
|
||||||
<label>Full Name *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
placeholder="e.g. John Doe"
|
|
||||||
value={formName()}
|
|
||||||
onInput={(e) => setFormName(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>Email *</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
placeholder="e.g. john@company.com"
|
|
||||||
value={formEmail()}
|
|
||||||
onInput={(e) => setFormEmail(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>Password *</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
placeholder="Set a password"
|
|
||||||
value={formPassword()}
|
|
||||||
onInput={(e) => setFormPassword(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>Role</label>
|
|
||||||
<select
|
|
||||||
value={formRoleId()}
|
|
||||||
onChange={(e) => setFormRoleId(e.currentTarget.value)}
|
|
||||||
>
|
|
||||||
<option value="">Select a role...</option>
|
|
||||||
<Show when={roles.loading}>
|
|
||||||
<option disabled>Loading roles...</option>
|
|
||||||
</Show>
|
|
||||||
<For each={roles() ?? []}>
|
|
||||||
{(r) => <option value={r.id}>{r.name}</option>}
|
|
||||||
</For>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={createError()}>
|
|
||||||
<p class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-top:10px">{createError()}</p>
|
|
||||||
</Show>
|
|
||||||
<div class="actions" style="margin-top:16px">
|
|
||||||
<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={() => {
|
|
||||||
resetCreateForm();
|
|
||||||
setView('list');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" class="inline-flex items-center rounded-lg bg-[#fd6216] px-4 py-2 text-sm font-semibold text-white hover:bg-orange-600 transition-colors" disabled={creating()}>
|
|
||||||
{creating() ? 'Creating...' : 'Create Internal User'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</Show>
|
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { A } from '@solidjs/router';
|
import { A } from '@solidjs/router';
|
||||||
import { createResource, createSignal, Show } from 'solid-js';
|
import { createResource, createSignal, Show, For } from 'solid-js';
|
||||||
import AdminShell from '~/components/AdminShell';
|
|
||||||
import { Eye, Pencil, Trash2 } from 'lucide-solid';
|
import { Eye, Pencil, Trash2 } from 'lucide-solid';
|
||||||
|
import AdminShell from '~/components/AdminShell';
|
||||||
|
|
||||||
const API = '/api/gateway';
|
const API = '/api/gateway';
|
||||||
|
|
||||||
|
|
@ -36,9 +36,8 @@ export default function InternalRolesPage() {
|
||||||
|
|
||||||
const handleDelete = async (id: string, name: string) => {
|
const handleDelete = async (id: string, name: string) => {
|
||||||
if (!confirm(`Delete role "${name}"? This cannot be undone.`)) return;
|
if (!confirm(`Delete role "${name}"? This cannot be undone.`)) return;
|
||||||
|
setDeleting(id); setDeleteError('');
|
||||||
try {
|
try {
|
||||||
setDeleting(id);
|
|
||||||
setDeleteError('');
|
|
||||||
const res = await fetch(`${API}/api/admin/roles/${id}`, { method: 'DELETE' });
|
const res = await fetch(`${API}/api/admin/roles/${id}`, { method: 'DELETE' });
|
||||||
if (!res.ok) throw new Error('Failed to delete role');
|
if (!res.ok) throw new Error('Failed to delete role');
|
||||||
refetch();
|
refetch();
|
||||||
|
|
@ -51,93 +50,101 @@ export default function InternalRolesPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell>
|
<AdminShell>
|
||||||
<div class="mb-8">
|
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
|
||||||
<h1 class="text-[52px] font-extrabold tracking-tight text-[#071b3d] sm:text-[36px] lg:text-[52px]">Roles Management</h1>
|
|
||||||
<p class="mt-2 text-[17px] text-[#4b5563]">Configure and maintain internal system roles and access privileges.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="hidden" aria-label="Role Management Navigation">
|
{/* ── Page header ── */}
|
||||||
<A class="hidden" href="/admin/roles">Internal Roles</A>
|
<div class="bg-white border-b border-gray-200 px-6 py-4">
|
||||||
<A class="hidden" href="/admin/runtime-roles">External Runtime Roles</A>
|
<h1 class="text-xl font-semibold text-gray-900">Internal Role Management</h1>
|
||||||
<A class="hidden" href="/admin/onboarding-schemas">Onboarding Schemas</A>
|
<p class="text-sm text-gray-500 mt-0.5">Manage internal employee roles and permissions.</p>
|
||||||
</nav>
|
|
||||||
|
|
||||||
<Show when={deleteError()}>
|
|
||||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{deleteError()}</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<section class="overflow-hidden rounded-[22px] border border-[#d8dbe5] bg-white shadow-[0_14px_28px_-20px_rgba(15,23,42,0.35)]">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full min-w-[860px] text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr class="bg-[#071b3d] text-white">
|
|
||||||
<th class="px-8 py-5 text-left text-[14px] font-semibold uppercase tracking-[0.08em]">ID</th>
|
|
||||||
<th class="px-8 py-5 text-left text-[14px] font-semibold uppercase tracking-[0.08em]">Name</th>
|
|
||||||
<th class="px-8 py-5 text-left text-[14px] font-semibold uppercase tracking-[0.08em]">Issue Type</th>
|
|
||||||
<th class="px-8 py-5 text-center text-[14px] font-semibold uppercase tracking-[0.08em]">Edit</th>
|
|
||||||
<th class="px-8 py-5 text-center text-[14px] font-semibold uppercase tracking-[0.08em]">Delete</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<Show when={roles.loading}>
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" style="text-align:center;padding:32px;color:#64748b;">Loading internal roles...</td>
|
|
||||||
</tr>
|
|
||||||
</Show>
|
|
||||||
<Show when={!roles.loading && roles.error}>
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" style="text-align:center;padding:32px;color:#b91c1c;">Failed to load roles. Is the backend running?</td>
|
|
||||||
</tr>
|
|
||||||
</Show>
|
|
||||||
<Show when={!roles.loading && !roles.error && roles()?.length === 0}>
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" style="text-align:center;padding:32px;color:#94a3b8;">No internal roles found. Create your first role.</td>
|
|
||||||
</tr>
|
|
||||||
</Show>
|
|
||||||
<Show when={!roles.loading && !roles.error && (roles()?.length ?? 0) > 0}>
|
|
||||||
{roles()!.map((role) => (
|
|
||||||
<tr class="border-b border-[#e4e7ef] text-[17px]">
|
|
||||||
<td class="px-8 py-7 font-medium text-[#364152]">{role.code || role.id?.slice(0, 6).toUpperCase() || 'ROL247'}</td>
|
|
||||||
<td class="px-8 py-7 font-semibold text-[#0f172a]">{role.name}</td>
|
|
||||||
<td class="px-8 py-7">
|
|
||||||
<A class="inline-flex items-center gap-2 font-semibold text-[#fd6216] hover:text-orange-700" href={`/admin/roles/${role.id}`} title="View Role" aria-label={`View ${role.name}`}>
|
|
||||||
<span>View</span>
|
|
||||||
<Eye size={16} />
|
|
||||||
</A>
|
|
||||||
</td>
|
|
||||||
<td class="px-8 py-7 text-center">
|
|
||||||
<A class="inline-flex h-9 w-9 items-center justify-center rounded-md text-[#071b3d] hover:bg-slate-100" href={`/admin/roles/${role.id}/edit`} title="Edit Role" aria-label={`Edit ${role.name}`}>
|
|
||||||
<Pencil size={17} />
|
|
||||||
</A>
|
|
||||||
</td>
|
|
||||||
<td class="px-8 py-7 text-center">
|
|
||||||
<button
|
|
||||||
class="inline-flex h-9 w-9 items-center justify-center rounded-md text-[#c81e1e] hover:bg-red-50 disabled:opacity-60"
|
|
||||||
disabled={deleting() === role.id}
|
|
||||||
onClick={() => handleDelete(role.id, role.name)}
|
|
||||||
title="Delete Role"
|
|
||||||
aria-label={`Delete ${role.name}`}
|
|
||||||
>
|
|
||||||
{deleting() === role.id ? '...' : <Trash2 size={17} />}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</Show>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between border-t border-[#e4e7ef] px-8 py-5">
|
|
||||||
<p class="text-[14px] font-semibold uppercase tracking-[0.1em] text-[#485163]">Showing 1 to 5 of {(roles()?.length || 0) || 5} entries</p>
|
{/* ── Tab bar ── */}
|
||||||
<div class="flex items-center gap-2">
|
<div class="bg-white border-b border-gray-200 px-6 flex items-center justify-between sticky top-0 z-10">
|
||||||
<button class="h-11 min-w-11 rounded-xl border border-[#d4d8e2] bg-white px-3 text-[#111827]">{'<'}</button>
|
<div class="flex gap-8">
|
||||||
<button class="h-11 min-w-11 rounded-xl bg-[#fd6216] px-3 font-bold text-white">1</button>
|
<A href="/admin/roles"
|
||||||
<button class="h-11 min-w-11 rounded-xl border border-[#d4d8e2] bg-white px-3 font-bold text-[#111827]">2</button>
|
class="py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium">
|
||||||
<button class="h-11 min-w-11 rounded-xl border border-[#d4d8e2] bg-white px-3 font-bold text-[#111827]">3</button>
|
Roles
|
||||||
<button class="h-11 min-w-11 rounded-xl border border-[#d4d8e2] bg-white px-3 text-[#111827]">{'>'}</button>
|
</A>
|
||||||
|
<A href="/admin/roles/create"
|
||||||
|
class="py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors">
|
||||||
|
Create Role
|
||||||
|
</A>
|
||||||
|
<A href="/admin/roles/templates"
|
||||||
|
class="py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors">
|
||||||
|
View Roles
|
||||||
|
</A>
|
||||||
|
</div>
|
||||||
|
<A
|
||||||
|
href="/admin/roles/create"
|
||||||
|
class="flex items-center gap-2 rounded-lg bg-[#0a1d37] px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-[#0f2a4e] shadow-sm"
|
||||||
|
>
|
||||||
|
Create Internal Role
|
||||||
|
</A>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Content ── */}
|
||||||
|
<div class="p-6">
|
||||||
|
<Show when={deleteError()}>
|
||||||
|
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{deleteError()}</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="table-card">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="data-table w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th class="text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<Show when={roles.loading}>
|
||||||
|
<tr><td colspan="3" class="py-10 text-center text-sm text-slate-400">Loading internal roles…</td></tr>
|
||||||
|
</Show>
|
||||||
|
<Show when={!roles.loading && roles.error}>
|
||||||
|
<tr><td colspan="3" class="py-10 text-center text-sm text-red-500">Failed to load roles. Is the backend running?</td></tr>
|
||||||
|
</Show>
|
||||||
|
<Show when={!roles.loading && !roles.error && (roles()?.length ?? 0) === 0}>
|
||||||
|
<tr><td colspan="3" class="py-10 text-center text-sm text-slate-400">No internal roles found. Create your first role.</td></tr>
|
||||||
|
</Show>
|
||||||
|
<For each={roles()}>
|
||||||
|
{(role) => (
|
||||||
|
<tr class="hover:bg-slate-50">
|
||||||
|
<td>
|
||||||
|
<p class="font-medium text-gray-900">{role.name}</p>
|
||||||
|
<p class="mt-0.5 text-xs text-slate-500">{role.code || role.id?.slice(0, 8) || '—'}</p>
|
||||||
|
</td>
|
||||||
|
<td class="text-slate-600">{role.description || 'No description added yet.'}</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<A href={`/admin/roles/${role.id}`} title="View Role"
|
||||||
|
class="action-btn flex items-center justify-center hover:bg-gray-50 transition-colors">
|
||||||
|
<Eye size={14} class="text-gray-600" />
|
||||||
|
</A>
|
||||||
|
<A href={`/admin/roles/${role.id}/edit`} title="Edit Role"
|
||||||
|
class="action-btn flex items-center justify-center hover:bg-gray-50 transition-colors">
|
||||||
|
<Pencil size={14} class="text-gray-600" />
|
||||||
|
</A>
|
||||||
|
<button
|
||||||
|
title="Delete Role"
|
||||||
|
disabled={deleting() === role.id}
|
||||||
|
onClick={() => handleDelete(role.id, role.name)}
|
||||||
|
class="action-btn flex items-center justify-center border-red-100 bg-red-50 hover:bg-red-100 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} class="text-red-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue