475 lines
18 KiB
TypeScript
475 lines
18 KiB
TypeScript
import {
|
|
mdiBookOpenVariant,
|
|
mdiCalendarClock,
|
|
mdiCheck,
|
|
mdiCheckCircleOutline,
|
|
mdiChevronRight,
|
|
mdiFlagVariantOutline,
|
|
mdiMessageReplyTextOutline,
|
|
mdiPencilOutline,
|
|
} from '@mdi/js';
|
|
import axios from 'axios';
|
|
import Head from 'next/head';
|
|
import React from 'react';
|
|
import type { ReactElement } from 'react';
|
|
import BaseIcon from '../components/BaseIcon';
|
|
import SectionMain from '../components/SectionMain';
|
|
import { getPageTitle } from '../config';
|
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
|
import { useAppSelector } from '../stores/hooks';
|
|
|
|
type PortalClient = {
|
|
id: string;
|
|
name: string;
|
|
email?: string;
|
|
goals?: string;
|
|
next_session_at?: string;
|
|
sessions?: Array<{ id: string; title: string; shared_client_notes?: string }>;
|
|
action_items?: Array<{ id: string; title: string; status: string }>;
|
|
prep_briefs?: Array<{
|
|
id: string;
|
|
client_reflection?: string;
|
|
client_reflection_at?: string;
|
|
}>;
|
|
resources?: Array<{
|
|
id: string;
|
|
title: string;
|
|
description?: string;
|
|
url?: string;
|
|
}>;
|
|
};
|
|
|
|
function Panel({
|
|
children,
|
|
className = '',
|
|
}: {
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
}) {
|
|
return (
|
|
<section
|
|
className={`rounded-none border border-[#19192d]/10 bg-white ${className}`}
|
|
>
|
|
{children}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function formatDate(value?: string) {
|
|
if (!value) {
|
|
return 'Not scheduled';
|
|
}
|
|
|
|
return new Intl.DateTimeFormat('en', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
}).format(new Date(value));
|
|
}
|
|
|
|
const ClientPortal = () => {
|
|
const { currentUser } = useAppSelector((state) => state.auth);
|
|
const [clients, setClients] = React.useState<PortalClient[]>([]);
|
|
const [clientId, setClientId] = React.useState('');
|
|
const [portalClient, setPortalClient] = React.useState<PortalClient | null>(
|
|
null,
|
|
);
|
|
const [updatingItemId, setUpdatingItemId] = React.useState('');
|
|
const [reflection, setReflection] = React.useState('');
|
|
const [reflectionSaved, setReflectionSaved] = React.useState(false);
|
|
|
|
const isClientUser = currentUser?.app_role?.name === 'Client';
|
|
|
|
React.useEffect(() => {
|
|
async function loadClients() {
|
|
if (isClientUser) {
|
|
const response = await axios.get('/coaching/client-portal/me');
|
|
setPortalClient(response.data);
|
|
setClientId(response.data.id);
|
|
const latestReflection =
|
|
response.data.prep_briefs?.[0]?.client_reflection || '';
|
|
setReflection(latestReflection);
|
|
setReflectionSaved(Boolean(latestReflection));
|
|
return;
|
|
}
|
|
|
|
const response = await axios.get('/coaching/clients');
|
|
setClients(response.data);
|
|
|
|
if (response.data.length > 0) {
|
|
setClientId(response.data[0].id);
|
|
}
|
|
}
|
|
|
|
loadClients();
|
|
}, [currentUser?.email, isClientUser]);
|
|
|
|
React.useEffect(() => {
|
|
async function loadPortal() {
|
|
if (!clientId) {
|
|
return;
|
|
}
|
|
|
|
const response = await axios.get(`/coaching/client-portal/${clientId}`);
|
|
setPortalClient(response.data);
|
|
const latestReflection =
|
|
response.data.prep_briefs?.[0]?.client_reflection || '';
|
|
setReflection(latestReflection);
|
|
setReflectionSaved(Boolean(latestReflection));
|
|
}
|
|
|
|
if (!isClientUser) {
|
|
loadPortal();
|
|
}
|
|
}, [clientId, isClientUser]);
|
|
|
|
async function toggleItem(item: { id: string; status: string }) {
|
|
const nextStatus = item.status === 'done' ? 'in_progress' : 'done';
|
|
setUpdatingItemId(item.id);
|
|
|
|
const response = await axios.patch(
|
|
`/coaching/client-portal/action-items/${item.id}`,
|
|
{ status: nextStatus },
|
|
);
|
|
|
|
setPortalClient((current) => {
|
|
if (!current) {
|
|
return current;
|
|
}
|
|
|
|
return {
|
|
...current,
|
|
action_items: (current.action_items || []).map((currentItem) => {
|
|
if (currentItem.id === item.id) {
|
|
return response.data;
|
|
}
|
|
|
|
return currentItem;
|
|
}),
|
|
};
|
|
});
|
|
|
|
setUpdatingItemId('');
|
|
}
|
|
|
|
async function saveReflection() {
|
|
const response = await axios.post('/coaching/client-portal/reflection', {
|
|
clientId: portalClient?.id,
|
|
reflection,
|
|
});
|
|
|
|
setPortalClient((current) => {
|
|
if (!current) {
|
|
return current;
|
|
}
|
|
|
|
const existingBriefs = current.prep_briefs || [];
|
|
const otherBriefs = existingBriefs.filter((brief) => {
|
|
return brief.id !== response.data.id;
|
|
});
|
|
|
|
return {
|
|
...current,
|
|
prep_briefs: [response.data, ...otherBriefs],
|
|
};
|
|
});
|
|
setReflectionSaved(true);
|
|
}
|
|
|
|
const openItems = (portalClient?.action_items || []).filter((item) => {
|
|
return item.status !== 'done';
|
|
});
|
|
const finishedCount = (portalClient?.action_items || []).filter(
|
|
(item) => item.status === 'done',
|
|
).length;
|
|
const latestSession = portalClient?.sessions?.[0];
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>{getPageTitle('Client Portal')}</title>
|
|
</Head>
|
|
<SectionMain>
|
|
<div className='mx-auto max-w-7xl'>
|
|
{!isClientUser && (
|
|
<Panel className='mb-4 p-6'>
|
|
<label className='block text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
|
|
Preview as client
|
|
</label>
|
|
<select
|
|
value={clientId}
|
|
onChange={(event) => setClientId(event.target.value)}
|
|
className='mt-3 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 md:w-96'
|
|
>
|
|
{clients.map((client) => (
|
|
<option key={client.id} value={client.id}>
|
|
{client.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Panel>
|
|
)}
|
|
|
|
{portalClient && (
|
|
<div className='grid gap-6'>
|
|
<div className='rounded-none border border-[#19192d]/10 bg-[#fffdf9] p-7'>
|
|
<div className='grid gap-6 lg:grid-cols-[1fr_280px]'>
|
|
<div>
|
|
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
|
|
Client workspace
|
|
</p>
|
|
<h1 className='mt-2 text-2xl font-semibold text-[#19192d]'>
|
|
Hi, {portalClient.name}
|
|
</h1>
|
|
<p className='mt-3 max-w-3xl text-sm leading-6 text-[#72798a]'>
|
|
This is the shared space for your coaching work: notes
|
|
your coach approved, commitments you are working on, and
|
|
resources for the next session.
|
|
</p>
|
|
</div>
|
|
|
|
<div className='rounded-none border border-[#19192d]/10 bg-white p-6'>
|
|
<div className='flex items-center gap-2 text-sm font-semibold text-[#35b7a5]'>
|
|
<BaseIcon path={mdiCalendarClock} size={18} />
|
|
Next session
|
|
</div>
|
|
<p className='mt-2 text-lg font-semibold text-[#19192d]'>
|
|
{formatDate(portalClient.next_session_at)}
|
|
</p>
|
|
<p className='mt-2 text-sm leading-6 text-[#72798a]'>
|
|
Add your reflection below before the call.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='grid gap-6 md:grid-cols-3'>
|
|
<Panel className='p-4'>
|
|
<p className='text-sm font-semibold text-[#72798a]'>
|
|
Open commitments
|
|
</p>
|
|
<p className='mt-2 text-2xl font-semibold text-[#19192d]'>
|
|
{openItems.length}
|
|
</p>
|
|
</Panel>
|
|
<Panel className='p-4'>
|
|
<p className='text-sm font-semibold text-[#72798a]'>
|
|
Completed
|
|
</p>
|
|
<p className='mt-2 text-2xl font-semibold text-[#19192d]'>
|
|
{finishedCount}
|
|
</p>
|
|
</Panel>
|
|
<Panel className='p-4'>
|
|
<p className='text-sm font-semibold text-[#72798a]'>
|
|
Shared resources
|
|
</p>
|
|
<p className='mt-2 text-2xl font-semibold text-[#19192d]'>
|
|
{portalClient.resources?.length || 0}
|
|
</p>
|
|
</Panel>
|
|
</div>
|
|
|
|
<div className='grid gap-6 xl:grid-cols-[0.95fr_1.05fr]'>
|
|
<div className='space-y-4'>
|
|
<Panel>
|
|
<div className='border-b border-[#19192d]/10 p-6'>
|
|
<div className='flex items-center gap-3'>
|
|
<span className='grid h-8 w-8 place-items-center rounded-none bg-[#fffdf9] text-[#35b7a5]'>
|
|
<BaseIcon path={mdiCheckCircleOutline} size={18} />
|
|
</span>
|
|
<div>
|
|
<h2 className='font-semibold text-[#19192d]'>
|
|
Commitments
|
|
</h2>
|
|
<p className='text-sm text-[#72798a]'>
|
|
Mark what you completed before the next session.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className='space-y-3 p-6'>
|
|
{(portalClient.action_items || []).map((item) => {
|
|
const isDone = item.status === 'done';
|
|
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
type='button'
|
|
className={`flex w-full gap-3 rounded-none border p-6 text-left transition ${
|
|
isDone
|
|
? 'border-[#35b7a5]/30 bg-[#fffdf9]'
|
|
: 'border-[#19192d]/10 bg-white hover:bg-[#fffdf9]'
|
|
}`}
|
|
disabled={updatingItemId === item.id}
|
|
onClick={() => toggleItem(item)}
|
|
>
|
|
<span
|
|
className={`mt-0.5 grid h-8 w-8 flex-none place-items-center rounded-none border ${
|
|
isDone
|
|
? 'border-[#35b7a5] bg-[#35b7a5] text-white'
|
|
: 'border-[#19192d]/10 bg-white text-[#72798a]'
|
|
}`}
|
|
>
|
|
<BaseIcon
|
|
path={isDone ? mdiCheck : mdiFlagVariantOutline}
|
|
size={18}
|
|
/>
|
|
</span>
|
|
<span>
|
|
<span className='block font-semibold text-[#19192d]'>
|
|
{item.title}
|
|
</span>
|
|
<span className='mt-1 block text-sm text-[#72798a]'>
|
|
{isDone
|
|
? 'Marked complete'
|
|
: item.status.replace('_', ' ')}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</Panel>
|
|
|
|
<Panel className='p-4'>
|
|
<div className='flex items-center gap-3'>
|
|
<span className='grid h-8 w-8 place-items-center rounded-none bg-[#fffdf9] text-[#35b7a5]'>
|
|
<BaseIcon path={mdiPencilOutline} size={18} />
|
|
</span>
|
|
<div>
|
|
<h2 className='font-semibold text-[#19192d]'>
|
|
Pre-session reflection
|
|
</h2>
|
|
<p className='text-sm text-[#72798a]'>
|
|
Share what changed since the last call.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<textarea
|
|
value={reflection}
|
|
onChange={(event) => {
|
|
setReflection(event.target.value);
|
|
setReflectionSaved(false);
|
|
}}
|
|
className='mt-4 min-h-[140px] 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'
|
|
placeholder='What did you complete? What changed? What should we focus on next?'
|
|
/>
|
|
<div className='mt-3 flex items-center justify-between gap-3'>
|
|
<p className='text-sm text-[#72798a]'>
|
|
{reflectionSaved
|
|
? 'Reflection saved for this session.'
|
|
: 'Your coach will see this before the next call.'}
|
|
</p>
|
|
<button
|
|
type='button'
|
|
className='rounded-none bg-[#35b7a5] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50'
|
|
disabled={!reflection.trim()}
|
|
onClick={saveReflection}
|
|
>
|
|
Save reflection
|
|
</button>
|
|
</div>
|
|
</Panel>
|
|
</div>
|
|
|
|
<div className='space-y-4'>
|
|
<Panel className='overflow-hidden'>
|
|
<div className='border-b border-[#19192d]/10 bg-[#fffdf9] p-6'>
|
|
<div className='flex items-center gap-3'>
|
|
<span className='grid h-8 w-8 place-items-center rounded-none bg-[#fffdf9] text-[#35b7a5]'>
|
|
<BaseIcon
|
|
path={mdiMessageReplyTextOutline}
|
|
size={18}
|
|
/>
|
|
</span>
|
|
<div>
|
|
<h2 className='font-semibold text-[#19192d]'>
|
|
Shared notes
|
|
</h2>
|
|
<p className='text-sm text-[#72798a]'>
|
|
Coach-approved notes only.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='space-y-3 p-6'>
|
|
{latestSession && (
|
|
<div className='rounded-none border border-[#19192d]/10 bg-white p-6'>
|
|
<p className='font-semibold text-[#19192d]'>
|
|
{latestSession.title}
|
|
</p>
|
|
<p className='mt-3 text-sm leading-6 text-[#72798a]'>
|
|
{latestSession.shared_client_notes}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{(portalClient.sessions || []).slice(1).map((session) => (
|
|
<details
|
|
key={session.id}
|
|
className='rounded-none border border-[#19192d]/10 bg-white p-6'
|
|
>
|
|
<summary className='cursor-pointer font-semibold text-[#19192d]'>
|
|
{session.title}
|
|
</summary>
|
|
<p className='mt-3 text-sm leading-6 text-[#72798a]'>
|
|
{session.shared_client_notes}
|
|
</p>
|
|
</details>
|
|
))}
|
|
</div>
|
|
</Panel>
|
|
|
|
<Panel>
|
|
<div className='border-b border-[#19192d]/10 p-6'>
|
|
<h2 className='font-semibold text-[#19192d]'>
|
|
Resources
|
|
</h2>
|
|
</div>
|
|
<div className='space-y-3 p-6'>
|
|
{(portalClient.resources || []).map((resource) => (
|
|
<a
|
|
key={resource.id}
|
|
href={resource.url}
|
|
className='flex items-center justify-between gap-6 rounded-none border border-[#19192d]/10 bg-white p-6 transition hover:bg-[#fffdf9]'
|
|
>
|
|
<div className='flex gap-3'>
|
|
<span className='grid h-8 w-8 place-items-center rounded-none bg-[#fffdf9] text-[#35b7a5]'>
|
|
<BaseIcon path={mdiBookOpenVariant} size={18} />
|
|
</span>
|
|
<div>
|
|
<p className='font-semibold text-[#19192d]'>
|
|
{resource.title}
|
|
</p>
|
|
<p className='mt-1 text-sm leading-6 text-[#72798a]'>
|
|
{resource.description}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<BaseIcon
|
|
path={mdiChevronRight}
|
|
size={18}
|
|
className='text-[#35b7a5]'
|
|
/>
|
|
</a>
|
|
))}
|
|
</div>
|
|
</Panel>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</SectionMain>
|
|
</>
|
|
);
|
|
};
|
|
|
|
ClientPortal.getLayout = function getLayout(page: ReactElement) {
|
|
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
|
};
|
|
|
|
export default ClientPortal;
|