760 lines
26 KiB
TypeScript
760 lines
26 KiB
TypeScript
import {
|
|
mdiCheckCircleOutline,
|
|
mdiCloudUploadOutline,
|
|
mdiContentCopy,
|
|
mdiFileDocumentEditOutline,
|
|
mdiLightbulbOnOutline,
|
|
mdiMicrophoneOutline,
|
|
mdiStopCircleOutline,
|
|
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-none 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-[#35b7a5]'>
|
|
{label}
|
|
</span>
|
|
<textarea
|
|
value={value}
|
|
rows={rows}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
className='mt-2 w-full rounded-none border border-[#19192d]/10 bg-[#fffdf9] px-4 py-3 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-none px-3 py-1 text-xs font-semibold ${
|
|
isShared ? 'bg-[#fffdf9] text-[#35b7a5]' : 'bg-[#fffdf9] text-[#35b7a5]'
|
|
}`}
|
|
>
|
|
{isShared ? 'Shared' : 'Coach draft'}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function requestErrorMessage(error: unknown, fallback: string) {
|
|
if (!axios.isAxiosError(error)) {
|
|
return fallback;
|
|
}
|
|
|
|
const data = error.response?.data;
|
|
|
|
if (typeof data?.message === 'string') {
|
|
return data.message;
|
|
}
|
|
|
|
if (typeof data?.error === 'string') {
|
|
return data.error;
|
|
}
|
|
|
|
if (typeof data?.error?.message === 'string') {
|
|
return data.error.message;
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
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 [isRecording, setIsRecording] = React.useState(false);
|
|
const [recordingSeconds, setRecordingSeconds] = React.useState(0);
|
|
const [isGenerating, setIsGenerating] = React.useState(false);
|
|
const [isTranscribing, setIsTranscribing] = React.useState(false);
|
|
const [isSaving, setIsSaving] = React.useState(false);
|
|
const [notice, setNotice] = React.useState('');
|
|
const [lastSavedSession, setLastSavedSession] =
|
|
React.useState<Session | null>(null);
|
|
const mediaRecorderRef = React.useRef<MediaRecorder | null>(null);
|
|
const mediaStreamRef = React.useRef<MediaStream | null>(null);
|
|
const audioChunksRef = React.useRef<Blob[]>([]);
|
|
|
|
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();
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (!isRecording) {
|
|
return;
|
|
}
|
|
|
|
const intervalId = window.setInterval(() => {
|
|
setRecordingSeconds((current) => current + 1);
|
|
}, 1000);
|
|
|
|
return () => {
|
|
window.clearInterval(intervalId);
|
|
};
|
|
}, [isRecording]);
|
|
|
|
React.useEffect(() => {
|
|
return () => {
|
|
stopAudioTracks();
|
|
};
|
|
}, []);
|
|
|
|
function updateDraft(field: keyof MemoryDraft, value: string) {
|
|
setDraft((current) => {
|
|
return {
|
|
...current,
|
|
[field]: value,
|
|
};
|
|
});
|
|
setNotice('');
|
|
setLastSavedSession(null);
|
|
}
|
|
|
|
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.');
|
|
setLastSavedSession(null);
|
|
} catch (error) {
|
|
setNotice(requestErrorMessage(error, '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) {
|
|
setNotice(requestErrorMessage(error, 'Audio transcription failed.'));
|
|
} finally {
|
|
setIsTranscribing(false);
|
|
}
|
|
}
|
|
|
|
function stopAudioTracks() {
|
|
if (!mediaStreamRef.current) {
|
|
return;
|
|
}
|
|
|
|
mediaStreamRef.current.getTracks().forEach((track) => {
|
|
track.stop();
|
|
});
|
|
mediaStreamRef.current = null;
|
|
}
|
|
|
|
function recordedAudioType() {
|
|
if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
|
|
return 'audio/webm;codecs=opus';
|
|
}
|
|
|
|
if (MediaRecorder.isTypeSupported('audio/webm')) {
|
|
return 'audio/webm';
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
async function startRecording() {
|
|
if (typeof MediaRecorder === 'undefined') {
|
|
setNotice('Audio recording is not supported in this browser.');
|
|
return;
|
|
}
|
|
|
|
if (!navigator.mediaDevices?.getUserMedia) {
|
|
setNotice('Audio recording is not supported in this browser.');
|
|
return;
|
|
}
|
|
|
|
let stream: MediaStream;
|
|
|
|
try {
|
|
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
} catch (error) {
|
|
setNotice('Microphone access was blocked or failed.');
|
|
return;
|
|
}
|
|
|
|
const mimeType = recordedAudioType();
|
|
const options = mimeType ? { mimeType } : undefined;
|
|
const recorder = new MediaRecorder(stream, options);
|
|
|
|
audioChunksRef.current = [];
|
|
mediaStreamRef.current = stream;
|
|
mediaRecorderRef.current = recorder;
|
|
|
|
recorder.ondataavailable = (event) => {
|
|
if (event.data.size > 0) {
|
|
audioChunksRef.current.push(event.data);
|
|
}
|
|
};
|
|
|
|
recorder.onstop = () => {
|
|
const type = recorder.mimeType || 'audio/webm';
|
|
const blob = new Blob(audioChunksRef.current, { type });
|
|
const file = new File([blob], `session-recording-${Date.now()}.webm`, {
|
|
type,
|
|
});
|
|
|
|
setAudioFile(file);
|
|
setNotice('Recording saved. Transcribe it when you are ready.');
|
|
setIsRecording(false);
|
|
stopAudioTracks();
|
|
};
|
|
|
|
setRecordingSeconds(0);
|
|
setAudioFile(null);
|
|
setNotice('');
|
|
setIsRecording(true);
|
|
recorder.start();
|
|
}
|
|
|
|
function stopRecording() {
|
|
if (!mediaRecorderRef.current) {
|
|
return;
|
|
}
|
|
|
|
if (mediaRecorderRef.current.state !== 'inactive') {
|
|
mediaRecorderRef.current.stop();
|
|
}
|
|
}
|
|
|
|
function recordingDuration() {
|
|
const minutes = Math.floor(recordingSeconds / 60)
|
|
.toString()
|
|
.padStart(2, '0');
|
|
const seconds = (recordingSeconds % 60).toString().padStart(2, '0');
|
|
|
|
return `${minutes}:${seconds}`;
|
|
}
|
|
|
|
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]);
|
|
setLastSavedSession(response.data);
|
|
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,
|
|
);
|
|
const selectedClient = clients.find((client) => client.id === clientId);
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>{getPageTitle('Session Memory')}</title>
|
|
</Head>
|
|
<SectionMain>
|
|
<div className='mx-auto max-w-7xl'>
|
|
<div className='mb-4 rounded-none bg-[#19192d] p-7 text-white'>
|
|
<div className='flex items-center gap-3 text-[#35b7a5]'>
|
|
<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-none border border-[#35b7a5]/20 bg-[#fffdf9] px-4 py-3 text-sm font-semibold text-[#35b7a5]'>
|
|
{notice}
|
|
</div>
|
|
)}
|
|
|
|
<div className='grid gap-6 xl:grid-cols-[0.8fr_1.2fr]'>
|
|
<Panel className='p-4'>
|
|
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
|
|
Raw session input
|
|
</p>
|
|
<h2 className='mt-2 text-lg font-semibold text-[#19192d]'>
|
|
Extract a session
|
|
</h2>
|
|
|
|
<label className='mb-2 mt-6 block text-sm font-semibold text-[#72798a]'>
|
|
Client
|
|
</label>
|
|
<select
|
|
value={clientId}
|
|
onChange={(event) => setClientId(event.target.value)}
|
|
className='w-full rounded-none border border-[#19192d]/10 bg-white px-4 py-3 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-none 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>
|
|
<div className='mt-4 border border-[#19192d]/10 bg-white p-4'>
|
|
<div className='flex flex-col gap-4 md:flex-row md:items-center md:justify-between'>
|
|
<div>
|
|
<p className='text-sm font-semibold text-[#19192d]'>
|
|
Record audio
|
|
</p>
|
|
<p className='mt-1 text-sm text-[#72798a]'>
|
|
{isRecording
|
|
? `Recording ${recordingDuration()}`
|
|
: 'Use your microphone and transcribe the recording below.'}
|
|
</p>
|
|
</div>
|
|
{isRecording ? (
|
|
<button
|
|
type='button'
|
|
className='inline-flex items-center justify-center gap-2 rounded-none bg-[#19192d] px-4 py-2 text-sm font-semibold text-white'
|
|
onClick={stopRecording}
|
|
>
|
|
<BaseIcon path={mdiStopCircleOutline} size={18} />
|
|
Stop recording
|
|
</button>
|
|
) : (
|
|
<button
|
|
type='button'
|
|
className='inline-flex items-center justify-center gap-2 rounded-none bg-[#35b7a5] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50'
|
|
disabled={isTranscribing}
|
|
onClick={startRecording}
|
|
>
|
|
<BaseIcon path={mdiMicrophoneOutline} size={18} />
|
|
Start recording
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<label className='mt-3 flex cursor-pointer items-center justify-between gap-3 rounded-none border border-dashed border-[#19192d]/15 bg-white px-4 py-4 text-sm text-[#72798a]'>
|
|
<span className='truncate'>
|
|
{audioFile ? audioFile.name : 'Choose an audio file'}
|
|
</span>
|
|
<span className='inline-flex items-center gap-2 rounded-none bg-[#fffdf9] px-3 py-1 text-xs font-semibold text-[#35b7a5]'>
|
|
<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-none bg-[#35b7a5] px-4 py-2 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-none border border-[#19192d]/10 bg-[#fffdf9] px-4 py-3 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-6'>
|
|
<div>
|
|
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
|
|
Coach review
|
|
</p>
|
|
<h2 className='mt-2 text-lg font-semibold text-[#19192d]'>
|
|
Edit the final memory
|
|
</h2>
|
|
</div>
|
|
{hasDraft && (
|
|
<span className='rounded-none bg-[#fffdf9] px-3 py-1 text-xs font-semibold text-[#35b7a5]'>
|
|
Draft not shared yet
|
|
</span>
|
|
)}
|
|
{!hasDraft && lastSavedSession && (
|
|
<StatusPill status={lastSavedSession.status} />
|
|
)}
|
|
</div>
|
|
|
|
{hasDraft ? (
|
|
<div className='mt-4 grid gap-6'>
|
|
<label className='block'>
|
|
<span className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
|
|
Title
|
|
</span>
|
|
<input
|
|
value={draft.title || ''}
|
|
onChange={(event) =>
|
|
updateDraft('title', event.target.value)
|
|
}
|
|
className='mt-2 w-full rounded-none border border-[#19192d]/10 bg-[#fffdf9] px-4 py-3 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-none bg-[#19192d] px-4 py-2 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-none bg-[#35b7a5] px-4 py-2 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-none border border-[#19192d]/10 bg-white px-4 py-2 text-sm font-semibold text-[#19192d]'
|
|
onClick={copyFollowUp}
|
|
>
|
|
<BaseIcon path={mdiContentCopy} size={18} />
|
|
Copy follow-up
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : lastSavedSession ? (
|
|
<div className='mt-4 rounded-none border border-[#35b7a5]/20 bg-[#fffdf9] p-6'>
|
|
<BaseIcon
|
|
path={
|
|
lastSavedSession.status === 'shared'
|
|
? mdiSendOutline
|
|
: mdiCheckCircleOutline
|
|
}
|
|
size={22}
|
|
className='text-[#35b7a5]'
|
|
/>
|
|
<p className='mt-4 font-semibold text-[#19192d]'>
|
|
{lastSavedSession.status === 'shared'
|
|
? 'Shared with client'
|
|
: 'Saved as coach draft'}
|
|
</p>
|
|
<p className='mt-2 leading-6 text-[#72798a]'>
|
|
{lastSavedSession.title} was saved for{' '}
|
|
{lastSavedSession.client?.name ||
|
|
selectedClient?.name ||
|
|
'the selected client'}
|
|
.
|
|
</p>
|
|
<p className='mt-3 text-sm font-semibold text-[#35b7a5]'>
|
|
You can find it in Recent memories below.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className='mt-4 rounded-none border border-dashed border-[#19192d]/10 bg-[#fffdf9] p-6'>
|
|
<BaseIcon
|
|
path={mdiLightbulbOnOutline}
|
|
size={22}
|
|
className='text-[#35b7a5]'
|
|
/>
|
|
<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-7'>
|
|
<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-6'>
|
|
<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-none bg-[#35b7a5] px-4 py-2 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;
|