feat(admin): add missing approval and user detail/edit routes
This commit is contained in:
parent
94f40d194b
commit
7e65337c60
5 changed files with 397 additions and 1 deletions
|
|
@ -1,3 +1,4 @@
|
|||
import { A } from '@solidjs/router';
|
||||
import { createMemo, createResource, createSignal, For, Show } from 'solid-js';
|
||||
import AdminShell from '~/components/AdminShell';
|
||||
|
||||
|
|
@ -341,6 +342,7 @@ export default function ApprovalPage() {
|
|||
<td style="color:#475569">{submittedAt ? new Date(submittedAt).toLocaleString() : '—'}</td>
|
||||
<td>
|
||||
<div class="table-actions">
|
||||
<A class="action-icon-btn" href={`/admin/approval/${item.id}`} title="Open Detail">↗</A>
|
||||
<button class="action-icon-btn" type="button" onClick={() => selectApproval(item)} title="View Detail">👁</button>
|
||||
<Show when={status !== 'APPROVED'}>
|
||||
<button class="action-icon-btn" type="button" disabled={!!acting()} onClick={() => handleAction(item.id, 'APPROVED')} title="Approve">✓</button>
|
||||
|
|
|
|||
122
src/routes/admin/approval/[id].tsx
Normal file
122
src/routes/admin/approval/[id].tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { A, useParams } from '@solidjs/router';
|
||||
import { createMemo, createResource, createSignal, Show } from 'solid-js';
|
||||
import AdminShell from '~/components/AdminShell';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
type ApprovalDetail = {
|
||||
id: string;
|
||||
requestType?: string;
|
||||
type?: string;
|
||||
requestStatus?: string;
|
||||
status?: string;
|
||||
priority?: number;
|
||||
createdAt?: string;
|
||||
created_at?: string;
|
||||
requester?: { name?: string; email?: string };
|
||||
requesterName?: string;
|
||||
requesterEmail?: string;
|
||||
requester_name?: string;
|
||||
requester_email?: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
async function loadApproval(id: string): Promise<ApprovalDetail | null> {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/approvals/${id}`);
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function ApprovalDetailPage() {
|
||||
const params = useParams();
|
||||
const [approval, { refetch }] = createResource(() => params.id, loadApproval);
|
||||
const [acting, setActing] = createSignal('');
|
||||
const [error, setError] = createSignal('');
|
||||
|
||||
const status = createMemo(() => (approval()?.requestStatus || approval()?.status || 'PENDING').toUpperCase());
|
||||
const requestType = createMemo(() => (approval()?.requestType || approval()?.type || 'OTHER').toUpperCase());
|
||||
const requesterName = createMemo(() => approval()?.requester?.name || approval()?.requesterName || approval()?.requester_name || 'Unknown');
|
||||
const requesterEmail = createMemo(() => approval()?.requester?.email || approval()?.requesterEmail || approval()?.requester_email || '—');
|
||||
const submittedAt = createMemo(() => approval()?.createdAt || approval()?.created_at || '');
|
||||
|
||||
const act = async (nextStatus: 'APPROVED' | 'REJECTED' | 'CHANGES_REQUESTED') => {
|
||||
try {
|
||||
setActing(nextStatus);
|
||||
setError('');
|
||||
const res = await fetch(`${API}/api/admin/approvals/${params.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: nextStatus }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to mark as ${nextStatus.toLowerCase()}`);
|
||||
refetch();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to update approval status');
|
||||
} finally {
|
||||
setActing('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div class="page-hero-card page-actions">
|
||||
<div>
|
||||
<h1 class="page-title">Approval Detail</h1>
|
||||
<p class="page-subtitle">Review one approval request in detail and take action.</p>
|
||||
</div>
|
||||
<A class="btn" href="/admin/approval">Back to Approval List</A>
|
||||
</div>
|
||||
|
||||
<Show when={error()}>
|
||||
<div class="error-box">{error()}</div>
|
||||
</Show>
|
||||
|
||||
<Show when={approval.loading}>
|
||||
<div class="card"><p class="notice">Loading approval...</p></div>
|
||||
</Show>
|
||||
|
||||
<Show when={!approval.loading && !approval()}>
|
||||
<div class="card"><p class="notice">Approval request not found.</p></div>
|
||||
</Show>
|
||||
|
||||
<Show when={approval()}>
|
||||
<div class="grid" style="margin-top:0">
|
||||
<section class="card">
|
||||
<h2 style="margin-bottom:8px">Request Summary</h2>
|
||||
<p class="notice" style="margin:0"><strong>ID:</strong> {approval()!.id}</p>
|
||||
<p class="notice" style="margin:8px 0 0"><strong>Type:</strong> {requestType()}</p>
|
||||
<p class="notice" style="margin:8px 0 0"><strong>Status:</strong> {status()}</p>
|
||||
<p class="notice" style="margin:8px 0 0"><strong>Priority:</strong> {approval()!.priority ?? '—'}</p>
|
||||
<p class="notice" style="margin:8px 0 0"><strong>Submitted:</strong> {submittedAt() ? new Date(submittedAt()).toLocaleString() : '—'}</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 style="margin-bottom:8px">Requester</h2>
|
||||
<p class="notice" style="margin:0"><strong>Name:</strong> {requesterName()}</p>
|
||||
<p class="notice" style="margin:8px 0 0"><strong>Email:</strong> {requesterEmail()}</p>
|
||||
<div class="actions">
|
||||
<button class="btn primary" type="button" disabled={!!acting()} onClick={() => act('APPROVED')}>
|
||||
{acting() === 'APPROVED' ? 'Approving...' : 'Approve'}
|
||||
</button>
|
||||
<button class="btn" type="button" disabled={!!acting()} onClick={() => act('CHANGES_REQUESTED')}>
|
||||
{acting() === 'CHANGES_REQUESTED' ? 'Updating...' : 'Request Changes'}
|
||||
</button>
|
||||
<button class="btn danger" type="button" disabled={!!acting()} onClick={() => act('REJECTED')}>
|
||||
{acting() === 'REJECTED' ? 'Rejecting...' : 'Reject'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card" style="margin-top:16px">
|
||||
<h2 style="margin-bottom:10px">Raw Payload</h2>
|
||||
<pre class="json">{JSON.stringify(approval(), null, 2)}</pre>
|
||||
</section>
|
||||
</Show>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -222,7 +222,8 @@ export default function UsersPage() {
|
|||
</td>
|
||||
<td>
|
||||
<div class="table-actions">
|
||||
<button class="action-icon-btn" type="button" onClick={() => onView(item)} title="View Details">👁</button>
|
||||
<A class="action-icon-btn" href={`/admin/users/details/${item.id}`} title="Open Detail Page">↗</A>
|
||||
<button class="action-icon-btn" type="button" onClick={() => onView(item)} title="Quick View">👁</button>
|
||||
<A class="action-icon-btn" href={`/admin/users/${item.id}/edit`} title="Edit User">✎</A>
|
||||
<button class="action-icon-btn danger" type="button" title="Delete User">🗑</button>
|
||||
</div>
|
||||
|
|
|
|||
182
src/routes/admin/users/[id]/edit.tsx
Normal file
182
src/routes/admin/users/[id]/edit.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { A, useNavigate, useParams } from '@solidjs/router';
|
||||
import { createMemo, createResource, createSignal, Show } from 'solid-js';
|
||||
import AdminShell from '~/components/AdminShell';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
type Role = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
name?: string;
|
||||
full_name?: string;
|
||||
email: string;
|
||||
roleId?: string;
|
||||
role_id?: string;
|
||||
role?: Role;
|
||||
status?: 'ACTIVE' | 'INACTIVE' | 'PENDING';
|
||||
createdAt?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
async function fetchRoles(): Promise<Role[]> {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/roles?audience=INTERNAL`);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
const rows = Array.isArray(data) ? data : (data.roles || []);
|
||||
return rows.map((r: any) => ({ id: r.id, name: r.name }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUser(id: string): Promise<User | null> {
|
||||
try {
|
||||
const adminRes = await fetch(`${API}/api/admin/users/${id}`);
|
||||
if (adminRes.ok) return adminRes.json();
|
||||
|
||||
const fallback = await fetch(`${API}/api/users/${id}`);
|
||||
if (!fallback.ok) return null;
|
||||
return fallback.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function EditUserPage() {
|
||||
const navigate = useNavigate();
|
||||
const params = useParams();
|
||||
const [user] = createResource(() => params.id, fetchUser);
|
||||
const [roles] = createResource(fetchRoles);
|
||||
|
||||
const [name, setName] = createSignal('');
|
||||
const [email, setEmail] = createSignal('');
|
||||
const [roleId, setRoleId] = createSignal('');
|
||||
const [status, setStatus] = createSignal<'ACTIVE' | 'INACTIVE' | 'PENDING'>('ACTIVE');
|
||||
const [submitting, setSubmitting] = createSignal(false);
|
||||
const [error, setError] = createSignal('');
|
||||
|
||||
createMemo(() => {
|
||||
const u = user();
|
||||
if (!u) return null;
|
||||
setName(u.name || u.full_name || '');
|
||||
setEmail(u.email || '');
|
||||
setRoleId(u.roleId || u.role_id || u.role?.id || '');
|
||||
setStatus((u.status || 'ACTIVE').toUpperCase() as 'ACTIVE' | 'INACTIVE' | 'PENDING');
|
||||
return null;
|
||||
});
|
||||
|
||||
const save = async () => {
|
||||
if (!name().trim() || !email().trim() || !roleId()) {
|
||||
setError('Please fill in name, email, and role.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
const body = {
|
||||
name: name().trim(),
|
||||
email: email().trim(),
|
||||
roleId: roleId(),
|
||||
status: status().toLowerCase(),
|
||||
};
|
||||
|
||||
let res = await fetch(`${API}/api/admin/users/${params.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
res = await fetch(`${API}/api/users/${params.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
throw new Error(payload.message || 'Failed to update user');
|
||||
}
|
||||
navigate('/admin/users');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to update user');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div class="page-hero-card page-actions">
|
||||
<div>
|
||||
<h1 class="page-title">Edit User</h1>
|
||||
<p class="page-subtitle">Update user profile, role assignment, and account status.</p>
|
||||
</div>
|
||||
<div class="page-actions-right">
|
||||
<A class="btn" href={`/admin/users/details/${params.id}`}>View Details</A>
|
||||
<A class="btn" href="/admin/users">Back to Users</A>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={error()}>
|
||||
<div class="error-box">{error()}</div>
|
||||
</Show>
|
||||
|
||||
<Show when={user.loading}>
|
||||
<div class="card"><p class="notice">Loading user...</p></div>
|
||||
</Show>
|
||||
|
||||
<Show when={!user.loading && !user()}>
|
||||
<div class="card"><p class="notice">User not found.</p></div>
|
||||
</Show>
|
||||
|
||||
<Show when={user()}>
|
||||
<section class="card" style="max-width:900px">
|
||||
<div class="field-grid-2">
|
||||
<div class="field">
|
||||
<label>Full Name</label>
|
||||
<input value={name()} onInput={(e) => setName(e.currentTarget.value)} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Email</label>
|
||||
<input type="email" value={email()} onInput={(e) => setEmail(e.currentTarget.value)} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Role</label>
|
||||
<select value={roleId()} onChange={(e) => setRoleId(e.currentTarget.value)}>
|
||||
<option value="">Select role</option>
|
||||
<Show when={!roles.loading}>
|
||||
{roles()?.map((r) => (
|
||||
<option value={r.id}>{r.name}</option>
|
||||
))}
|
||||
</Show>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Status</label>
|
||||
<select value={status()} onChange={(e) => setStatus(e.currentTarget.value as 'ACTIVE' | 'INACTIVE' | 'PENDING')}>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="INACTIVE">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions" style="justify-content:flex-end">
|
||||
<button class="btn" type="button" onClick={() => navigate('/admin/users')}>Cancel</button>
|
||||
<button class="btn primary" type="button" onClick={save} disabled={submitting()}>
|
||||
{submitting() ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
89
src/routes/admin/users/details/[id].tsx
Normal file
89
src/routes/admin/users/details/[id].tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { A, useParams } from '@solidjs/router';
|
||||
import { createMemo, createResource, Show } from 'solid-js';
|
||||
import AdminShell from '~/components/AdminShell';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
name?: string;
|
||||
full_name?: string;
|
||||
email: string;
|
||||
role?: string;
|
||||
role_name?: string;
|
||||
roleId?: string;
|
||||
status?: 'ACTIVE' | 'INACTIVE' | 'PENDING';
|
||||
createdAt?: string;
|
||||
created_at?: string;
|
||||
updatedAt?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
async function fetchUser(id: string): Promise<User | null> {
|
||||
try {
|
||||
const adminRes = await fetch(`${API}/api/admin/users/${id}`);
|
||||
if (adminRes.ok) return adminRes.json();
|
||||
|
||||
const fallback = await fetch(`${API}/api/users/${id}`);
|
||||
if (!fallback.ok) return null;
|
||||
return fallback.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function UserDetailPage() {
|
||||
const params = useParams();
|
||||
const [user] = createResource(() => params.id, fetchUser);
|
||||
|
||||
const displayName = createMemo(() => user()?.name || user()?.full_name || 'Unknown User');
|
||||
const roleName = createMemo(() => user()?.role_name || user()?.role || 'UNKNOWN');
|
||||
const createdAt = createMemo(() => user()?.createdAt || user()?.created_at || '');
|
||||
const updatedAt = createMemo(() => user()?.updatedAt || user()?.updated_at || '');
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div class="page-hero-card page-actions">
|
||||
<div>
|
||||
<h1 class="page-title">User Detail</h1>
|
||||
<p class="page-subtitle">Review account profile, role assignment, and account status.</p>
|
||||
</div>
|
||||
<div class="page-actions-right">
|
||||
<A class="btn" href="/admin/users">Back to Users</A>
|
||||
<A class="btn navy" href={`/admin/users/${params.id}/edit`}>Edit User</A>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={user.loading}>
|
||||
<div class="card"><p class="notice">Loading user...</p></div>
|
||||
</Show>
|
||||
|
||||
<Show when={!user.loading && !user()}>
|
||||
<div class="card"><p class="notice">User not found.</p></div>
|
||||
</Show>
|
||||
|
||||
<Show when={user()}>
|
||||
<div class="grid" style="margin-top:0">
|
||||
<section class="card">
|
||||
<h2 style="margin-bottom:8px">Profile</h2>
|
||||
<p class="notice" style="margin:0"><strong>ID:</strong> {user()!.id}</p>
|
||||
<p class="notice" style="margin:8px 0 0"><strong>Name:</strong> {displayName()}</p>
|
||||
<p class="notice" style="margin:8px 0 0"><strong>Email:</strong> {user()!.email}</p>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2 style="margin-bottom:8px">Account</h2>
|
||||
<p class="notice" style="margin:0"><strong>Role:</strong> {roleName()}</p>
|
||||
<p class="notice" style="margin:8px 0 0"><strong>Status:</strong> {user()!.status || 'PENDING'}</p>
|
||||
<p class="notice" style="margin:8px 0 0"><strong>Created:</strong> {createdAt() ? new Date(createdAt()).toLocaleString() : '—'}</p>
|
||||
<p class="notice" style="margin:8px 0 0"><strong>Updated:</strong> {updatedAt() ? new Date(updatedAt()).toLocaleString() : '—'}</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card" style="margin-top:16px">
|
||||
<h2 style="margin-bottom:10px">Raw Data</h2>
|
||||
<pre class="json">{JSON.stringify(user(), null, 2)}</pre>
|
||||
</section>
|
||||
</Show>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue