Add coach client management flow

This commit is contained in:
Flatlogic Bot 2026-06-09 18:01:43 +00:00
parent be3bbea7aa
commit cbebf46653
2 changed files with 758 additions and 228 deletions

View File

@ -29,6 +29,32 @@ function splitActionItems(value) {
.slice(0, 8);
}
function clientPayload(data, userId) {
return {
name: data.name,
email: data.email,
status: data.status || "active",
goals: data.goals,
notes: data.notes,
company: data.company,
role_title: data.role_title,
tags: data.tags,
next_session_at: data.next_session_at || null,
last_session_at: data.last_session_at || null,
updatedById: userId,
};
}
function clientIncludes() {
return [
{ model: db.packages, as: "package" },
{ model: db.sessions, as: "sessions", order: [["session_at", "DESC"]] },
{ model: db.action_items, as: "action_items", order: [["due_at", "ASC"]] },
{ model: db.resources, as: "resources", order: [["createdAt", "DESC"]] },
{ model: db.prep_briefs, as: "prep_briefs", order: [["next_session_at", "DESC"]] },
];
}
function getFirstUploadedFile(files, fieldName) {
const file = files[fieldName];
@ -270,17 +296,35 @@ router.get(
}),
);
router.post(
"/clients",
wrapAsync(async (req, res) => {
const data = req.body.data || req.body;
if (!String(data.name || "").trim()) {
res.status(400).send({ error: "client_name_required" });
return;
}
const client = await db.clients.create({
...clientPayload(data, req.currentUser.id),
ownerId: req.currentUser.id,
createdById: req.currentUser.id,
});
const savedClient = await db.clients.findByPk(client.id, {
include: clientIncludes(),
});
res.status(200).send(savedClient);
}),
);
router.get(
"/clients/:id",
wrapAsync(async (req, res) => {
const client = await db.clients.findByPk(req.params.id, {
include: [
{ model: db.packages, as: "package" },
{ model: db.sessions, as: "sessions", order: [["session_at", "DESC"]] },
{ model: db.action_items, as: "action_items", order: [["due_at", "ASC"]] },
{ model: db.resources, as: "resources" },
{ model: db.prep_briefs, as: "prep_briefs", order: [["next_session_at", "DESC"]] },
],
include: clientIncludes(),
});
if (!client) {
@ -292,6 +336,93 @@ router.get(
}),
);
router.patch(
"/clients/:id",
wrapAsync(async (req, res) => {
const data = req.body.data || req.body;
const client = await db.clients.findByPk(req.params.id);
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
if (!String(data.name || "").trim()) {
res.status(400).send({ error: "client_name_required" });
return;
}
await client.update(clientPayload(data, req.currentUser.id));
const savedClient = await db.clients.findByPk(client.id, {
include: clientIncludes(),
});
res.status(200).send(savedClient);
}),
);
router.post(
"/clients/:id/action-items",
wrapAsync(async (req, res) => {
const data = req.body.data || req.body;
const client = await db.clients.findByPk(req.params.id);
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
if (!String(data.title || "").trim()) {
res.status(400).send({ error: "action_title_required" });
return;
}
const actionItem = await db.action_items.create({
clientId: client.id,
title: data.title,
due_at: data.due_at || null,
status: data.status || "not_started",
notes: data.notes,
createdById: req.currentUser.id,
updatedById: req.currentUser.id,
});
res.status(200).send(actionItem);
}),
);
router.post(
"/clients/:id/resources",
wrapAsync(async (req, res) => {
const data = req.body.data || req.body;
const client = await db.clients.findByPk(req.params.id);
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
if (!String(data.title || "").trim()) {
res.status(400).send({ error: "resource_title_required" });
return;
}
const resource = await db.resources.create({
clientId: client.id,
title: data.title,
description: data.description,
url: data.url,
resource_type: data.resource_type || "link",
is_shared: data.is_shared !== false,
createdById: req.currentUser.id,
updatedById: req.currentUser.id,
});
res.status(200).send(resource);
}),
);
router.get(
"/session-memory",
wrapAsync(async (req, res) => {

View File

@ -2,7 +2,10 @@ import {
mdiAccountGroup,
mdiCalendarClock,
mdiCheckCircleOutline,
mdiContentSaveOutline,
mdiFileDocumentOutline,
mdiLinkVariant,
mdiPlus,
mdiTarget,
} from '@mdi/js';
import axios from 'axios';
@ -19,6 +22,8 @@ type ActionItem = {
id: string;
title: string;
status: string;
due_at?: string;
notes?: string;
};
type PrepBrief = {
@ -31,10 +36,21 @@ type PrepBrief = {
client_reflection_at?: string;
};
type Resource = {
id: string;
title: string;
description?: string;
url?: string;
resource_type?: string;
is_shared?: boolean;
};
type Session = {
id: string;
title: string;
ai_summary?: string;
session_at?: string;
status?: string;
};
type Client = {
@ -48,8 +64,10 @@ type Client = {
role_title?: string;
tags?: string;
next_session_at?: string;
last_session_at?: string;
sessions?: Session[];
action_items?: ActionItem[];
resources?: Resource[];
prep_briefs?: PrepBrief[];
package?: {
title?: string;
@ -57,6 +75,77 @@ type Client = {
};
};
type ClientForm = {
name: string;
email: string;
status: string;
company: string;
role_title: string;
tags: string;
goals: string;
notes: string;
next_session_at: string;
};
function emptyClientForm(): ClientForm {
return {
name: '',
email: '',
status: 'active',
company: '',
role_title: '',
tags: '',
goals: '',
notes: '',
next_session_at: '',
};
}
function formFromClient(client: Client): ClientForm {
return {
name: client.name || '',
email: client.email || '',
status: client.status || 'active',
company: client.company || '',
role_title: client.role_title || '',
tags: client.tags || '',
goals: client.goals || '',
notes: client.notes || '',
next_session_at: toDateTimeInput(client.next_session_at),
};
}
function toDateTimeInput(value?: string) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return date.toISOString().slice(0, 16);
}
function displayDateTime(value?: string) {
if (!value) {
return 'No session scheduled';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
function Panel({
children,
className = '',
@ -81,6 +170,27 @@ function EmptyState({ label }: { label: string }) {
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<label className='block'>
<span className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
{label}
</span>
<div className='mt-2'>{children}</div>
</label>
);
}
function inputClass() {
return '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';
}
function PrepText({ value }: { value?: string }) {
if (!value) {
return <EmptyState label='No prep details saved yet.' />;
@ -105,6 +215,17 @@ const Clients = () => {
const router = useRouter();
const [clients, setClients] = React.useState<Client[]>([]);
const [selectedClientId, setSelectedClientId] = React.useState<string>('');
const [selectedClient, setSelectedClient] = React.useState<Client | null>(
null,
);
const [clientForm, setClientForm] =
React.useState<ClientForm>(emptyClientForm());
const [isCreatingClient, setIsCreatingClient] = React.useState(false);
const [isSavingClient, setIsSavingClient] = React.useState(false);
const [newActionTitle, setNewActionTitle] = React.useState('');
const [newResourceTitle, setNewResourceTitle] = React.useState('');
const [newResourceUrl, setNewResourceUrl] = React.useState('');
const [notice, setNotice] = React.useState('');
React.useEffect(() => {
async function loadClients() {
@ -113,12 +234,14 @@ const Clients = () => {
const queryClientId = router.query.clientId;
if (typeof queryClientId === 'string') {
setSelectedClientId(queryClientId);
await selectClient(queryClientId, false);
return;
}
if (response.data.length > 0) {
setSelectedClientId(response.data[0].id);
await selectClient(response.data[0].id, false);
} else {
startNewClient();
}
}
@ -127,20 +250,134 @@ const Clients = () => {
}
}, [router.isReady]);
const selectedClient =
clients.find((client) => client.id === selectedClientId) || clients[0];
const latestReflection = selectedClient?.prep_briefs?.find((brief) => {
return Boolean(brief.client_reflection);
});
const latestPrepBrief = selectedClient?.prep_briefs?.[0];
function selectClient(clientId: string) {
setSelectedClientId(clientId);
router.replace(`/clients?clientId=${clientId}`, undefined, {
shallow: true,
async function loadClient(clientId: string) {
const response = await axios.get(`/coaching/clients/${clientId}`);
const client = response.data;
setSelectedClient(client);
setClientForm(formFromClient(client));
setClients((current) => {
const exists = current.some((item) => item.id === client.id);
if (!exists) {
return [client, ...current];
}
return current.map((item) => {
if (item.id === client.id) {
return client;
}
return item;
});
});
}
async function selectClient(clientId: string, updateUrl = true) {
setSelectedClientId(clientId);
setIsCreatingClient(false);
setNotice('');
await loadClient(clientId);
if (updateUrl) {
router.replace(`/clients?clientId=${clientId}`, undefined, {
shallow: true,
});
}
}
function startNewClient() {
setIsCreatingClient(true);
setSelectedClientId('');
setSelectedClient(null);
setClientForm(emptyClientForm());
setNotice('');
}
function updateClientForm(field: keyof ClientForm, value: string) {
setClientForm((current) => {
return {
...current,
[field]: value,
};
});
}
async function saveClient() {
setIsSavingClient(true);
setNotice('');
try {
const payload = {
...clientForm,
next_session_at: clientForm.next_session_at || null,
};
const response = isCreatingClient
? await axios.post('/coaching/clients', payload)
: await axios.patch(`/coaching/clients/${selectedClientId}`, payload);
const client = response.data;
setSelectedClientId(client.id);
setSelectedClient(client);
setClientForm(formFromClient(client));
setIsCreatingClient(false);
setClients((current) => {
const exists = current.some((item) => item.id === client.id);
if (!exists) {
return [client, ...current];
}
return current.map((item) => {
if (item.id === client.id) {
return client;
}
return item;
});
});
router.replace(`/clients?clientId=${client.id}`, undefined, {
shallow: true,
});
setNotice('Client record saved.');
} finally {
setIsSavingClient(false);
}
}
async function addActionItem() {
if (!selectedClientId || !newActionTitle.trim()) {
return;
}
await axios.post(`/coaching/clients/${selectedClientId}/action-items`, {
title: newActionTitle,
});
setNewActionTitle('');
await loadClient(selectedClientId);
setNotice('Commitment added.');
}
async function addResource() {
if (!selectedClientId || !newResourceTitle.trim()) {
return;
}
await axios.post(`/coaching/clients/${selectedClientId}/resources`, {
title: newResourceTitle,
url: newResourceUrl,
resource_type: 'link',
is_shared: true,
});
setNewResourceTitle('');
setNewResourceUrl('');
await loadClient(selectedClientId);
setNotice('Resource added and shared with client.');
}
return (
<>
<Head>
@ -158,21 +395,35 @@ const Clients = () => {
</div>
<h1 className='mt-3 text-xl font-semibold'>Client records</h1>
<p className='mt-2 max-w-2xl text-sm leading-6 text-[#fffdf9]'>
Open a client record to review goals, coach notes, recent
sessions, and commitments.
Manage coaching relationships, prep context, commitments, and
shared resources in one working record.
</p>
</div>
<div className='rounded-none bg-white/10 px-4 py-2 text-sm font-semibold text-[#fffdf9]'>
{clients.length} clients
</div>
<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'
onClick={startNewClient}
>
<BaseIcon path={mdiPlus} size={18} />
New client
</button>
</div>
<div className='grid gap-6 xl:grid-cols-[0.85fr_1.15fr]'>
{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.75fr_1.25fr]'>
<Panel className='overflow-hidden'>
<div className='border-b border-[#19192d]/10 p-7'>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
Coaching relationships
</p>
<p className='mt-2 text-sm text-[#72798a]'>
{clients.length} clients in this workspace
</p>
</div>
<div className='divide-y divide-[#19192d]/10'>
{clients.map((client) => (
@ -181,7 +432,7 @@ const Clients = () => {
type='button'
onClick={() => selectClient(client.id)}
className={`block w-full p-7 text-left transition ${
selectedClient?.id === client.id
selectedClientId === client.id
? 'bg-[#fffdf9]'
: 'bg-white hover:bg-[#fffdf9]'
}`}
@ -192,236 +443,384 @@ const Clients = () => {
{client.name}
</p>
<p className='mt-1 text-sm text-[#72798a]'>
{client.role_title} · {client.company}
{[client.role_title, client.company]
.filter(Boolean)
.join(' · ') || client.email}
</p>
</div>
<span className='rounded-none bg-[#fffdf9] px-3 py-1 text-xs font-semibold text-[#35b7a5]'>
<span className='rounded-none bg-white px-3 py-1 text-xs font-semibold text-[#35b7a5]'>
{client.status}
</span>
</div>
<div className='mt-4 flex flex-wrap gap-2'>
{(client.tags || '')
.split(',')
.map((tag) => tag.trim())
.filter(Boolean)
.slice(0, 3)
.map((tag) => (
<span
key={tag}
className='rounded-none bg-white px-3 py-1 text-xs text-[#72798a]'
>
{tag}
</span>
))}
</div>
<p className='mt-4 text-sm text-[#72798a]'>
Next: {displayDateTime(client.next_session_at)}
</p>
</button>
))}
</div>
</Panel>
{selectedClient && (
<div className='space-y-6'>
<Panel className='p-4'>
<div className='flex flex-col justify-between gap-6 md:flex-row md:items-start'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
{selectedClient.package?.title || 'Coaching client'}
</p>
<h2 className='mt-2 text-xl font-semibold text-[#19192d]'>
{selectedClient.name}
</h2>
<p className='mt-2 text-[#72798a]'>
{selectedClient.email}
</p>
</div>
<div className='rounded-none border border-[#19192d]/10 bg-[#fffdf9] p-6 md:min-w-56'>
<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-sm text-[#72798a]'>
{selectedClient.next_session_at ||
'No session scheduled'}
</p>
</div>
<div className='space-y-6'>
<Panel className='p-7'>
<div className='flex flex-col justify-between gap-6 md:flex-row md:items-start'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
{isCreatingClient ? 'New coaching client' : 'Client file'}
</p>
<h2 className='mt-2 text-xl font-semibold text-[#19192d]'>
{isCreatingClient
? 'Create client record'
: selectedClient?.name || 'Client record'}
</h2>
</div>
<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 disabled:opacity-50'
disabled={isSavingClient}
onClick={saveClient}
>
<BaseIcon path={mdiContentSaveOutline} size={18} />
{isSavingClient ? 'Saving...' : 'Save client'}
</button>
</div>
<div className='mt-4 grid gap-6 lg:grid-cols-2'>
<div className='rounded-none bg-[#fffdf9] p-7'>
<div className='flex items-center gap-2 text-sm font-semibold text-[#35b7a5]'>
<BaseIcon path={mdiTarget} size={18} />
Goals
</div>
<p className='mt-3 leading-6 text-[#72798a]'>
{selectedClient.goals}
</p>
</div>
<div className='rounded-none bg-[#fffdf9] p-7'>
<div className='flex items-center gap-2 text-sm font-semibold text-[#35b7a5]'>
<BaseIcon path={mdiFileDocumentOutline} size={18} />
Private coach notes
</div>
<p className='mt-3 leading-6 text-[#72798a]'>
{selectedClient.notes}
</p>
</div>
</div>
</Panel>
<div className='mt-6 grid gap-6 lg:grid-cols-2'>
<Field label='Name'>
<input
value={clientForm.name}
onChange={(event) =>
updateClientForm('name', event.target.value)
}
className={inputClass()}
/>
</Field>
<Field label='Email'>
<input
value={clientForm.email}
type='email'
onChange={(event) =>
updateClientForm('email', event.target.value)
}
className={inputClass()}
/>
</Field>
<Field label='Status'>
<select
value={clientForm.status}
onChange={(event) =>
updateClientForm('status', event.target.value)
}
className={inputClass()}
>
<option value='lead'>Lead</option>
<option value='active'>Active</option>
<option value='paused'>Paused</option>
<option value='completed'>Completed</option>
</select>
</Field>
<Field label='Next session'>
<input
value={clientForm.next_session_at}
type='datetime-local'
onChange={(event) =>
updateClientForm('next_session_at', event.target.value)
}
className={inputClass()}
/>
</Field>
<Field label='Company'>
<input
value={clientForm.company}
onChange={(event) =>
updateClientForm('company', event.target.value)
}
className={inputClass()}
/>
</Field>
<Field label='Role title'>
<input
value={clientForm.role_title}
onChange={(event) =>
updateClientForm('role_title', event.target.value)
}
className={inputClass()}
/>
</Field>
<Field label='Tags'>
<input
value={clientForm.tags}
onChange={(event) =>
updateClientForm('tags', event.target.value)
}
className={inputClass()}
placeholder='executive, founder, delegation'
/>
</Field>
</div>
<Panel>
<div className='border-b border-[#19192d]/10 p-7'>
<div className='flex items-center justify-between gap-6'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
Next-session prep
</p>
<h3 className='mt-2 text-lg font-semibold text-[#19192d]'>
Coach prep brief
<div className='mt-6 grid gap-6 lg:grid-cols-2'>
<Field label='Goals'>
<textarea
value={clientForm.goals}
rows={5}
onChange={(event) =>
updateClientForm('goals', event.target.value)
}
className={inputClass()}
/>
</Field>
<Field label='Private coach notes'>
<textarea
value={clientForm.notes}
rows={5}
onChange={(event) =>
updateClientForm('notes', event.target.value)
}
className={inputClass()}
/>
</Field>
</div>
</Panel>
{selectedClient && !isCreatingClient && (
<>
<Panel>
<div className='border-b border-[#19192d]/10 p-7'>
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-center'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
Next-session prep
</p>
<h3 className='mt-2 text-lg font-semibold text-[#19192d]'>
Coach prep brief
</h3>
</div>
<div className='flex items-center gap-2 text-sm font-semibold text-[#35b7a5]'>
<BaseIcon path={mdiCalendarClock} size={18} />
{displayDateTime(selectedClient.next_session_at)}
</div>
</div>
</div>
{latestPrepBrief ? (
<div className='grid gap-6 p-7 lg:grid-cols-2'>
<div className='rounded-none bg-[#fffdf9] p-6'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Client reflection
</p>
<div className='mt-3'>
<PrepText
value={latestPrepBrief.client_reflection}
/>
</div>
</div>
<div className='rounded-none bg-[#fffdf9] p-6'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Open commitments
</p>
<div className='mt-3'>
<PrepText
value={latestPrepBrief.open_commitments}
/>
</div>
</div>
<div className='rounded-none bg-[#fffdf9] p-6'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Suggested questions
</p>
<div className='mt-3'>
<PrepText
value={latestPrepBrief.suggested_questions}
/>
</div>
</div>
<div className='rounded-none bg-[#fffdf9] p-6'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Watch points
</p>
<div className='mt-3'>
<PrepText
value={latestPrepBrief.sensitive_topics}
/>
</div>
</div>
</div>
) : (
<div className='p-7'>
<EmptyState label='No next-session prep brief yet. Generate session memory or ask the client for a reflection.' />
</div>
)}
</Panel>
<div className='grid gap-6 lg:grid-cols-2'>
<Panel>
<div className='border-b border-[#19192d]/10 p-7'>
<h3 className='text-lg font-semibold text-[#19192d]'>
Open commitments
</h3>
</div>
<span className='rounded-none bg-[#fffdf9] px-3 py-1 text-xs font-semibold text-[#35b7a5]'>
Ready
</span>
</div>
</div>
{latestPrepBrief ? (
<div className='grid gap-6 p-7 lg:grid-cols-2'>
<div className='rounded-none bg-[#fffdf9] p-6'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Client reflection
</p>
<div className='mt-3'>
<PrepText value={latestPrepBrief.client_reflection} />
</div>
</div>
<div className='rounded-none bg-[#fffdf9] p-6'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Open commitments
</p>
<div className='mt-3'>
<PrepText value={latestPrepBrief.open_commitments} />
</div>
</div>
<div className='rounded-none bg-[#fffdf9] p-6'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Suggested questions
</p>
<div className='mt-3'>
<PrepText
value={latestPrepBrief.suggested_questions}
<div className='border-b border-[#19192d]/10 p-7'>
<div className='flex flex-col gap-3 md:flex-row'>
<input
value={newActionTitle}
onChange={(event) =>
setNewActionTitle(event.target.value)
}
className={inputClass()}
placeholder='Add a commitment before the next session'
/>
</div>
</div>
<div className='rounded-none bg-[#fffdf9] p-6'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Watch points
</p>
<div className='mt-3'>
<PrepText value={latestPrepBrief.sensitive_topics} />
</div>
</div>
<div className='rounded-none bg-[#fffdf9] p-6 lg:col-span-2'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#35b7a5]'>
Previous summary
</p>
<div className='mt-3'>
<PrepText value={latestPrepBrief.previous_summary} />
</div>
</div>
</div>
) : (
<div className='p-5'>
<EmptyState label='No next-session prep brief yet.' />
</div>
)}
</Panel>
<div className='grid gap-6 lg:grid-cols-2'>
<Panel>
<div className='border-b border-[#19192d]/10 p-7'>
<h3 className='text-lg font-semibold text-[#19192d]'>
Session timeline
</h3>
</div>
<div className='space-y-4 p-7'>
{(selectedClient.sessions || []).length > 0 ? (
(selectedClient.sessions || []).map((session) => (
<div
key={session.id}
className='rounded-none border border-[#19192d]/10 bg-[#fffdf9] p-6'
<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={!newActionTitle.trim()}
onClick={addActionItem}
>
<p className='font-semibold text-[#19192d]'>
{session.title}
</p>
<p className='mt-2 text-sm leading-6 text-[#72798a]'>
{session.ai_summary}
</p>
</div>
))
) : (
<EmptyState label='No saved session memories yet.' />
)}
</div>
</Panel>
<Panel>
<div className='border-b border-[#19192d]/10 p-7'>
<h3 className='text-lg font-semibold text-[#19192d]'>
Pre-session reflection
</h3>
</div>
<div className='p-5'>
{latestReflection ? (
<div className='rounded-none border border-[#19192d]/10 bg-[#fffdf9] p-6'>
<p className='text-sm leading-6 text-[#72798a]'>
{latestReflection.client_reflection}
</p>
<BaseIcon path={mdiPlus} size={18} />
Add
</button>
</div>
) : (
<EmptyState label='No client reflection submitted yet.' />
)}
</div>
</Panel>
</div>
</div>
<div className='space-y-4 p-7'>
{(selectedClient.action_items || []).length > 0 ? (
(selectedClient.action_items || []).map((item) => (
<div
key={item.id}
className='flex gap-3 rounded-none border border-[#19192d]/10 bg-white p-6'
>
<span className='mt-0.5 grid h-8 w-8 flex-none place-items-center rounded-none bg-[#fffdf9] text-[#35b7a5]'>
<BaseIcon
path={mdiCheckCircleOutline}
size={18}
/>
</span>
<div>
<p className='font-semibold text-[#19192d]'>
{item.title}
</p>
<p className='mt-1 text-sm text-[#72798a]'>
{item.status.replace('_', ' ')}
</p>
</div>
</div>
))
) : (
<EmptyState label='No open commitments for this client.' />
)}
</div>
</Panel>
<div className='grid gap-6 lg:grid-cols-2'>
<Panel>
<div className='border-b border-[#19192d]/10 p-7'>
<h3 className='text-lg font-semibold text-[#19192d]'>
Open commitments
</h3>
</div>
<div className='space-y-4 p-7'>
{(selectedClient.action_items || []).length > 0 ? (
(selectedClient.action_items || []).map((item) => (
<div
key={item.id}
className='flex gap-3 rounded-none border border-[#19192d]/10 bg-white p-6'
>
<span className='mt-0.5 grid h-8 w-8 flex-none place-items-center rounded-none bg-[#fffdf9] text-[#35b7a5]'>
<BaseIcon
path={mdiCheckCircleOutline}
size={18}
/>
</span>
<div>
<Panel>
<div className='border-b border-[#19192d]/10 p-7'>
<h3 className='text-lg font-semibold text-[#19192d]'>
Shared resources
</h3>
</div>
<div className='space-y-3 border-b border-[#19192d]/10 p-7'>
<input
value={newResourceTitle}
onChange={(event) =>
setNewResourceTitle(event.target.value)
}
className={inputClass()}
placeholder='Resource title'
/>
<input
value={newResourceUrl}
onChange={(event) =>
setNewResourceUrl(event.target.value)
}
className={inputClass()}
placeholder='https://...'
/>
<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={!newResourceTitle.trim()}
onClick={addResource}
>
<BaseIcon path={mdiPlus} size={18} />
Add resource
</button>
</div>
<div className='space-y-4 p-7'>
{(selectedClient.resources || []).length > 0 ? (
(selectedClient.resources || []).map((resource) => (
<a
key={resource.id}
href={resource.url || '#'}
target='_blank'
rel='noreferrer'
className='flex items-start gap-3 rounded-none border border-[#19192d]/10 bg-white p-6 transition hover:bg-[#fffdf9]'
>
<span className='mt-0.5 grid h-8 w-8 flex-none place-items-center rounded-none bg-[#fffdf9] text-[#35b7a5]'>
<BaseIcon path={mdiLinkVariant} size={18} />
</span>
<span>
<span className='block font-semibold text-[#19192d]'>
{resource.title}
</span>
<span className='mt-1 block text-sm text-[#72798a]'>
{resource.description || resource.url}
</span>
</span>
</a>
))
) : (
<EmptyState label='No shared resources for this client yet.' />
)}
</div>
</Panel>
</div>
<div className='grid gap-6 lg:grid-cols-2'>
<Panel>
<div className='border-b border-[#19192d]/10 p-7'>
<h3 className='text-lg font-semibold text-[#19192d]'>
Session timeline
</h3>
</div>
<div className='space-y-4 p-7'>
{(selectedClient.sessions || []).length > 0 ? (
(selectedClient.sessions || []).map((session) => (
<div
key={session.id}
className='rounded-none border border-[#19192d]/10 bg-[#fffdf9] p-6'
>
<p className='font-semibold text-[#19192d]'>
{item.title}
{session.title}
</p>
<p className='mt-1 text-sm text-[#72798a]'>
{item.status.replace('_', ' ')}
<p className='mt-2 text-sm leading-6 text-[#72798a]'>
{session.ai_summary}
</p>
</div>
))
) : (
<EmptyState label='No saved session memories yet.' />
)}
</div>
</Panel>
<Panel>
<div className='border-b border-[#19192d]/10 p-7'>
<h3 className='text-lg font-semibold text-[#19192d]'>
Pre-session reflection
</h3>
</div>
<div className='p-7'>
{latestReflection ? (
<div className='rounded-none border border-[#19192d]/10 bg-[#fffdf9] p-6'>
<p className='text-sm leading-6 text-[#72798a]'>
{latestReflection.client_reflection}
</p>
</div>
))
) : (
<EmptyState label='No open commitments for this client.' />
)}
</div>
</Panel>
</div>
</div>
)}
) : (
<EmptyState label='No client reflection submitted yet.' />
)}
</div>
</Panel>
</div>
</>
)}
</div>
</div>
</div>
</SectionMain>