Split clients list and detail views
This commit is contained in:
parent
cbebf46653
commit
2c902aa822
@ -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>
|
||||
</>
|
||||
|
||||
686
frontend/src/pages/clients/view.tsx
Normal file
686
frontend/src/pages/clients/view.tsx
Normal 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;
|
||||
Loading…
x
Reference in New Issue
Block a user