40234-vm/frontend/src/pages/clients.tsx
2026-06-09 18:40:39 +00:00

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;