feat(client): port Encounter HPI, SOAP, Sick Visit
Three more tabs behind /app/*:
/encounter → Encounter.tsx — POST /api/generate-hpi-encounter
/soap → Soap.tsx — POST /api/generate-soap (full or
subjective-only output type)
/sickvisit → SickVisit.tsx — POST /api/sick-visit/note (chief
complaint is the only required
field; transcript optional)
All three share the same skeleton: demographic triad + textarea +
Zod-validated submit + copy-to-clipboard output pane. Follow-up work
per tab (save/load, refine, audio capture) lands in subsequent
commits — this first pass proves the AI generate path for each.
Layout sidebar updated: Encounter HPI / SOAP Note / Sick Visit now
marked available. Pending stubs remain for Hospital Course, Chart
Review, Well Visit, and all Clinical Tools + Settings.
Build: 361 kB / 109 kB gzipped (+11 kB over previous, as expected
for three small pages using the existing lib/api + shared schemas).
Typecheck + vite build both green on the client side.
This commit is contained in:
parent
e20af37bf5
commit
54f30cbacb
9 changed files with 356 additions and 15 deletions
|
|
@ -4,6 +4,9 @@ import Layout from '@/components/Layout';
|
||||||
import Extensions from '@/pages/Extensions';
|
import Extensions from '@/pages/Extensions';
|
||||||
import Faq from '@/pages/Faq';
|
import Faq from '@/pages/Faq';
|
||||||
import Dictation from '@/pages/Dictation';
|
import Dictation from '@/pages/Dictation';
|
||||||
|
import Encounter from '@/pages/Encounter';
|
||||||
|
import Soap from '@/pages/Soap';
|
||||||
|
import SickVisit from '@/pages/SickVisit';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
|
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
|
||||||
|
|
@ -19,8 +22,11 @@ function Home() {
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">Ported tabs so far:</p>
|
<p className="text-sm text-muted-foreground">Ported tabs so far:</p>
|
||||||
<ul className="list-disc pl-6 text-sm space-y-1">
|
<ul className="list-disc pl-6 text-sm space-y-1">
|
||||||
<li><Link to="/extensions" className="underline">Extensions & Pagers</Link></li>
|
<li><Link to="/encounter" className="underline">Encounter HPI</Link></li>
|
||||||
<li><Link to="/dictation" className="underline">Dictation HPI</Link></li>
|
<li><Link to="/dictation" className="underline">Dictation HPI</Link></li>
|
||||||
|
<li><Link to="/soap" className="underline">SOAP Note</Link></li>
|
||||||
|
<li><Link to="/sickvisit" className="underline">Sick Visit</Link></li>
|
||||||
|
<li><Link to="/extensions" className="underline">Extensions & Pagers</Link></li>
|
||||||
<li><Link to="/faq" className="underline">FAQ</Link></li>
|
<li><Link to="/faq" className="underline">FAQ</Link></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -34,8 +40,11 @@ export default function App() {
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/extensions" element={<Extensions />} />
|
<Route path="/encounter" element={<Encounter />} />
|
||||||
<Route path="/dictation" element={<Dictation />} />
|
<Route path="/dictation" element={<Dictation />} />
|
||||||
|
<Route path="/soap" element={<Soap />} />
|
||||||
|
<Route path="/sickvisit" element={<SickVisit />} />
|
||||||
|
<Route path="/extensions" element={<Extensions />} />
|
||||||
<Route path="/faq" element={<Faq />} />
|
<Route path="/faq" element={<Faq />} />
|
||||||
{/* catch-all falls back to home while more tabs port over */}
|
{/* catch-all falls back to home while more tabs port over */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ const NAV: NavGroup[] = [
|
||||||
{
|
{
|
||||||
label: 'Encounters',
|
label: 'Encounters',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/encounter', label: 'Encounter HPI', available: false },
|
{ to: '/encounter', label: 'Encounter HPI', available: true },
|
||||||
{ to: '/dictation', label: 'Dictation HPI', available: true },
|
{ to: '/dictation', label: 'Dictation HPI', available: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -31,9 +31,9 @@ const NAV: NavGroup[] = [
|
||||||
items: [
|
items: [
|
||||||
{ to: '/hospital', label: 'Hospital Course', available: false },
|
{ to: '/hospital', label: 'Hospital Course', available: false },
|
||||||
{ to: '/chart', label: 'Chart Review', available: false },
|
{ to: '/chart', label: 'Chart Review', available: false },
|
||||||
{ to: '/soap', label: 'SOAP Note', available: false },
|
{ to: '/soap', label: 'SOAP Note', available: true },
|
||||||
{ to: '/wellvisit', label: 'Well Visit', available: false },
|
{ to: '/wellvisit', label: 'Well Visit', available: false },
|
||||||
{ to: '/sickvisit', label: 'Sick Visit', available: false },
|
{ to: '/sickvisit', label: 'Sick Visit', available: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
112
client/src/pages/Encounter.tsx
Normal file
112
client/src/pages/Encounter.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
// ============================================================
|
||||||
|
// ENCOUNTER — live encounter → HPI via /api/generate-hpi-encounter
|
||||||
|
// Minimum-viable port: same shape as Dictation (same endpoint family).
|
||||||
|
// Audio capture + save/load + refine deferred to follow-up commits.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { api, ApiError } from '@/lib/api';
|
||||||
|
import type { HpiOk } from '@/shared/types';
|
||||||
|
import { HpiEncounterRequestSchema, type HpiEncounterRequest } from '@/shared/schemas';
|
||||||
|
|
||||||
|
type Setting = 'outpatient' | 'inpatient';
|
||||||
|
|
||||||
|
export default function Encounter() {
|
||||||
|
const [patientAge, setPatientAge] = useState('');
|
||||||
|
const [patientGender, setPatientGender] = useState('');
|
||||||
|
const [setting, setSetting] = useState<Setting>('outpatient');
|
||||||
|
const [transcript, setTranscript] = useState('');
|
||||||
|
const [result, setResult] = useState<string | null>(null);
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const generate = useMutation<HpiOk, Error, HpiEncounterRequest>({
|
||||||
|
mutationFn: (body) => api.post<HpiOk>('/api/generate-hpi-encounter', body),
|
||||||
|
onSuccess: (data) => setResult(data.hpi),
|
||||||
|
});
|
||||||
|
|
||||||
|
function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setValidationError(null);
|
||||||
|
const body: HpiEncounterRequest = { transcript, patientAge, patientGender, setting };
|
||||||
|
const parsed = HpiEncounterRequestSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
setValidationError(parsed.error.issues.map((i: { message: string }) => i.message).join(', '));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResult(null);
|
||||||
|
generate.mutate(parsed.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto p-6 space-y-4">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-2xl font-semibold">Live Encounter → HPI</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Record or paste an encounter transcript; generate a structured HPI.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Age</span>
|
||||||
|
<input className={input} placeholder="e.g. 5 years" value={patientAge} onChange={(e) => setPatientAge(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Gender</span>
|
||||||
|
<select className={input} value={patientGender} onChange={(e) => setPatientGender(e.target.value)}>
|
||||||
|
<option value="">Select</option>
|
||||||
|
<option>Male</option>
|
||||||
|
<option>Female</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Setting</span>
|
||||||
|
<select className={input} value={setting} onChange={(e) => setSetting(e.target.value as Setting)}>
|
||||||
|
<option value="outpatient">Outpatient</option>
|
||||||
|
<option value="inpatient">Inpatient / Floors</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Transcript
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
className={input + ' min-h-[220px] font-mono text-sm'}
|
||||||
|
placeholder="Type or paste an encounter transcript, then click Generate."
|
||||||
|
value={transcript}
|
||||||
|
onChange={(e) => setTranscript(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{validationError && <div className="text-sm text-destructive">{validationError}</div>}
|
||||||
|
{generate.error && <div className="text-sm text-destructive">{(generate.error as ApiError).message}</div>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={generate.isPending || !transcript.trim()}
|
||||||
|
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{generate.isPending ? 'Generating…' : 'Generate HPI'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<section className="rounded-lg border border-border bg-card">
|
||||||
|
<header className="px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40">
|
||||||
|
<h2 className="text-sm font-semibold">Generated HPI</h2>
|
||||||
|
<button onClick={() => navigator.clipboard.writeText(result)} className="text-xs text-muted-foreground underline">
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
client/src/pages/SickVisit.tsx
Normal file
101
client/src/pages/SickVisit.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
// ============================================================
|
||||||
|
// SICK VISIT — /api/sick-visit/note
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { api, ApiError } from '@/lib/api';
|
||||||
|
import type { VisitNoteOk } from '@/shared/types';
|
||||||
|
import { SickVisitRequestSchema, type SickVisitRequest } from '@/shared/schemas';
|
||||||
|
|
||||||
|
export default function SickVisit() {
|
||||||
|
const [patientAge, setPatientAge] = useState('');
|
||||||
|
const [patientGender, setPatientGender] = useState('');
|
||||||
|
const [chiefComplaint, setChiefComplaint] = useState('');
|
||||||
|
const [transcript, setTranscript] = useState('');
|
||||||
|
const [result, setResult] = useState<string | null>(null);
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const generate = useMutation<VisitNoteOk, Error, SickVisitRequest>({
|
||||||
|
mutationFn: (body) => api.post<VisitNoteOk>('/api/sick-visit/note', body),
|
||||||
|
onSuccess: (data) => setResult(data.note),
|
||||||
|
});
|
||||||
|
|
||||||
|
function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setValidationError(null);
|
||||||
|
const body: SickVisitRequest = { patientAge, patientGender, chiefComplaint, transcript };
|
||||||
|
const parsed = SickVisitRequestSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
setValidationError(parsed.error.issues.map((i: { message: string }) => i.message).join(', '));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResult(null);
|
||||||
|
generate.mutate(parsed.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto p-6 space-y-4">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-2xl font-semibold">Sick Visit</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Chief complaint + transcript → structured sick-visit note.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Age</span>
|
||||||
|
<input className={input} placeholder="e.g. 4 years" value={patientAge} onChange={(e) => setPatientAge(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Gender</span>
|
||||||
|
<select className={input} value={patientGender} onChange={(e) => setPatientGender(e.target.value)}>
|
||||||
|
<option value="">Select</option>
|
||||||
|
<option>Male</option>
|
||||||
|
<option>Female</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 col-span-3 md:col-span-1">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Chief complaint</span>
|
||||||
|
<input className={input} placeholder="e.g. Fever x 2 days" value={chiefComplaint} onChange={(e) => setChiefComplaint(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Transcript / dictation</span>
|
||||||
|
<textarea
|
||||||
|
className={input + ' min-h-[200px] font-mono text-sm'}
|
||||||
|
placeholder="Encounter narrative — transcribed or dictated."
|
||||||
|
value={transcript}
|
||||||
|
onChange={(e) => setTranscript(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{validationError && <div className="text-sm text-destructive">{validationError}</div>}
|
||||||
|
{generate.error && <div className="text-sm text-destructive">{(generate.error as ApiError).message}</div>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={generate.isPending || !chiefComplaint.trim()}
|
||||||
|
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{generate.isPending ? 'Generating…' : 'Generate Note'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<section className="rounded-lg border border-border bg-card">
|
||||||
|
<header className="px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40">
|
||||||
|
<h2 className="text-sm font-semibold">Sick Visit Note</h2>
|
||||||
|
<button onClick={() => navigator.clipboard.writeText(result)} className="text-xs text-muted-foreground underline">Copy</button>
|
||||||
|
</header>
|
||||||
|
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
client/src/pages/Soap.tsx
Normal file
119
client/src/pages/Soap.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
// ============================================================
|
||||||
|
// SOAP — transcript → SOAP note via /api/generate-soap
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { api, ApiError } from '@/lib/api';
|
||||||
|
import type { SoapOk } from '@/shared/types';
|
||||||
|
import { SoapRequestSchema, type SoapRequest } from '@/shared/schemas';
|
||||||
|
|
||||||
|
type SoapType = 'full' | 'subjective';
|
||||||
|
|
||||||
|
export default function Soap() {
|
||||||
|
const [patientAge, setPatientAge] = useState('');
|
||||||
|
const [patientGender, setPatientGender] = useState('');
|
||||||
|
const [type, setType] = useState<SoapType>('full');
|
||||||
|
const [transcript, setTranscript] = useState('');
|
||||||
|
const [additionalInstructions, setAdditionalInstructions] = useState('');
|
||||||
|
const [result, setResult] = useState<string | null>(null);
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const generate = useMutation<SoapOk, Error, SoapRequest>({
|
||||||
|
mutationFn: (body) => api.post<SoapOk>('/api/generate-soap', body),
|
||||||
|
onSuccess: (data) => setResult(data.soap),
|
||||||
|
});
|
||||||
|
|
||||||
|
function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setValidationError(null);
|
||||||
|
const body: SoapRequest = { transcript, patientAge, patientGender, type, additionalInstructions };
|
||||||
|
const parsed = SoapRequestSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
setValidationError(parsed.error.issues.map((i: { message: string }) => i.message).join(', '));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResult(null);
|
||||||
|
generate.mutate(parsed.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto p-6 space-y-4">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-2xl font-semibold">SOAP Note</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Encounter transcript → full SOAP or subjective-only narrative.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Age</span>
|
||||||
|
<input className={input} placeholder="e.g. 3 years" value={patientAge} onChange={(e) => setPatientAge(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Gender</span>
|
||||||
|
<select className={input} value={patientGender} onChange={(e) => setPatientGender(e.target.value)}>
|
||||||
|
<option value="">Select</option>
|
||||||
|
<option>Male</option>
|
||||||
|
<option>Female</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Output type</span>
|
||||||
|
<select className={input} value={type} onChange={(e) => setType(e.target.value as SoapType)}>
|
||||||
|
<option value="full">Full SOAP</option>
|
||||||
|
<option value="subjective">Subjective only</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Transcript</span>
|
||||||
|
<textarea
|
||||||
|
className={input + ' min-h-[200px] font-mono text-sm'}
|
||||||
|
placeholder="Type or paste the encounter transcript."
|
||||||
|
value={transcript}
|
||||||
|
onChange={(e) => setTranscript(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Additional instructions <span className="text-muted-foreground normal-case font-normal">(optional)</span>
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
className={input + ' min-h-[60px] text-sm'}
|
||||||
|
placeholder="e.g., 'Include return precautions', 'Add differential for otitis media'"
|
||||||
|
value={additionalInstructions}
|
||||||
|
onChange={(e) => setAdditionalInstructions(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{validationError && <div className="text-sm text-destructive">{validationError}</div>}
|
||||||
|
{generate.error && <div className="text-sm text-destructive">{(generate.error as ApiError).message}</div>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={generate.isPending || !transcript.trim()}
|
||||||
|
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{generate.isPending ? 'Generating…' : 'Generate SOAP'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<section className="rounded-lg border border-border bg-card">
|
||||||
|
<header className="px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40">
|
||||||
|
<h2 className="text-sm font-semibold">Generated SOAP</h2>
|
||||||
|
<button onClick={() => navigator.clipboard.writeText(result)} className="text-xs text-muted-foreground underline">Copy</button>
|
||||||
|
</header>
|
||||||
|
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
public/app/assets/index-Cge-0uqv.css
Normal file
2
public/app/assets/index-Cge-0uqv.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -5,8 +5,8 @@
|
||||||
<link rel="icon" type="image/svg+xml" href="/app/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/app/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>client</title>
|
<title>client</title>
|
||||||
<script type="module" crossorigin src="/app/assets/index-BmpHzFRb.js"></script>
|
<script type="module" crossorigin src="/app/assets/index-CJTSwzwK.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/app/assets/index-B2vc_21R.css">
|
<link rel="stylesheet" crossorigin href="/app/assets/index-Cge-0uqv.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue