425 lines
14 KiB
TypeScript
425 lines
14 KiB
TypeScript
|
|
import { A } from '@solidjs/router';
|
||
|
|
import { createResource, createSignal, createMemo, Show, For } from 'solid-js';
|
||
|
|
import AdminShell from '~/components/AdminShell';
|
||
|
|
|
||
|
|
const API = '/api/gateway';
|
||
|
|
|
||
|
|
type Department = {
|
||
|
|
id: string;
|
||
|
|
name?: string;
|
||
|
|
departmentName?: string;
|
||
|
|
description?: string;
|
||
|
|
is_archived?: boolean;
|
||
|
|
status?: string | number;
|
||
|
|
created_at?: string;
|
||
|
|
createdAt?: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
async function loadDepartments(): Promise<Department[]> {
|
||
|
|
try {
|
||
|
|
const res = await fetch(`${API}/api/admin/departments`);
|
||
|
|
if (!res.ok) throw new Error('Failed to load');
|
||
|
|
const data = await res.json();
|
||
|
|
return Array.isArray(data) ? data : (data.departments ?? []);
|
||
|
|
} catch {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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 [departments, { refetch }] = createResource(loadDepartments);
|
||
|
|
|
||
|
|
// tabs
|
||
|
|
const [tab, setTab] = createSignal<'active' | 'archived'>('active');
|
||
|
|
|
||
|
|
// create form
|
||
|
|
const [showCreate, setShowCreate] = createSignal(false);
|
||
|
|
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('');
|
||
|
|
|
||
|
|
// row-level busy
|
||
|
|
const [busy, setBusy] = createSignal('');
|
||
|
|
const [actionError, setActionError] = createSignal('');
|
||
|
|
|
||
|
|
const filtered = createMemo(() => {
|
||
|
|
const all = departments() ?? [];
|
||
|
|
return tab() === 'archived'
|
||
|
|
? all.filter((d) => isArchived(d))
|
||
|
|
: all.filter((d) => !isArchived(d));
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------- CREATE ----------
|
||
|
|
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('');
|
||
|
|
setShowCreate(false);
|
||
|
|
setTab('active');
|
||
|
|
refetch();
|
||
|
|
} catch (err: any) {
|
||
|
|
setCreateError(err.message || 'Failed to create department');
|
||
|
|
} finally {
|
||
|
|
setCreating(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// ---------- EDIT ----------
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// ---------- ARCHIVE / RESTORE ----------
|
||
|
|
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('');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// ---------- DELETE ----------
|
||
|
|
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('');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<AdminShell>
|
||
|
|
{/* Header */}
|
||
|
|
<div class="page-actions">
|
||
|
|
<div>
|
||
|
|
<h1 class="page-title">Departments</h1>
|
||
|
|
<p class="page-subtitle">Manage organization departments</p>
|
||
|
|
</div>
|
||
|
|
<button
|
||
|
|
class="btn navy"
|
||
|
|
onClick={() => {
|
||
|
|
setShowCreate((v) => !v);
|
||
|
|
setCreateError('');
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{showCreate() ? 'Cancel' : 'Add Department'}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Create form */}
|
||
|
|
<Show when={showCreate()}>
|
||
|
|
<section class="card role-form-section">
|
||
|
|
<form onSubmit={handleCreate}>
|
||
|
|
<div class="field-grid-2">
|
||
|
|
<div class="field">
|
||
|
|
<label>Name *</label>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
required
|
||
|
|
placeholder="e.g. Engineering"
|
||
|
|
value={createName()}
|
||
|
|
onInput={(e) => setCreateName(e.currentTarget.value)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div class="field">
|
||
|
|
<label>Description</label>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
placeholder="Optional description"
|
||
|
|
value={createDesc()}
|
||
|
|
onInput={(e) => setCreateDesc(e.currentTarget.value)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<Show when={createError()}>
|
||
|
|
<p class="error-box" style="margin-top:10px">{createError()}</p>
|
||
|
|
</Show>
|
||
|
|
<div class="actions" style="margin-top:16px">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
class="btn"
|
||
|
|
onClick={() => {
|
||
|
|
setShowCreate(false);
|
||
|
|
setCreateError('');
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
Cancel
|
||
|
|
</button>
|
||
|
|
<button type="submit" class="btn navy" disabled={creating()}>
|
||
|
|
{creating() ? 'Saving...' : 'Save'}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
</section>
|
||
|
|
</Show>
|
||
|
|
|
||
|
|
{/* Tabs */}
|
||
|
|
<div style="display:flex;gap:24px;border-bottom:1px solid #e2e8f0;margin-bottom:16px">
|
||
|
|
<button
|
||
|
|
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="error-box" style="margin-bottom:12px">{actionError()}</div>
|
||
|
|
</Show>
|
||
|
|
|
||
|
|
{/* Table */}
|
||
|
|
<section class="card" style="padding:0;overflow:hidden">
|
||
|
|
<div class="table-wrap">
|
||
|
|
<table class="list-table">
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th>Name</th>
|
||
|
|
<th>Description</th>
|
||
|
|
<th>Created At</th>
|
||
|
|
<th class="align-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="table-actions">
|
||
|
|
<button
|
||
|
|
class="btn"
|
||
|
|
onClick={() => startEdit(item)}
|
||
|
|
>
|
||
|
|
Edit
|
||
|
|
</button>
|
||
|
|
<Show when={tab() === 'active'}>
|
||
|
|
<button
|
||
|
|
class="btn"
|
||
|
|
disabled={busy() === item.id}
|
||
|
|
onClick={() => handleArchive(item.id)}
|
||
|
|
>
|
||
|
|
{busy() === item.id ? '...' : 'Archive'}
|
||
|
|
</button>
|
||
|
|
</Show>
|
||
|
|
<Show when={tab() === 'archived'}>
|
||
|
|
<button
|
||
|
|
class="btn"
|
||
|
|
disabled={busy() === item.id}
|
||
|
|
onClick={() => handleRestore(item.id)}
|
||
|
|
>
|
||
|
|
{busy() === item.id ? '...' : 'Restore'}
|
||
|
|
</button>
|
||
|
|
</Show>
|
||
|
|
<button
|
||
|
|
class="btn danger"
|
||
|
|
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="field-grid-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="error-box" style="margin-bottom:8px">{editError()}</p>
|
||
|
|
</Show>
|
||
|
|
<div class="actions">
|
||
|
|
<button class="btn" type="button" onClick={cancelEdit}>
|
||
|
|
Cancel
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
class="btn navy"
|
||
|
|
type="button"
|
||
|
|
disabled={saving()}
|
||
|
|
onClick={() => handleUpdate(item.id)}
|
||
|
|
>
|
||
|
|
{saving() ? 'Saving...' : 'Save'}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
</Show>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</For>
|
||
|
|
</Show>
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
</AdminShell>
|
||
|
|
);
|
||
|
|
}
|