40234-vm/frontend/src/pages/session-memory.tsx
2026-06-09 14:50:17 +00:00

555 lines
19 KiB
TypeScript

import {
mdiCheckCircleOutline,
mdiCloudUploadOutline,
mdiContentCopy,
mdiFileDocumentEditOutline,
mdiLightbulbOnOutline,
mdiMicrophoneOutline,
mdiSendOutline,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import React from 'react';
import type { ReactElement } from 'react';
import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import SectionMain from '../components/SectionMain';
import { getPageTitle } from '../config';
import LayoutAuthenticated from '../layouts/Authenticated';
type Client = {
id: string;
name: string;
};
type MemoryDraft = {
title?: string;
ai_summary?: string;
key_topics?: string;
goals_discussed?: string;
blockers?: string;
commitments?: string;
homework?: string;
emotional_themes?: string;
important_quotes?: string;
follow_up_email?: string;
next_session_prep?: string;
private_coach_notes?: string;
shared_client_notes?: string;
};
type Session = MemoryDraft & {
id: string;
status?: string;
client?: Client;
};
function Panel({
children,
className = '',
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<section
className={`rounded-lg border border-[#19192d]/10 bg-white ${className}`}
>
{children}
</section>
);
}
function TextField({
label,
value,
onChange,
rows = 3,
}: {
label: string;
value: string;
onChange: (value: string) => void;
rows?: number;
}) {
return (
<label className='block'>
<span className='text-xs font-semibold uppercase tracking-[0.18em] text-[#b17a1e]'>
{label}
</span>
<textarea
value={value}
rows={rows}
onChange={(event) => onChange(event.target.value)}
className='mt-2 w-full rounded-lg border border-[#19192d]/10 bg-[#fffdf9] px-3 py-2 text-sm leading-6 text-[#19192d] outline-none focus:border-[#35b7a5] focus:ring-2 focus:ring-[#35b7a5]/15'
/>
</label>
);
}
function StatusPill({ status }: { status?: string }) {
const isShared = status === 'shared';
return (
<span
className={`rounded-full px-3 py-1 text-xs font-semibold ${
isShared
? 'bg-[#f3fbf8] text-[#35b7a5]'
: 'bg-[#fbf8f1] text-[#b17a1e]'
}`}
>
{isShared ? 'Shared' : 'Coach draft'}
</span>
);
}
const emptyDraft: MemoryDraft = {
title: '',
ai_summary: '',
key_topics: '',
goals_discussed: '',
blockers: '',
commitments: '',
homework: '',
emotional_themes: '',
important_quotes: '',
follow_up_email: '',
next_session_prep: '',
private_coach_notes: '',
shared_client_notes: '',
};
const SessionMemory = () => {
const [clients, setClients] = React.useState<Client[]>([]);
const [sessions, setSessions] = React.useState<Session[]>([]);
const [clientId, setClientId] = React.useState('');
const [transcript, setTranscript] = React.useState('');
const [draft, setDraft] = React.useState<MemoryDraft>(emptyDraft);
const [audioFile, setAudioFile] = React.useState<File | null>(null);
const [isGenerating, setIsGenerating] = React.useState(false);
const [isTranscribing, setIsTranscribing] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const [notice, setNotice] = React.useState('');
async function loadData() {
const [clientsResponse, sessionsResponse] = await Promise.all([
axios.get('/coaching/clients'),
axios.get('/coaching/session-memory'),
]);
setClients(clientsResponse.data);
setSessions(sessionsResponse.data);
if (!clientId && clientsResponse.data.length > 0) {
setClientId(clientsResponse.data[0].id);
}
}
React.useEffect(() => {
loadData();
}, []);
function updateDraft(field: keyof MemoryDraft, value: string) {
setDraft((current) => {
return {
...current,
[field]: value,
};
});
setNotice('');
}
async function generateMemory() {
setIsGenerating(true);
try {
const response = await axios.post('/coaching/session-memory/generate', {
clientId,
transcript,
});
setDraft({
...emptyDraft,
...response.data,
});
setNotice('Draft generated. Review and edit before saving.');
} catch (error) {
if (axios.isAxiosError(error)) {
setNotice(
error.response?.data?.message ||
error.response?.data?.error ||
'Memory generation failed.',
);
} else {
setNotice('Memory generation failed.');
}
} finally {
setIsGenerating(false);
}
}
async function transcribeAudio() {
if (!audioFile) {
setNotice('Choose an audio file first.');
return;
}
const formData = new FormData();
formData.append('audio', audioFile);
setIsTranscribing(true);
try {
const response = await axios.post(
'/coaching/session-memory/transcribe',
formData,
{
headers: { 'Content-Type': 'multipart/form-data' },
},
);
setTranscript(response.data.text || '');
setNotice('Audio transcribed. Review the transcript before generating memory.');
} catch (error) {
if (axios.isAxiosError(error)) {
setNotice(
error.response?.data?.message ||
error.response?.data?.error ||
'Audio transcription failed.',
);
} else {
setNotice('Audio transcription failed.');
}
} finally {
setIsTranscribing(false);
}
}
async function saveMemory(shareWithClient: boolean) {
setIsSaving(true);
const response = await axios.post('/coaching/session-memory/save', {
...draft,
clientId,
transcript_notes: transcript,
shareWithClient,
});
setSessions((current) => [response.data, ...current]);
setDraft(emptyDraft);
setTranscript('');
setNotice(
shareWithClient
? 'Session saved and shared with the client.'
: 'Session saved as a coach draft.',
);
setIsSaving(false);
}
async function shareSession(session: Session) {
const response = await axios.patch(`/coaching/sessions/${session.id}/share`, {
shared_client_notes: session.shared_client_notes,
});
setSessions((current) =>
current.map((currentSession) => {
if (currentSession.id === session.id) {
return response.data;
}
return currentSession;
}),
);
setNotice('Session shared with the client.');
}
async function copyFollowUp() {
await navigator.clipboard.writeText(draft.follow_up_email || '');
setNotice('Follow-up copied.');
}
const hasDraft = Boolean(
draft.title ||
draft.ai_summary ||
draft.follow_up_email ||
draft.shared_client_notes,
);
return (
<>
<Head>
<title>{getPageTitle('Session Memory')}</title>
</Head>
<SectionMain>
<div className='mx-auto max-w-7xl'>
<div className='mb-4 rounded-lg bg-[#19192d] p-5 text-white'>
<div className='flex items-center gap-3 text-[#b17a1e]'>
<BaseIcon path={mdiFileDocumentEditOutline} size={18} />
<span className='text-xs font-semibold uppercase tracking-[0.22em]'>
Session Memory
</span>
</div>
<h1 className='mt-3 max-w-3xl text-xl font-semibold'>
Turn rough notes into follow-up, commitments, and next-session
prep.
</h1>
<p className='mt-2 max-w-3xl text-sm leading-6 text-[#fffdf9]'>
Generate a structured draft, edit it as the coach, save it as a
private draft, or share approved notes with the client portal.
</p>
</div>
{notice && (
<div className='mb-4 rounded-lg border border-[#35b7a5]/20 bg-[#f3fbf8] px-4 py-3 text-sm font-semibold text-[#257f73]'>
{notice}
</div>
)}
<div className='grid gap-4 xl:grid-cols-[0.8fr_1.2fr]'>
<Panel className='p-4'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#b17a1e]'>
Raw session input
</p>
<h2 className='mt-2 text-lg font-semibold text-[#19192d]'>
Extract a session
</h2>
<label className='mb-2 mt-4 block text-sm font-semibold text-[#72798a]'>
Client
</label>
<select
value={clientId}
onChange={(event) => setClientId(event.target.value)}
className='w-full rounded-lg border border-[#19192d]/10 bg-white px-3 py-2 text-[#19192d] outline-none focus:border-[#35b7a5] focus:ring-2 focus:ring-[#35b7a5]/15'
>
{clients.map((client) => (
<option key={client.id} value={client.id}>
{client.name}
</option>
))}
</select>
<div className='mt-5 rounded-lg border border-[#19192d]/10 bg-[#fffdf9] p-3'>
<div className='flex items-center gap-2 text-sm font-semibold text-[#19192d]'>
<BaseIcon path={mdiMicrophoneOutline} size={18} />
Audio transcription
</div>
<label className='mt-3 flex cursor-pointer items-center justify-between gap-3 rounded-lg border border-dashed border-[#19192d]/15 bg-white px-3 py-3 text-sm text-[#72798a]'>
<span className='truncate'>
{audioFile ? audioFile.name : 'Choose an audio file'}
</span>
<span className='inline-flex items-center gap-2 rounded-full bg-[#f3fbf8] px-3 py-1 text-xs font-semibold text-[#257f73]'>
<BaseIcon path={mdiCloudUploadOutline} size={16} />
Upload
</span>
<input
type='file'
accept='audio/*'
className='hidden'
onChange={(event) => {
const file = event.target.files?.[0] || null;
setAudioFile(file);
setNotice('');
}}
/>
</label>
<button
type='button'
className='mt-3 inline-flex items-center gap-2 rounded-full bg-[#35b7a5] px-3 py-1.5 text-sm font-semibold text-white disabled:opacity-50'
disabled={isTranscribing || !audioFile}
onClick={transcribeAudio}
>
<BaseIcon path={mdiMicrophoneOutline} size={18} />
{isTranscribing ? 'Transcribing...' : 'Transcribe audio'}
</button>
</div>
<label className='mb-2 mt-5 block text-sm font-semibold text-[#72798a]'>
Transcript or raw notes
</label>
<textarea
value={transcript}
onChange={(event) => setTranscript(event.target.value)}
className='min-h-[220px] w-full rounded-lg border border-[#19192d]/10 bg-[#fffdf9] px-3 py-2 leading-6 text-[#19192d] outline-none focus:border-[#35b7a5] focus:ring-2 focus:ring-[#35b7a5]/15'
placeholder='Paste session transcript, coach notes, commitments, blockers, or a rough debrief...'
/>
<div className='mt-5'>
<BaseButton
label={isGenerating ? 'Generating...' : 'Generate memory'}
color='info'
disabled={isGenerating || !clientId || !transcript.trim()}
onClick={generateMemory}
/>
</div>
</Panel>
<div className='space-y-4'>
<Panel className='p-4'>
<div className='flex items-start justify-between gap-4'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#b17a1e]'>
Coach review
</p>
<h2 className='mt-2 text-lg font-semibold text-[#19192d]'>
Edit the final memory
</h2>
</div>
<span className='rounded-full bg-[#fbf8f1] px-3 py-1 text-xs font-semibold text-[#b17a1e]'>
Not shared until approved
</span>
</div>
{hasDraft ? (
<div className='mt-4 grid gap-4'>
<label className='block'>
<span className='text-xs font-semibold uppercase tracking-[0.18em] text-[#b17a1e]'>
Title
</span>
<input
value={draft.title || ''}
onChange={(event) =>
updateDraft('title', event.target.value)
}
className='mt-2 w-full rounded-lg border border-[#19192d]/10 bg-[#fffdf9] px-3 py-2 text-sm text-[#19192d] outline-none focus:border-[#35b7a5] focus:ring-2 focus:ring-[#35b7a5]/15'
/>
</label>
<TextField
label='Summary'
value={draft.ai_summary || ''}
onChange={(value) => updateDraft('ai_summary', value)}
rows={4}
/>
<TextField
label='Commitments'
value={draft.commitments || ''}
onChange={(value) => updateDraft('commitments', value)}
/>
<TextField
label='Homework'
value={draft.homework || ''}
onChange={(value) => updateDraft('homework', value)}
/>
<TextField
label='Shared client notes'
value={draft.shared_client_notes || ''}
onChange={(value) =>
updateDraft('shared_client_notes', value)
}
rows={4}
/>
<TextField
label='Follow-up email'
value={draft.follow_up_email || ''}
onChange={(value) => updateDraft('follow_up_email', value)}
rows={4}
/>
<TextField
label='Next-session prep'
value={draft.next_session_prep || ''}
onChange={(value) =>
updateDraft('next_session_prep', value)
}
/>
<TextField
label='Private coach notes'
value={draft.private_coach_notes || ''}
onChange={(value) =>
updateDraft('private_coach_notes', value)
}
/>
<div className='flex flex-wrap gap-3'>
<button
type='button'
className='inline-flex items-center gap-2 rounded-full bg-[#19192d] px-3 py-1.5 text-sm font-semibold text-white disabled:opacity-50'
disabled={isSaving}
onClick={() => saveMemory(false)}
>
<BaseIcon path={mdiCheckCircleOutline} size={18} />
Save coach draft
</button>
<button
type='button'
className='inline-flex items-center gap-2 rounded-full bg-[#35b7a5] px-3 py-1.5 text-sm font-semibold text-white disabled:opacity-50'
disabled={isSaving}
onClick={() => saveMemory(true)}
>
<BaseIcon path={mdiSendOutline} size={18} />
Save and share
</button>
<button
type='button'
className='inline-flex items-center gap-2 rounded-full border border-[#19192d]/10 bg-white px-3 py-1.5 text-sm font-semibold text-[#19192d]'
onClick={copyFollowUp}
>
<BaseIcon path={mdiContentCopy} size={18} />
Copy follow-up
</button>
</div>
</div>
) : (
<div className='mt-4 rounded-lg border border-dashed border-[#19192d]/10 bg-[#fffdf9] p-4'>
<BaseIcon
path={mdiLightbulbOnOutline}
size={22}
className='text-[#b17a1e]'
/>
<p className='mt-4 leading-6 text-[#72798a]'>
Generate memory from raw notes. The editable draft will
appear here before anything is saved or shared.
</p>
</div>
)}
</Panel>
<Panel>
<div className='border-b border-[#19192d]/10 p-5'>
<h2 className='text-lg font-semibold text-[#19192d]'>
Recent memories
</h2>
</div>
<div className='divide-y divide-[#19192d]/10'>
{sessions.map((session) => (
<div key={session.id} className='p-5'>
<div className='flex items-start justify-between gap-4'>
<div>
<div className='flex flex-wrap items-center gap-2'>
<p className='font-semibold text-[#19192d]'>
{session.title}
</p>
<StatusPill status={session.status} />
</div>
<p className='mt-1 text-sm text-[#72798a]'>
{session.client?.name}
</p>
</div>
{session.status !== 'shared' && (
<button
type='button'
className='inline-flex items-center gap-2 rounded-full bg-[#35b7a5] px-3 py-1.5 text-sm font-semibold text-white'
onClick={() => shareSession(session)}
>
<BaseIcon path={mdiSendOutline} size={18} />
Share
</button>
)}
</div>
<p className='mt-3 leading-6 text-[#72798a]'>
{session.ai_summary}
</p>
<p className='mt-3 text-sm font-semibold text-[#35b7a5]'>
{session.key_topics}
</p>
</div>
))}
</div>
</Panel>
</div>
</div>
</div>
</SectionMain>
</>
);
};
SessionMemory.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default SessionMemory;