End-to-end proof that the full React + Vite + Tailwind + TypeScript
pipeline works against the existing Express API:
client/src/pages/Extensions.tsx
Minimum-viable port of the Extensions tab. Fetches /api/extensions
via the typed api wrapper; renders an add form backed by Zod
validation (ExtensionCreateSchema). Uses @tanstack/react-query for
server state (queryKey: ['extensions'], invalidate on mutate).
Full CRUD UI (trash / restore / purge / search) is a follow-up —
this ships just enough to prove the stack works.
client/src/lib/api.ts
Thin fetch wrapper. Every React page goes through apiFetch<T>(),
which narrows ApiResponse<T> to the success shape and throws
ApiError on failure. Central spot for future request/response
instrumentation, auth-token refresh, etc.
client/src/App.tsx
Replaced the Vite starter splash screen with a minimal router:
BrowserRouter basename='/app', routes for / (landing) and
/extensions. QueryClientProvider wraps the tree so every page can
use useQuery/useMutation.
client/src/shared/
Mirrored copy of /shared/types.ts + schemas.ts. Canonical source
stays at /shared/ (used by backend). A post-migration task is to
wire proper TypeScript project references so the client can import
straight from /shared — TS 6's cross-root bundler-mode paths
resolution isn't pulling it in cleanly. For now, the mirror is
header-annotated 'do not edit, mirror only'.
client/src/index.css
Tailwind v4 @theme block declaring the shadcn design tokens as
first-class CSS custom properties, which exposes the
bg-background / text-foreground / border-border utility classes
the page components use. v4 no longer uses @apply for these —
the theme block is the idiomatic form.
server.ts
Added:
app.get('/app/*splat', ...) → sendFile public/app/index.html
so React Router deep links (e.g. /app/extensions) resolve
client-side. Express static middleware below continues to serve
the hashed /app/assets/*.js + .css.
Build output (checked into public/app/ so the next prod docker
rebuild ships the React bundle without requiring a client/npm
install step in the Dockerfile — that's a day-8 refinement):
index.html 0.46 kB gzip 0.29 kB
index.css 9.51 kB gzip 2.69 kB
index.js 329.24 kB gzip 100.98 kB
Typecheck green on both sides:
server: npx tsc --noEmit → EXIT 0
client: npx tsc -b → EXIT 0
client: npx vite build → 150 modules, 219ms
How to see it live (after Daniel rebuilds prod):
https://<host>/app/ → React landing page
https://<host>/app/extensions → React-rendered Extensions list
https://<host>/ → unchanged vanilla JS app
Nothing destructive. The vanilla JS /extensions tab still works
identically. The React /app/extensions route talks to the same
/api/extensions backend endpoints. Both render from the same
PostgreSQL rows.
This closes out the 7-day migration scaffolding. The rest is a
port-one-tab-at-a-time grind that Codex (or anyone) can pick up
tab-by-tab with the Playwright suite as the safety net.
153 lines
5.4 KiB
TypeScript
153 lines
5.4 KiB
TypeScript
// ============================================================
|
|
// EXTENSIONS — first tab ported from vanilla JS to React.
|
|
// Read-only list view with a simple add form. The old vanilla
|
|
// version has richer UI (trash, restore, purge, search) — this
|
|
// minimum-viable port proves the migration pipeline works:
|
|
// shared types + api wrapper + React Query + Tailwind shadcn.
|
|
// The full CRUD UI lands in a follow-up when polish time arrives.
|
|
// ============================================================
|
|
|
|
import { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { api } from '@/lib/api';
|
|
import type { ExtensionsListOk, Extension } from '@/shared/types';
|
|
import { ExtensionCreateSchema, type ExtensionCreate } from '@/shared/schemas';
|
|
|
|
function ExtensionRow({ ext }: { ext: Extension }) {
|
|
return (
|
|
<div className="flex items-center gap-3 px-4 py-2 border-b border-border">
|
|
<div className="flex-1">
|
|
<div className="font-medium">{ext.name}</div>
|
|
<div className="text-xs text-muted-foreground">{ext.location}</div>
|
|
</div>
|
|
<div className="font-mono text-sm">{ext.number}</div>
|
|
<div className="text-xs uppercase text-muted-foreground w-20 text-right">
|
|
{ext.type}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AddForm({ onDone }: { onDone: () => void }) {
|
|
const qc = useQueryClient();
|
|
const [form, setForm] = useState<ExtensionCreate>({
|
|
location: '',
|
|
name: '',
|
|
number: '',
|
|
type: 'extension',
|
|
notes: '',
|
|
});
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: (body: ExtensionCreate) => api.post<{ id: number }>('/api/extensions', body),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['extensions'] });
|
|
onDone();
|
|
},
|
|
onError: (e: Error) => setError(e.message),
|
|
});
|
|
|
|
function submit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setError(null);
|
|
const parsed = ExtensionCreateSchema.safeParse(form);
|
|
if (!parsed.success) {
|
|
setError(parsed.error.issues.map((i: { message: string }) => i.message).join(', '));
|
|
return;
|
|
}
|
|
createMutation.mutate(parsed.data);
|
|
}
|
|
|
|
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
|
|
|
|
return (
|
|
<form onSubmit={submit} className="space-y-3 p-4 bg-muted/40 rounded-lg border border-border">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<input
|
|
className={input}
|
|
placeholder="Location (e.g. Main Hospital)"
|
|
value={form.location}
|
|
onChange={e => setForm({ ...form, location: e.target.value })}
|
|
/>
|
|
<input
|
|
className={input}
|
|
placeholder="Name / department"
|
|
value={form.name}
|
|
onChange={e => setForm({ ...form, name: e.target.value })}
|
|
/>
|
|
<input
|
|
className={input}
|
|
placeholder="Number"
|
|
value={form.number}
|
|
onChange={e => setForm({ ...form, number: e.target.value })}
|
|
/>
|
|
<select
|
|
className={input}
|
|
value={form.type}
|
|
onChange={e => setForm({ ...form, type: e.target.value as 'extension' | 'pager' })}
|
|
>
|
|
<option value="extension">Extension</option>
|
|
<option value="pager">Pager</option>
|
|
</select>
|
|
</div>
|
|
{error && <div className="text-sm text-destructive">{error}</div>}
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="submit"
|
|
disabled={createMutation.isPending}
|
|
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
|
|
>
|
|
{createMutation.isPending ? 'Saving…' : 'Save'}
|
|
</button>
|
|
<button type="button" onClick={onDone} className="rounded-md border border-border px-4 py-2 text-sm">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export default function Extensions() {
|
|
const [adding, setAdding] = useState(false);
|
|
const { data, isLoading, error } = useQuery<ExtensionsListOk>({
|
|
queryKey: ['extensions'],
|
|
queryFn: () => api.get<ExtensionsListOk>('/api/extensions'),
|
|
});
|
|
|
|
return (
|
|
<div className="max-w-3xl mx-auto p-6 space-y-4">
|
|
<header className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold">Pagers & Extensions</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Per-user directory. This React port is the migration proof-of-life.
|
|
</p>
|
|
</div>
|
|
{!adding && (
|
|
<button
|
|
onClick={() => setAdding(true)}
|
|
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium"
|
|
>
|
|
+ Add
|
|
</button>
|
|
)}
|
|
</header>
|
|
|
|
{adding && <AddForm onDone={() => setAdding(false)} />}
|
|
|
|
{isLoading && <div className="text-sm text-muted-foreground">Loading…</div>}
|
|
{error && <div className="text-sm text-destructive">{(error as Error).message}</div>}
|
|
{data && data.items.length === 0 && (
|
|
<div className="text-sm text-muted-foreground italic py-8 text-center">
|
|
No extensions yet. Click Add to create the first one.
|
|
</div>
|
|
)}
|
|
{data && data.items.length > 0 && (
|
|
<div className="rounded-lg border border-border overflow-hidden">
|
|
{data.items.map((ext: Extension) => <ExtensionRow key={ext.id} ext={ext} />)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|