40234-vm/frontend/src/pages/client-portal.tsx
2026-06-09 17:13:18 +00:00

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;