382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
import {
|
|
mdiAccountGroup,
|
|
mdiArrowRight,
|
|
mdiContentSaveOutline,
|
|
mdiPlus,
|
|
} 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;
|
|
status: string;
|
|
};
|
|
|
|
type Client = {
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
status: string;
|
|
company?: string;
|
|
role_title?: string;
|
|
tags?: string;
|
|
next_session_at?: string;
|
|
action_items?: ActionItem[];
|
|
};
|
|
|
|
type ClientForm = {
|
|
name: string;
|
|
email: string;
|
|
status: string;
|
|
company: string;
|
|
role_title: string;
|
|
tags: string;
|
|
goals: string;
|
|
notes: string;
|
|
next_session_at: string;
|
|
};
|
|
|
|
function emptyClientForm(): ClientForm {
|
|
return {
|
|
name: '',
|
|
email: '',
|
|
status: 'active',
|
|
company: '',
|
|
role_title: '',
|
|
tags: '',
|
|
goals: '',
|
|
notes: '',
|
|
next_session_at: '',
|
|
};
|
|
}
|
|
|
|
function displayDateTime(value?: string) {
|
|
if (!value) {
|
|
return 'Not 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 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({
|
|
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>
|
|
);
|
|
}
|
|
|
|
const Clients = () => {
|
|
const router = useRouter();
|
|
const [clients, setClients] = React.useState<Client[]>([]);
|
|
const [clientForm, setClientForm] =
|
|
React.useState<ClientForm>(emptyClientForm());
|
|
const [isCreating, setIsCreating] = React.useState(false);
|
|
const [isSaving, setIsSaving] = React.useState(false);
|
|
const [notice, setNotice] = React.useState('');
|
|
|
|
React.useEffect(() => {
|
|
loadClients();
|
|
}, []);
|
|
|
|
async function loadClients() {
|
|
const response = await axios.get('/coaching/clients');
|
|
setClients(response.data);
|
|
}
|
|
|
|
function updateClientForm(field: keyof ClientForm, value: string) {
|
|
setClientForm((current) => {
|
|
return {
|
|
...current,
|
|
[field]: value,
|
|
};
|
|
});
|
|
}
|
|
|
|
async function createClient() {
|
|
setIsSaving(true);
|
|
setNotice('');
|
|
|
|
try {
|
|
const response = await axios.post('/coaching/clients', {
|
|
...clientForm,
|
|
next_session_at: clientForm.next_session_at || null,
|
|
});
|
|
await loadClients();
|
|
setClientForm(emptyClientForm());
|
|
setIsCreating(false);
|
|
await router.push(`/clients/${response.data.id}`);
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
function openCreateForm() {
|
|
setIsCreating(true);
|
|
setNotice('');
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>{getPageTitle('Clients')}</title>
|
|
</Head>
|
|
<SectionMain>
|
|
<div className='mx-auto max-w-7xl'>
|
|
<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} />
|
|
<span className='text-xs font-semibold uppercase tracking-[0.22em]'>
|
|
Client CRM
|
|
</span>
|
|
</div>
|
|
<h1 className='mt-3 text-xl font-semibold'>Clients</h1>
|
|
<p className='mt-2 max-w-2xl text-sm leading-6 text-[#fffdf9]'>
|
|
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={openCreateForm}
|
|
>
|
|
<BaseIcon path={mdiPlus} size={18} />
|
|
New client
|
|
</button>
|
|
</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>
|
|
)}
|
|
|
|
{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='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()}
|
|
>
|
|
<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>
|
|
)}
|
|
|
|
<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 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>
|
|
</>
|
|
);
|
|
};
|
|
|
|
Clients.getLayout = function getLayout(page: ReactElement) {
|
|
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
|
};
|
|
|
|
export default Clients;
|