Replace per-page AdminShell wrapping with a single SolidStart layout file (src/routes/admin.tsx) so the shell mounts once and persists across all /admin/* navigation — eliminating the sidebar bounce and session re-check flash that occurred on every page transition. - Create src/routes/admin.tsx as layout with <Outlet /> for child routes - Remove <AdminShell> import/wrapper from all 66 route files and 2 shared components (RoleUserManagementTablePage, UserListPage) - Fix company.tsx: wrong fetch URL /api/admin/companies → /api/gateway/api/admin/companies - Add missing auth headers (Authorization Bearer) to company.tsx and users.tsx - Fix admin/index.tsx API constant from hardcoded localhost:8000 → /api/gateway Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
166 lines
6.2 KiB
TypeScript
166 lines
6.2 KiB
TypeScript
import { createMemo, createResource, createSignal, For, Show } from 'solid-js';
|
|
|
|
const API = '/api/gateway';
|
|
|
|
type Requirement = {
|
|
id: string;
|
|
customerId?: string;
|
|
title?: string;
|
|
status?: string;
|
|
};
|
|
|
|
type ResponseRow = {
|
|
id: string;
|
|
requirementId: string;
|
|
professionalId: string;
|
|
professionalName?: string;
|
|
quote?: number;
|
|
message?: string;
|
|
status: string;
|
|
createdAt?: string;
|
|
};
|
|
|
|
type ResponsesPayload = {
|
|
responses: ResponseRow[];
|
|
requirements: Requirement[];
|
|
};
|
|
|
|
async function fetchResponses(): Promise<ResponsesPayload> {
|
|
const [resRes, reqRes] = await Promise.all([
|
|
fetch(`${API}/api/responses`),
|
|
fetch(`${API}/api/requirements`),
|
|
]);
|
|
const resData = resRes.ok ? await resRes.json() : {};
|
|
const reqData = reqRes.ok ? await reqRes.json() : {};
|
|
|
|
return {
|
|
responses: resData?.responses || [],
|
|
requirements: reqData?.requirements || [],
|
|
};
|
|
}
|
|
|
|
export default function ResponsesPage() {
|
|
const [refreshToken, setRefreshToken] = createSignal(0);
|
|
const [error, setError] = createSignal('');
|
|
const [busyId, setBusyId] = createSignal('');
|
|
|
|
const [payload] = createResource(refreshToken, fetchResponses);
|
|
const responses = createMemo(() => payload()?.responses || []);
|
|
const requirements = createMemo(() => payload()?.requirements || []);
|
|
|
|
function requirementTitle(id: string): string {
|
|
return requirements().find((item) => item.id === id)?.title || id;
|
|
}
|
|
|
|
function statusBadge(status: string) {
|
|
if (status === 'ACCEPTED') return 'bg-green-100 text-green-800';
|
|
if (status === 'REJECTED') return 'bg-red-100 text-red-700';
|
|
if (status === 'SHORTLISTED') return 'bg-blue-100 text-blue-700';
|
|
return 'bg-yellow-100 text-yellow-800';
|
|
}
|
|
|
|
async function transition(id: string, status: string) {
|
|
setError('');
|
|
setBusyId(id);
|
|
try {
|
|
const res = await fetch(`${API}/api/responses/${id}/status`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ status }),
|
|
});
|
|
if (!res.ok) {
|
|
const message = await res.text();
|
|
throw new Error(message || 'Failed to update response');
|
|
}
|
|
setRefreshToken((value) => value + 1);
|
|
} catch (nextError: any) {
|
|
setError(nextError?.message || 'Failed to update response');
|
|
} finally {
|
|
setBusyId('');
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
|
|
|
|
{/* ── Page header ── */}
|
|
<div class="bg-white border-b border-gray-200 px-6 py-4">
|
|
<h1 class="text-xl font-semibold text-gray-900">Responses</h1>
|
|
<p class="text-sm text-gray-500 mt-0.5">Track professional responses and move them through shortlist, accept, or reject states.</p>
|
|
</div>
|
|
|
|
{/* ── Content ── */}
|
|
<div class="p-6">
|
|
<Show when={error()}>
|
|
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error()}</div>
|
|
</Show>
|
|
|
|
<Show when={payload.loading}>
|
|
<div class="table-card">
|
|
<p class="py-10 text-center text-sm text-slate-400">Loading responses…</p>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={!payload.loading && responses().length === 0}>
|
|
<div class="table-card">
|
|
<p class="py-10 text-center text-sm text-slate-400">No responses yet.</p>
|
|
</div>
|
|
</Show>
|
|
|
|
<div class="flex flex-col gap-4">
|
|
<For each={responses()}>
|
|
{(row) => (
|
|
<div class="rounded-xl border border-gray-200 bg-white shadow-sm p-5">
|
|
<div class="flex items-start justify-between gap-3 flex-wrap">
|
|
<p class="text-base font-semibold text-gray-900">{requirementTitle(row.requirementId)}</p>
|
|
<span class={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${statusBadge(row.status)}`}>{row.status}</span>
|
|
</div>
|
|
<p class="mt-1.5 text-sm text-slate-500">{row.message || 'No message'}</p>
|
|
<div class="mt-3 flex flex-wrap gap-2">
|
|
<span class="inline-flex items-center rounded-md bg-gray-100 px-2.5 py-0.5 text-xs text-gray-700">
|
|
Professional: {row.professionalName || row.professionalId}
|
|
</span>
|
|
<span class="inline-flex items-center rounded-md bg-gray-100 px-2.5 py-0.5 text-xs text-gray-700">
|
|
Quote: {row.quote || 0}
|
|
</span>
|
|
<Show when={row.createdAt}>
|
|
<span class="inline-flex items-center rounded-md bg-gray-100 px-2.5 py-0.5 text-xs text-gray-700">
|
|
{new Date(row.createdAt!).toLocaleDateString()}
|
|
</span>
|
|
</Show>
|
|
</div>
|
|
<div class="mt-4 flex gap-2 flex-wrap">
|
|
<Show when={row.status === 'SUBMITTED'}>
|
|
<button
|
|
class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
|
|
disabled={busyId() === row.id}
|
|
onClick={() => transition(row.id, 'SHORTLISTED')}
|
|
>
|
|
Shortlist
|
|
</button>
|
|
</Show>
|
|
<Show when={row.status === 'SUBMITTED' || row.status === 'SHORTLISTED'}>
|
|
<button
|
|
class="btn-primary"
|
|
disabled={busyId() === row.id}
|
|
onClick={() => transition(row.id, 'ACCEPTED')}
|
|
>
|
|
Accept
|
|
</button>
|
|
<button
|
|
class="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={busyId() === row.id}
|
|
onClick={() => transition(row.id, 'REJECTED')}
|
|
>
|
|
Reject
|
|
</button>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|