Split clients list and detail views
This commit is contained in:
parent
cbebf46653
commit
2c902aa822
@ -1,15 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
mdiAccountGroup,
|
mdiAccountGroup,
|
||||||
mdiCalendarClock,
|
mdiArrowRight,
|
||||||
mdiCheckCircleOutline,
|
|
||||||
mdiContentSaveOutline,
|
mdiContentSaveOutline,
|
||||||
mdiFileDocumentOutline,
|
|
||||||
mdiLinkVariant,
|
|
||||||
mdiPlus,
|
mdiPlus,
|
||||||
mdiTarget,
|
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
@ -20,37 +17,7 @@ import LayoutAuthenticated from '../layouts/Authenticated';
|
|||||||
|
|
||||||
type ActionItem = {
|
type ActionItem = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
|
||||||
status: 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 = {
|
type Client = {
|
||||||
@ -58,21 +25,11 @@ type Client = {
|
|||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
status: string;
|
status: string;
|
||||||
goals?: string;
|
|
||||||
notes?: string;
|
|
||||||
company?: string;
|
company?: string;
|
||||||
role_title?: string;
|
role_title?: string;
|
||||||
tags?: string;
|
tags?: string;
|
||||||
next_session_at?: string;
|
next_session_at?: string;
|
||||||
last_session_at?: string;
|
|
||||||
sessions?: Session[];
|
|
||||||
action_items?: ActionItem[];
|
action_items?: ActionItem[];
|
||||||
resources?: Resource[];
|
|
||||||
prep_briefs?: PrepBrief[];
|
|
||||||
package?: {
|
|
||||||
title?: string;
|
|
||||||
duration?: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ClientForm = {
|
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) {
|
function displayDateTime(value?: string) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return 'No session scheduled';
|
return 'Not scheduled';
|
||||||
}
|
}
|
||||||
|
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
@ -162,12 +92,8 @@ function Panel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyState({ label }: { label: string }) {
|
function inputClass() {
|
||||||
return (
|
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';
|
||||||
<p className='rounded-none border border-dashed border-[#19192d]/10 bg-[#fffdf9] p-6 text-sm text-[#72798a]'>
|
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({
|
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 Clients = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [clients, setClients] = React.useState<Client[]>([]);
|
const [clients, setClients] = React.useState<Client[]>([]);
|
||||||
const [selectedClientId, setSelectedClientId] = React.useState<string>('');
|
|
||||||
const [selectedClient, setSelectedClient] = React.useState<Client | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [clientForm, setClientForm] =
|
const [clientForm, setClientForm] =
|
||||||
React.useState<ClientForm>(emptyClientForm());
|
React.useState<ClientForm>(emptyClientForm());
|
||||||
const [isCreatingClient, setIsCreatingClient] = React.useState(false);
|
const [isCreating, setIsCreating] = React.useState(false);
|
||||||
const [isSavingClient, setIsSavingClient] = React.useState(false);
|
const [isSaving, setIsSaving] = React.useState(false);
|
||||||
const [newActionTitle, setNewActionTitle] = React.useState('');
|
|
||||||
const [newResourceTitle, setNewResourceTitle] = React.useState('');
|
|
||||||
const [newResourceUrl, setNewResourceUrl] = React.useState('');
|
|
||||||
const [notice, setNotice] = React.useState('');
|
const [notice, setNotice] = React.useState('');
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
async function loadClients() {
|
loadClients();
|
||||||
const response = await axios.get('/coaching/clients');
|
}, []);
|
||||||
setClients(response.data);
|
|
||||||
|
|
||||||
const queryClientId = router.query.clientId;
|
async function loadClients() {
|
||||||
if (typeof queryClientId === 'string') {
|
const response = await axios.get('/coaching/clients');
|
||||||
await selectClient(queryClientId, false);
|
setClients(response.data);
|
||||||
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('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateClientForm(field: keyof ClientForm, value: string) {
|
function updateClientForm(field: keyof ClientForm, value: string) {
|
||||||
@ -307,75 +140,27 @@ const Clients = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveClient() {
|
async function createClient() {
|
||||||
setIsSavingClient(true);
|
setIsSaving(true);
|
||||||
setNotice('');
|
setNotice('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const response = await axios.post('/coaching/clients', {
|
||||||
...clientForm,
|
...clientForm,
|
||||||
next_session_at: clientForm.next_session_at || null,
|
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, {
|
await loadClients();
|
||||||
shallow: true,
|
setClientForm(emptyClientForm());
|
||||||
});
|
setIsCreating(false);
|
||||||
setNotice('Client record saved.');
|
await router.push(`/clients/${response.data.id}`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingClient(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addActionItem() {
|
function openCreateForm() {
|
||||||
if (!selectedClientId || !newActionTitle.trim()) {
|
setIsCreating(true);
|
||||||
return;
|
setNotice('');
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
@ -385,7 +170,7 @@ const Clients = () => {
|
|||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<div className='mx-auto max-w-7xl'>
|
<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>
|
||||||
<div className='flex items-center gap-3 text-[#35b7a5]'>
|
<div className='flex items-center gap-3 text-[#35b7a5]'>
|
||||||
<BaseIcon path={mdiAccountGroup} size={18} />
|
<BaseIcon path={mdiAccountGroup} size={18} />
|
||||||
@ -393,16 +178,16 @@ const Clients = () => {
|
|||||||
Client CRM
|
Client CRM
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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]'>
|
<p className='mt-2 max-w-2xl text-sm leading-6 text-[#fffdf9]'>
|
||||||
Manage coaching relationships, prep context, commitments, and
|
Scan the practice, add a client, and open a dedicated client
|
||||||
shared resources in one working record.
|
file when you need the full context.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type='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'
|
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} />
|
<BaseIcon path={mdiPlus} size={18} />
|
||||||
New client
|
New client
|
||||||
@ -415,413 +200,174 @@ const Clients = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className='grid gap-6 xl:grid-cols-[0.75fr_1.25fr]'>
|
{isCreating && (
|
||||||
<Panel className='overflow-hidden'>
|
<Panel className='mb-6 p-7'>
|
||||||
<div className='border-b border-[#19192d]/10 p-7'>
|
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-start'>
|
||||||
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
|
<div>
|
||||||
Coaching relationships
|
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
|
||||||
</p>
|
New client
|
||||||
<p className='mt-2 text-sm text-[#72798a]'>
|
</p>
|
||||||
{clients.length} clients in this workspace
|
<h2 className='mt-2 text-lg font-semibold text-[#19192d]'>
|
||||||
</p>
|
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>
|
||||||
<div className='divide-y divide-[#19192d]/10'>
|
|
||||||
{clients.map((client) => (
|
<div className='mt-6 grid gap-6 md:grid-cols-2 xl:grid-cols-4'>
|
||||||
<button
|
<Field label='Name'>
|
||||||
key={client.id}
|
<input
|
||||||
type='button'
|
value={clientForm.name}
|
||||||
onClick={() => selectClient(client.id)}
|
onChange={(event) =>
|
||||||
className={`block w-full p-7 text-left transition ${
|
updateClientForm('name', event.target.value)
|
||||||
selectedClientId === client.id
|
}
|
||||||
? 'bg-[#fffdf9]'
|
className={inputClass()}
|
||||||
: 'bg-white hover:bg-[#fffdf9]'
|
/>
|
||||||
}`}
|
</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'>
|
<option value='lead'>Lead</option>
|
||||||
<div>
|
<option value='active'>Active</option>
|
||||||
<p className='font-semibold text-[#19192d]'>
|
<option value='paused'>Paused</option>
|
||||||
{client.name}
|
<option value='completed'>Completed</option>
|
||||||
</p>
|
</select>
|
||||||
<p className='mt-1 text-sm text-[#72798a]'>
|
</Field>
|
||||||
{[client.role_title, client.company]
|
<Field label='Next session'>
|
||||||
.filter(Boolean)
|
<input
|
||||||
.join(' · ') || client.email}
|
value={clientForm.next_session_at}
|
||||||
</p>
|
type='datetime-local'
|
||||||
</div>
|
onChange={(event) =>
|
||||||
<span className='rounded-none bg-white px-3 py-1 text-xs font-semibold text-[#35b7a5]'>
|
updateClientForm('next_session_at', event.target.value)
|
||||||
{client.status}
|
}
|
||||||
</span>
|
className={inputClass()}
|
||||||
</div>
|
/>
|
||||||
<p className='mt-4 text-sm text-[#72798a]'>
|
</Field>
|
||||||
Next: {displayDateTime(client.next_session_at)}
|
<Field label='Tags'>
|
||||||
</p>
|
<input
|
||||||
</button>
|
value={clientForm.tags}
|
||||||
))}
|
onChange={(event) =>
|
||||||
|
updateClientForm('tags', event.target.value)
|
||||||
|
}
|
||||||
|
className={inputClass()}
|
||||||
|
placeholder='executive, founder, delegation'
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className='space-y-6'>
|
<Panel className='overflow-hidden'>
|
||||||
<Panel className='p-7'>
|
<div className='border-b border-[#19192d]/10 p-6'>
|
||||||
<div className='flex flex-col justify-between gap-6 md:flex-row md:items-start'>
|
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
|
||||||
<div>
|
Coaching relationships
|
||||||
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#35b7a5]'>
|
</p>
|
||||||
{isCreatingClient ? 'New coaching client' : 'Client file'}
|
<p className='mt-2 text-sm text-[#72798a]'>
|
||||||
</p>
|
{clients.length} clients in this workspace
|
||||||
<h2 className='mt-2 text-xl font-semibold text-[#19192d]'>
|
</p>
|
||||||
{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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</SectionMain>
|
</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