434 lines
22 KiB
TypeScript
434 lines
22 KiB
TypeScript
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';
|
|
|
|
const API = '/api/gateway';
|
|
|
|
type Department = {
|
|
id: string;
|
|
departmentId?: string;
|
|
name?: string;
|
|
departmentName?: string;
|
|
description?: string;
|
|
createdBy?: string;
|
|
updatedBy?: string;
|
|
is_archived?: boolean;
|
|
status?: string | number;
|
|
createdAt?: string;
|
|
created_at?: string;
|
|
updatedAt?: string;
|
|
};
|
|
|
|
type ViewMode = 'list' | 'create' | 'update';
|
|
|
|
async function loadDepartments(params: { page: number; limit: number; status: string }): Promise<{ items: Department[]; total: number }> {
|
|
try {
|
|
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');
|
|
const data = await res.json();
|
|
const items = Array.isArray(data) ? data : (data.departments ?? []);
|
|
const total = data.total ?? items.length;
|
|
return { items, total };
|
|
} catch {
|
|
return { items: [], total: 0 };
|
|
}
|
|
}
|
|
|
|
function isArchived(item: Department): boolean {
|
|
if (item.is_archived !== undefined) return item.is_archived;
|
|
if (item.status !== undefined) {
|
|
const s = String(item.status).toUpperCase();
|
|
return s === 'ARCHIVED' || s === '2';
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function deptLabel(item: Department): string {
|
|
return item.departmentName || item.name || '—';
|
|
}
|
|
|
|
function fmtDate(val?: string): string {
|
|
if (!val) return '—';
|
|
try { return new Date(val).toLocaleDateString(); } catch { return val; }
|
|
}
|
|
|
|
export default function DepartmentPage() {
|
|
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' ? '2' : '1',
|
|
}));
|
|
|
|
const [data, { refetch }] = createResource(fetchParams, loadDepartments);
|
|
|
|
// form state
|
|
const [createName, setCreateName] = createSignal('');
|
|
const [createDesc, setCreateDesc] = createSignal('');
|
|
const [creating, setCreating] = createSignal(false);
|
|
const [createError, setCreateError] = createSignal('');
|
|
|
|
// inline edit
|
|
const [editingId, setEditingId] = createSignal('');
|
|
const [editName, setEditName] = createSignal('');
|
|
const [editDesc, setEditDesc] = createSignal('');
|
|
const [saving, setSaving] = createSignal(false);
|
|
const [editError, setEditError] = createSignal('');
|
|
|
|
const [busy, setBusy] = createSignal('');
|
|
const [actionError, setActionError] = createSignal('');
|
|
|
|
const items = () => data()?.items ?? [];
|
|
const total = () => data()?.total ?? 0;
|
|
const totalPages = () => Math.ceil(total() / limit);
|
|
|
|
const filtered = createMemo(() => {
|
|
const all = items();
|
|
return statusFilter() === 'archived'
|
|
? all.filter((d) => isArchived(d))
|
|
: all.filter((d) => !isArchived(d));
|
|
});
|
|
|
|
const handleCreate = async (e: Event) => {
|
|
e.preventDefault();
|
|
if (!createName().trim()) return;
|
|
setCreating(true);
|
|
setCreateError('');
|
|
try {
|
|
const res = await fetch(`${API}/api/admin/departments`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: createName().trim(), description: createDesc().trim() }),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error((err as any).message || 'Failed to create');
|
|
}
|
|
setCreateName(''); setCreateDesc('');
|
|
setView('list'); setStatusFilter('active'); setPage(1);
|
|
refetch();
|
|
} catch (err: any) {
|
|
setCreateError(err.message || 'Failed to create department');
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
const startEdit = (item: Department) => {
|
|
setEditingId(item.id); setEditName(deptLabel(item)); setEditDesc(item.description ?? ''); setEditError('');
|
|
};
|
|
const cancelEdit = () => { setEditingId(''); setEditError(''); };
|
|
|
|
const handleUpdate = async (id: string) => {
|
|
if (!editName().trim()) return;
|
|
setSaving(true); setEditError('');
|
|
try {
|
|
const res = await fetch(`${API}/api/admin/departments/${id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: editName().trim(), description: editDesc().trim() }),
|
|
});
|
|
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error((err as any).message || 'Failed to update'); }
|
|
setEditingId(''); refetch();
|
|
} catch (err: any) { setEditError(err.message || 'Failed to update department'); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
const handleArchive = async (id: string) => {
|
|
setBusy(id); setActionError('');
|
|
try {
|
|
const res = await fetch(`${API}/api/admin/departments/${id}`, {
|
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ is_archived: true }),
|
|
});
|
|
if (!res.ok) throw new Error('Failed to archive');
|
|
refetch();
|
|
} catch (err: any) { setActionError(err.message || 'Failed to archive department'); }
|
|
finally { setBusy(''); }
|
|
};
|
|
|
|
const handleRestore = async (id: string) => {
|
|
setBusy(id); setActionError('');
|
|
try {
|
|
const res = await fetch(`${API}/api/admin/departments/${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 department'); }
|
|
finally { setBusy(''); }
|
|
};
|
|
|
|
const handleDelete = async (id: string, name: string) => {
|
|
if (!confirm(`Delete department "${name}"?`)) return;
|
|
setBusy(id); setActionError('');
|
|
try {
|
|
const res = await fetch(`${API}/api/admin/departments/${id}`, { method: 'DELETE' });
|
|
if (!res.ok) throw new Error('Failed to delete');
|
|
refetch();
|
|
} catch (err: any) { setActionError(err.message || 'Failed to delete department'); }
|
|
finally { setBusy(''); }
|
|
};
|
|
|
|
const switchTab = (t: 'active' | 'archived') => {
|
|
setView('list'); setStatusFilter(t); setPage(1); setEditingId('');
|
|
};
|
|
|
|
return (
|
|
<AdminShell>
|
|
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
|
|
<div class="p-6 flex-1 max-w-[1600px] mx-auto w-full">
|
|
{/* Header & Title */}
|
|
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-8">
|
|
<div>
|
|
<h1 class="text-[32px] font-bold text-[#050026] leading-tight">Department Management</h1>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<button class="inline-flex h-11 items-center justify-center rounded-xl border border-[#d9dde6] bg-white px-6 text-[14px] font-semibold text-[#050026] transition-colors hover:bg-[#f8f9fc]">
|
|
Export Data
|
|
</button>
|
|
<button
|
|
class="inline-flex h-11 items-center justify-center rounded-xl bg-[#050026] px-6 text-[14px] font-semibold text-white transition-colors hover:bg-[#0a0044]"
|
|
onClick={() => { setView('create'); setCreateError(''); setCreateName(''); setCreateDesc(''); }}
|
|
>
|
|
<span class="mr-2 text-lg leading-none">+</span> Create Department
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<Show when={view() === 'create'}>
|
|
{/* Create form */}
|
|
<div class="bg-white border focus-within:border-[#0a1d37] border-[#e2e6ee] rounded-3xl p-6 mb-8 max-w-4xl shadow-sm">
|
|
<h2 class="text-[22px] font-bold text-[#050026] mb-6">Create New Department</h2>
|
|
<form onSubmit={handleCreate} class="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-semibold text-[#383e5c]">Department Name *</label>
|
|
<input
|
|
type="text" required
|
|
placeholder="e.g. Engineering"
|
|
value={createName()}
|
|
onInput={(e) => setCreateName(e.currentTarget.value)}
|
|
class="h-11 w-full rounded-xl border border-[#d9dde6] bg-[#f9fafb] px-4 text-[14px] outline-none transition-colors focus:border-[#050026] focus:bg-white"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label class="mb-1.5 block text-sm font-semibold text-[#383e5c]">Description</label>
|
|
<input
|
|
type="text"
|
|
placeholder="Optional description"
|
|
value={createDesc()}
|
|
onInput={(e) => setCreateDesc(e.currentTarget.value)}
|
|
class="h-11 w-full rounded-xl border border-[#d9dde6] bg-[#f9fafb] px-4 text-[14px] outline-none transition-colors focus:border-[#050026] focus:bg-white"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Show when={createError()}>
|
|
<p class="rounded-xl 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 pt-4 border-t border-[#e2e6ee]">
|
|
<button type="button" onClick={() => setView('list')} class="h-11 rounded-xl border border-[#d9dde6] bg-white px-6 text-[14px] font-semibold text-[#050026] hover:bg-[#f8f9fc] transition-colors">
|
|
Cancel
|
|
</button>
|
|
<button type="submit" disabled={creating()} class="h-11 rounded-xl bg-[#050026] px-6 text-[14px] font-semibold text-white hover:bg-[#0a0044] transition-colors disabled:opacity-70">
|
|
{creating() ? 'Saving…' : 'Save'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={view() === 'list'}>
|
|
{/* Main Table Section */}
|
|
<section class="rounded-[24px] border border-[#e2e6ee] bg-[#f7f7f8] p-1.5 h-full">
|
|
<div class="rounded-[20px] bg-white p-5">
|
|
|
|
{/* Error Message */}
|
|
<Show when={actionError()}>
|
|
<div class="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{actionError()}</div>
|
|
</Show>
|
|
|
|
{/* Tabs */}
|
|
<div class="flex gap-6 mb-6 border-b border-[#e2e6ee]">
|
|
<For each={(['active', 'archived'] as const)}>
|
|
{(t) => (
|
|
<button
|
|
onClick={() => switchTab(t)}
|
|
class={`pb-3 text-[14px] font-bold capitalize transition-colors border-b-2 ${
|
|
statusFilter() === t
|
|
? 'border-[#050026] text-[#050026]'
|
|
: 'border-transparent text-[#8087a0] hover:text-[#050026]'
|
|
}`}
|
|
>
|
|
{t} Department
|
|
</button>
|
|
)}
|
|
</For>
|
|
</div>
|
|
|
|
{/* Filters Row */}
|
|
<div class="flex flex-col gap-4 md:flex-row items-center mb-6">
|
|
<div class="relative w-full md:w-[320px]">
|
|
<div class="absolute inset-y-0 left-4 flex items-center pointer-events-none">
|
|
<svg class="h-5 w-5 text-[#a0aabf]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
</svg>
|
|
</div>
|
|
<input
|
|
type="text"
|
|
placeholder="Search departments..."
|
|
class="h-11 w-full rounded-xl border border-[#d9dde6] bg-[#f9fafb] pl-11 pr-4 text-[14px] text-[#050026] outline-none transition-colors focus:border-[#050026] focus:bg-white"
|
|
/>
|
|
</div>
|
|
<div class="h-11 w-full md:w-[200px] rounded-xl border border-[#d9dde6] bg-[#f9fafb]"></div>
|
|
<div class="flex-1"></div>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full min-w-[1000px] border-collapse">
|
|
<thead>
|
|
<tr class="bg-[#050026] text-left text-white">
|
|
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider rounded-tl-xl whitespace-nowrap">DEPARTMENT ID</th>
|
|
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider whitespace-nowrap">DEPARTMENT NAME</th>
|
|
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider whitespace-nowrap">DESCRIPTION</th>
|
|
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider whitespace-nowrap">CREATED BY</th>
|
|
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider whitespace-nowrap">CREATED</th>
|
|
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider whitespace-nowrap">LAST UPDATED BY</th>
|
|
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider whitespace-nowrap">LAST UPDATED AT</th>
|
|
<th class="px-6 py-4 text-[11px] font-bold uppercase tracking-wider text-right rounded-tr-xl whitespace-nowrap">ACTION</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<Show when={data.loading}>
|
|
<tr><td colspan="8" class="text-center py-12 text-[#8087a0] text-[14px]">Loading departments...</td></tr>
|
|
</Show>
|
|
<Show when={!data.loading && data.error}>
|
|
<tr><td colspan="8" class="text-center py-12 text-red-500 text-[14px]">Failed to load. Is the backend running?</td></tr>
|
|
</Show>
|
|
<Show when={!data.loading && !data.error && filtered().length === 0}>
|
|
<tr><td colspan="8" class="text-center py-12 text-[#8087a0] text-[14px]">No departments found.</td></tr>
|
|
</Show>
|
|
<For each={filtered()}>
|
|
{(item) => (
|
|
<>
|
|
<tr class="border-b border-[#e2e6ee] bg-white transition-colors hover:bg-[#f8f9fc]">
|
|
<td class="px-6 py-4 text-[14px] font-semibold text-[#64748b]">{item.departmentId || item.id.slice(0, 8).toUpperCase()}</td>
|
|
<td class="px-6 py-4 text-[14px] font-bold text-[#050026]">{deptLabel(item)}</td>
|
|
<td class="px-6 py-4 text-[14px] text-[#475569]">{item.description || '—'}</td>
|
|
<td class="px-6 py-4 text-[14px] text-[#0ea5e9] hover:underline cursor-pointer">{item.createdBy || 'System'}</td>
|
|
<td class="px-6 py-4 text-[14px] text-[#475569]">{fmtDate(item.createdAt || item.created_at)}</td>
|
|
<td class="px-6 py-4 text-[14px] text-[#0ea5e9] hover:underline cursor-pointer">{item.updatedBy || item.createdBy || 'System'}</td>
|
|
<td class="px-6 py-4 text-[14px] text-[#475569]">{fmtDate(item.updatedAt)}</td>
|
|
<td class="px-6 py-4">
|
|
<div class="flex items-center justify-end gap-2">
|
|
<Show when={!isArchived(item)}>
|
|
<button
|
|
title="Edit"
|
|
onClick={() => startEdit(item)}
|
|
class="flex h-8 w-8 items-center justify-center rounded-lg border border-[#e2e6ee] bg-white text-[#64748b] hover:bg-[#f8f9fc] hover:text-[#050026] transition-colors"
|
|
>
|
|
<Pencil size={14} />
|
|
</button>
|
|
<button
|
|
title="Archive"
|
|
disabled={busy() === item.id}
|
|
onClick={() => handleArchive(item.id)}
|
|
class="flex h-8 w-8 items-center justify-center rounded-lg border border-[#e2e6ee] bg-white text-[#64748b] hover:bg-[#f8f9fc] hover:text-[#050026] transition-colors"
|
|
>
|
|
<Archive size={14} />
|
|
</button>
|
|
</Show>
|
|
<Show when={isArchived(item)}>
|
|
<button
|
|
title="Restore"
|
|
disabled={busy() === item.id}
|
|
onClick={() => handleRestore(item.id)}
|
|
class="flex h-8 w-8 items-center justify-center rounded-lg border border-green-200 bg-green-50 text-[#00c853] hover:bg-green-100 transition-colors"
|
|
>
|
|
<RotateCcw size={14} />
|
|
</button>
|
|
</Show>
|
|
<button
|
|
title="Delete"
|
|
disabled={busy() === item.id}
|
|
onClick={() => handleDelete(item.id, deptLabel(item))}
|
|
class="flex h-8 w-8 items-center justify-center rounded-lg border border-red-200 bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{/* Inline edit row */}
|
|
<Show when={editingId() === item.id}>
|
|
<tr>
|
|
<td colspan="8" class="bg-[#f8f9fc] px-6 py-4 border-b border-[#e2e6ee]">
|
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 max-w-2xl bg-white p-4 rounded-xl shadow-sm border border-[#e2e6ee]">
|
|
<div>
|
|
<label class="mb-1 block text-xs font-semibold text-[#383e5c]">Name *</label>
|
|
<input type="text" required value={editName()} onInput={(e) => setEditName(e.currentTarget.value)}
|
|
class="h-10 w-full rounded-lg border border-[#d9dde6] bg-[#f9fafb] px-3 text-sm outline-none focus:border-[#050026] focus:bg-white transition-colors" />
|
|
</div>
|
|
<div>
|
|
<label class="mb-1 block text-xs font-semibold text-[#383e5c]">Description</label>
|
|
<input type="text" value={editDesc()} onInput={(e) => setEditDesc(e.currentTarget.value)}
|
|
class="h-10 w-full rounded-lg border border-[#d9dde6] bg-[#f9fafb] px-3 text-sm outline-none focus:border-[#050026] focus:bg-white transition-colors" />
|
|
</div>
|
|
<Show when={editError()}>
|
|
<div class="col-span-2 rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">{editError()}</div>
|
|
</Show>
|
|
<div class="col-span-2 mt-2 flex justify-end gap-2 pt-3 border-t border-[#e2e6ee]">
|
|
<button type="button" onClick={cancelEdit} class="h-9 rounded-lg border border-[#d9dde6] bg-white px-4 text-[13px] font-semibold text-[#050026] hover:bg-[#f8f9fc] transition-colors">Cancel</button>
|
|
<button type="button" disabled={saving()} onClick={() => handleUpdate(item.id)}
|
|
class="h-9 rounded-lg bg-[#050026] px-4 text-[13px] font-semibold text-white hover:bg-[#0a0044] transition-colors disabled:opacity-70">
|
|
{saving() ? 'Saving…' : 'Save'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</Show>
|
|
</>
|
|
)}
|
|
</For>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
<Show when={totalPages() > 1}>
|
|
<div class="mt-6 flex items-center justify-between border-t border-[#e2e6ee] pt-4">
|
|
<span class="text-[13px] font-medium text-[#8087a0]">Page {page()} of {totalPages()}</span>
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
disabled={page() === 1}
|
|
onClick={() => setPage((p) => p - 1)}
|
|
class="flex h-9 w-9 items-center justify-center rounded-lg border border-[#e2e6ee] bg-white text-[#64748b] hover:bg-[#f8f9fc] hover:text-[#050026] disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
<ChevronLeft size={16} />
|
|
</button>
|
|
<button
|
|
disabled={page() >= totalPages()}
|
|
onClick={() => setPage((p) => p + 1)}
|
|
class="flex h-9 w-9 items-center justify-center rounded-lg border border-[#e2e6ee] bg-white text-[#64748b] hover:bg-[#f8f9fc] hover:text-[#050026] disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
<ChevronRight size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
</div>
|
|
</section>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</AdminShell>
|
|
);
|
|
}
|