Add coach client management flow
This commit is contained in:
parent
be3bbea7aa
commit
cbebf46653
@ -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) => {
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user