Split clients list and detail views

This commit is contained in:
Flatlogic Bot 2026-06-09 18:40:39 +00:00
parent cbebf46653
commit 2c902aa822
2 changed files with 876 additions and 644 deletions

View File

@ -1,15 +1,12 @@
import {
mdiAccountGroup,
mdiCalendarClock,
mdiCheckCircleOutline,
mdiArrowRight,
mdiContentSaveOutline,
mdiFileDocumentOutline,
mdiLinkVariant,
mdiPlus,
mdiTarget,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React from 'react';
import type { ReactElement } from 'react';
@ -20,37 +17,7 @@ import LayoutAuthenticated from '../layouts/Authenticated';
type ActionItem = {
id: string;
title: string;
status: string;
due_at?: string;
notes?: string;
};
type PrepBrief = {
id: string;
previous_summary?: string;
open_commitments?: string;
suggested_questions?: string;
sensitive_topics?: string;
client_reflection?: string;
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 = {
@ -58,21 +25,11 @@ type Client = {
name: string;
email: string;
status: string;
goals?: string;
notes?: string;
company?: string;
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;
duration?: string;
};
};
type ClientForm = {
@ -101,36 +58,9 @@ function emptyClientForm(): ClientForm {
};
}
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';
return 'Not scheduled';
}
const date = new Date(value);
@ -162,12 +92,8 @@ function Panel({
);
}
function EmptyState({ label }: { label: string }) {
return (
<p className='rounded-none border border-dashed border-[#19192d]/10 bg-[#fffdf9] p-6 text-sm text-[#72798a]'>
{label}
</p>
);
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 Field({
@ -187,115 +113,22 @@ function Field({
);
}
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.' />;
}
return (
<div className='space-y-2'>
{value
.split(/\n|;/)
.map((item) => item.trim())
.filter(Boolean)
.map((item) => (
<p key={item} className='text-sm leading-6 text-[#72798a]'>
{item}
</p>
))}
</div>
);
}
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 [isCreating, setIsCreating] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const [notice, setNotice] = React.useState('');
React.useEffect(() => {
async function loadClients() {
const response = await axios.get('/coaching/clients');
setClients(response.data);
loadClients();
}, []);
const queryClientId = router.query.clientId;
if (typeof queryClientId === 'string') {
await selectClient(queryClientId, false);
return;
}
if (response.data.length > 0) {
await selectClient(response.data[0].id, false);
} else {
startNewClient();
}
}
if (router.isReady) {
loadClients();
}
}, [router.isReady]);
const latestReflection = selectedClient?.prep_briefs?.find((brief) => {
return Boolean(brief.client_reflection);
});
const latestPrepBrief = selectedClient?.prep_briefs?.[0];
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('');
async function loadClients() {
const response = await axios.get('/coaching/clients');
setClients(response.data);
}
function updateClientForm(field: keyof ClientForm, value: string) {
@ -307,75 +140,27 @@ const Clients = () => {
});
}
async function saveClient() {
setIsSavingClient(true);
async function createClient() {
setIsSaving(true);
setNotice('');
try {
const payload = {
const response = await axios.post('/coaching/clients', {
...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.');
await loadClients();
setClientForm(emptyClientForm());
setIsCreating(false);
await router.push(`/clients/${response.data.id}`);
} finally {
setIsSavingClient(false);
setIsSaving(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.');
function openCreateForm() {
setIsCreating(true);
setNotice('');
}
return (
@ -385,7 +170,7 @@ const Clients = () => {
</Head>
<SectionMain>
<div className='mx-auto max-w-7xl'>
<div className='mb-4 flex flex-col justify-between gap-6 rounded-none bg-[#19192d] p-7 text-white md:flex-row md:items-end'>
<div className='mb-6 flex flex-col justify-between gap-6 rounded-none bg-[#19192d] p-7 text-white md:flex-row md:items-end'>
<div>
<div className='flex items-center gap-3 text-[#35b7a5]'>
<BaseIcon path={mdiAccountGroup} size={18} />
@ -393,16 +178,16 @@ const Clients = () => {
Client CRM
</span>
</div>
<h1 className='mt-3 text-xl font-semibold'>Client records</h1>
<h1 className='mt-3 text-xl font-semibold'>Clients</h1>
<p className='mt-2 max-w-2xl text-sm leading-6 text-[#fffdf9]'>
Manage coaching relationships, prep context, commitments, and
shared resources in one working record.
Scan the practice, add a client, and open a dedicated client
file when you need the full context.
</p>
</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}
onClick={openCreateForm}
>
<BaseIcon path={mdiPlus} size={18} />
New client
@ -415,413 +200,174 @@ const Clients = () => {
</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>
{isCreating && (
<Panel className='mb-6 p-7'>
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-start'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
New client
</p>
<h2 className='mt-2 text-lg font-semibold text-[#19192d]'>
Create a 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={isSaving}
onClick={createClient}
>
<BaseIcon path={mdiContentSaveOutline} size={18} />
{isSaving ? 'Saving...' : 'Create and open'}
</button>
</div>
<div className='divide-y divide-[#19192d]/10'>
{clients.map((client) => (
<button
key={client.id}
type='button'
onClick={() => selectClient(client.id)}
className={`block w-full p-7 text-left transition ${
selectedClientId === client.id
? 'bg-[#fffdf9]'
: 'bg-white hover:bg-[#fffdf9]'
}`}
<div className='mt-6 grid gap-6 md:grid-cols-2 xl:grid-cols-4'>
<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='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='Status'>
<select
value={clientForm.status}
onChange={(event) =>
updateClientForm('status', event.target.value)
}
className={inputClass()}
>
<div className='flex items-start justify-between gap-3'>
<div>
<p className='font-semibold text-[#19192d]'>
{client.name}
</p>
<p className='mt-1 text-sm text-[#72798a]'>
{[client.role_title, client.company]
.filter(Boolean)
.join(' · ') || client.email}
</p>
</div>
<span className='rounded-none bg-white px-3 py-1 text-xs font-semibold text-[#35b7a5]'>
{client.status}
</span>
</div>
<p className='mt-4 text-sm text-[#72798a]'>
Next: {displayDateTime(client.next_session_at)}
</p>
</button>
))}
<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='Tags'>
<input
value={clientForm.tags}
onChange={(event) =>
updateClientForm('tags', event.target.value)
}
className={inputClass()}
placeholder='executive, founder, delegation'
/>
</Field>
</div>
</Panel>
)}
<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-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>
<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>
<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'
/>
<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}
>
<BaseIcon path={mdiPlus} size={18} />
Add
</button>
</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>
<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]'>
{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-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 client reflection submitted yet.' />
)}
</div>
</Panel>
</div>
</>
)}
<Panel className='overflow-hidden'>
<div className='border-b border-[#19192d]/10 p-6'>
<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>
<div className='overflow-x-auto'>
<table className='min-w-full divide-y divide-[#19192d]/10 text-left text-sm'>
<thead className='bg-[#fffdf9] text-xs uppercase tracking-[0.16em] text-[#72798a]'>
<tr>
<th className='px-6 py-4 font-semibold'>Client</th>
<th className='px-6 py-4 font-semibold'>Status</th>
<th className='px-6 py-4 font-semibold'>Next session</th>
<th className='px-6 py-4 font-semibold'>Commitments</th>
<th className='px-6 py-4 font-semibold'>Tags</th>
<th className='px-6 py-4 font-semibold'></th>
</tr>
</thead>
<tbody className='divide-y divide-[#19192d]/10 bg-white'>
{clients.map((client) => {
const openActions = (client.action_items || []).filter(
(item) => item.status !== 'done',
).length;
return (
<tr key={client.id} className='hover:bg-[#fffdf9]'>
<td className='px-6 py-5'>
<p className='font-semibold text-[#19192d]'>
{client.name}
</p>
<p className='mt-1 text-sm text-[#72798a]'>
{[client.role_title, client.company]
.filter(Boolean)
.join(' · ') || client.email}
</p>
</td>
<td className='px-6 py-5'>
<span className='inline-flex rounded-none bg-[#fffdf9] px-3 py-1 text-xs font-semibold text-[#35b7a5]'>
{client.status}
</span>
</td>
<td className='px-6 py-5 text-[#72798a]'>
{displayDateTime(client.next_session_at)}
</td>
<td className='px-6 py-5 text-[#72798a]'>
{openActions}
</td>
<td className='max-w-xs px-6 py-5 text-[#72798a]'>
{(client.tags || '').trim() || '—'}
</td>
<td className='px-6 py-5 text-right'>
<Link
href={`/clients/view?clientId=${client.id}`}
className='inline-flex items-center justify-center gap-2 rounded-none bg-[#19192d] px-4 py-2 text-sm font-semibold text-white'
>
Open
<BaseIcon path={mdiArrowRight} size={18} />
</Link>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Panel>
</div>
</SectionMain>
</>

View File

@ -0,0 +1,686 @@
import {
mdiArrowLeft,
mdiCalendarClock,
mdiCheckCircleOutline,
mdiContentSaveOutline,
mdiFileDocumentOutline,
mdiLinkVariant,
mdiPlus,
mdiTarget,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
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';
type ActionItem = {
id: string;
title: string;
status: string;
due_at?: string;
notes?: string;
};
type PrepBrief = {
id: string;
previous_summary?: string;
open_commitments?: string;
suggested_questions?: string;
sensitive_topics?: string;
client_reflection?: string;
};
type Resource = {
id: string;
title: string;
description?: string;
url?: string;
};
type Session = {
id: string;
title: string;
ai_summary?: string;
session_at?: string;
status?: string;
};
type Client = {
id: string;
name: string;
email: string;
status: string;
goals?: string;
notes?: string;
company?: string;
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;
};
};
type ClientForm = {
name: string;
email: string;
status: string;
company: string;
role_title: string;
tags: string;
goals: string;
notes: string;
next_session_at: string;
};
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 = '',
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<section
className={`rounded-none border border-[#19192d]/10 bg-white ${className}`}
>
{children}
</section>
);
}
function EmptyState({ label }: { label: string }) {
return (
<p className='rounded-none border border-dashed border-[#19192d]/10 bg-[#fffdf9] p-6 text-sm text-[#72798a]'>
{label}
</p>
);
}
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.' />;
}
return (
<div className='space-y-2'>
{value
.split(/\n|;/)
.map((item) => item.trim())
.filter(Boolean)
.map((item) => (
<p key={item} className='text-sm leading-6 text-[#72798a]'>
{item}
</p>
))}
</div>
);
}
const ClientDetail = () => {
const router = useRouter();
const [client, setClient] = React.useState<Client | null>(null);
const [clientForm, setClientForm] = React.useState<ClientForm | null>(null);
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(() => {
if (!router.isReady || typeof router.query.clientId !== 'string') {
return;
}
loadClient(router.query.clientId);
}, [router.isReady, router.query.clientId]);
const latestReflection = client?.prep_briefs?.find((brief) => {
return Boolean(brief.client_reflection);
});
const latestPrepBrief = client?.prep_briefs?.[0];
async function loadClient(clientId: string) {
const response = await axios.get(`/coaching/clients/${clientId}`);
setClient(response.data);
setClientForm(formFromClient(response.data));
}
function updateClientForm(field: keyof ClientForm, value: string) {
setClientForm((current) => {
if (!current) {
return current;
}
return {
...current,
[field]: value,
};
});
}
async function saveClient() {
if (!client || !clientForm) {
return;
}
setIsSavingClient(true);
setNotice('');
try {
const response = await axios.patch(`/coaching/clients/${client.id}`, {
...clientForm,
next_session_at: clientForm.next_session_at || null,
});
setClient(response.data);
setClientForm(formFromClient(response.data));
setNotice('Client record saved.');
} finally {
setIsSavingClient(false);
}
}
async function addActionItem() {
if (!client || !newActionTitle.trim()) {
return;
}
await axios.post(`/coaching/clients/${client.id}/action-items`, {
title: newActionTitle,
});
setNewActionTitle('');
await loadClient(client.id);
setNotice('Commitment added.');
}
async function addResource() {
if (!client || !newResourceTitle.trim()) {
return;
}
await axios.post(`/coaching/clients/${client.id}/resources`, {
title: newResourceTitle,
url: newResourceUrl,
resource_type: 'link',
is_shared: true,
});
setNewResourceTitle('');
setNewResourceUrl('');
await loadClient(client.id);
setNotice('Resource added and shared with client.');
}
return (
<>
<Head>
<title>{getPageTitle(client?.name || 'Client')}</title>
</Head>
<SectionMain>
<div className='mx-auto max-w-7xl'>
<div className='mb-6'>
<Link
href='/clients'
className='inline-flex items-center gap-2 text-sm font-semibold text-[#72798a] hover:text-[#19192d]'
>
<BaseIcon path={mdiArrowLeft} size={18} />
Clients
</Link>
</div>
<div className='mb-6 flex flex-col justify-between gap-6 rounded-none bg-[#19192d] p-7 text-white md:flex-row md:items-end'>
<div>
<div className='flex items-center gap-3 text-[#35b7a5]'>
<BaseIcon path={mdiFileDocumentOutline} size={18} />
<span className='text-xs font-semibold uppercase tracking-[0.22em]'>
Client file
</span>
</div>
<h1 className='mt-3 text-xl font-semibold'>
{client?.name || 'Loading client...'}
</h1>
<p className='mt-2 max-w-2xl text-sm leading-6 text-[#fffdf9]'>
Edit profile context, review prep, and manage commitments and
shared resources.
</p>
</div>
<div className='flex items-center gap-2 text-sm font-semibold text-[#35b7a5]'>
<BaseIcon path={mdiCalendarClock} size={18} />
{displayDateTime(client?.next_session_at)}
</div>
</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>
)}
{client && clientForm ? (
<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]'>
{client.package?.title || 'Coaching client'}
</p>
<h2 className='mt-2 text-lg font-semibold text-[#19192d]'>
Profile
</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-6 grid gap-6 lg:grid-cols-4'>
<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()}
/>
</Field>
</div>
<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>
<Panel>
<div className='border-b border-[#19192d]/10 p-7'>
<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>
{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>
<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'
/>
<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}
>
<BaseIcon path={mdiPlus} size={18} />
Add
</button>
</div>
</div>
<div className='space-y-4 p-7'>
{(client.action_items || []).length > 0 ? (
(client.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>
<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'>
{(client.resources || []).length > 0 ? (
(client.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'>
{(client.sessions || []).length > 0 ? (
(client.sessions || []).map((session) => (
<div
key={session.id}
className='rounded-none border border-[#19192d]/10 bg-[#fffdf9] p-6'
>
<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-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 client reflection submitted yet.' />
)}
</div>
</Panel>
</div>
</div>
) : (
<Panel className='p-7'>
<p className='text-sm text-[#72798a]'>Loading client...</p>
</Panel>
)}
</div>
</SectionMain>
</>
);
};
ClientDetail.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default ClientDetail;