diff --git a/frontend/src/pages/clients.tsx b/frontend/src/pages/clients.tsx
index 6220f89..81e4d0c 100644
--- a/frontend/src/pages/clients.tsx
+++ b/frontend/src/pages/clients.tsx
@@ -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 (
-
- {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 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 ;
- }
-
- return (
-
- {value
- .split(/\n|;/)
- .map((item) => item.trim())
- .filter(Boolean)
- .map((item) => (
-
- {item}
-
- ))}
-
- );
-}
-
const Clients = () => {
const router = useRouter();
const [clients, setClients] = React.useState([]);
- const [selectedClientId, setSelectedClientId] = React.useState('');
- const [selectedClient, setSelectedClient] = React.useState(
- null,
- );
const [clientForm, setClientForm] =
React.useState(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 = () => {
-
+
@@ -393,16 +178,16 @@ const Clients = () => {
Client CRM
-
Client records
+
Clients
- 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.
New client
@@ -415,413 +200,174 @@ const Clients = () => {
)}
-
-
-
-
- Coaching relationships
-
-
- {clients.length} clients in this workspace
-
+ {isCreating && (
+
+
+
+
+ New client
+
+
+ Create a client record
+
+
+
+
+ {isSaving ? 'Saving...' : 'Create and open'}
+
-
- {clients.map((client) => (
-
selectClient(client.id)}
- className={`block w-full p-7 text-left transition ${
- selectedClientId === client.id
- ? 'bg-[#fffdf9]'
- : 'bg-white hover:bg-[#fffdf9]'
- }`}
+
+
+
+
+ updateClientForm('name', event.target.value)
+ }
+ className={inputClass()}
+ />
+
+
+
+ updateClientForm('email', event.target.value)
+ }
+ className={inputClass()}
+ />
+
+
+
+ updateClientForm('company', event.target.value)
+ }
+ className={inputClass()}
+ />
+
+
+
+ updateClientForm('role_title', event.target.value)
+ }
+ className={inputClass()}
+ />
+
+
+
+ updateClientForm('status', event.target.value)
+ }
+ className={inputClass()}
>
-
-
-
- {client.name}
-
-
- {[client.role_title, client.company]
- .filter(Boolean)
- .join(' · ') || client.email}
-
-
-
- {client.status}
-
-
-
- Next: {displayDateTime(client.next_session_at)}
-
-
- ))}
+ Lead
+ Active
+ Paused
+ Completed
+
+
+
+
+ updateClientForm('next_session_at', event.target.value)
+ }
+ className={inputClass()}
+ />
+
+
+
+ updateClientForm('tags', event.target.value)
+ }
+ className={inputClass()}
+ placeholder='executive, founder, delegation'
+ />
+
+ )}
-
-
-
-
-
- {isCreatingClient ? 'New coaching client' : 'Client file'}
-
-
- {isCreatingClient
- ? 'Create client record'
- : selectedClient?.name || 'Client record'}
-
-
-
-
- {isSavingClient ? 'Saving...' : 'Save client'}
-
-
-
-
-
-
- updateClientForm('name', event.target.value)
- }
- className={inputClass()}
- />
-
-
-
- updateClientForm('email', event.target.value)
- }
- className={inputClass()}
- />
-
-
-
- updateClientForm('status', event.target.value)
- }
- className={inputClass()}
- >
- Lead
- Active
- Paused
- Completed
-
-
-
-
- updateClientForm('next_session_at', event.target.value)
- }
- className={inputClass()}
- />
-
-
-
- updateClientForm('company', event.target.value)
- }
- className={inputClass()}
- />
-
-
-
- updateClientForm('role_title', event.target.value)
- }
- className={inputClass()}
- />
-
-
-
- updateClientForm('tags', event.target.value)
- }
- className={inputClass()}
- placeholder='executive, founder, delegation'
- />
-
-
-
-
-
-
-
-
-
-
-
- {selectedClient && !isCreatingClient && (
- <>
-
-
-
-
-
- Next-session prep
-
-
- Coach prep brief
-
-
-
-
- {displayDateTime(selectedClient.next_session_at)}
-
-
-
- {latestPrepBrief ? (
-
-
-
- Client reflection
-
-
-
-
-
- Open commitments
-
-
-
-
-
- Suggested questions
-
-
-
-
-
- ) : (
-
-
-
- )}
-
-
-
-
-
-
- Open commitments
-
-
-
-
-
- setNewActionTitle(event.target.value)
- }
- className={inputClass()}
- placeholder='Add a commitment before the next session'
- />
-
-
- Add
-
-
-
-
- {(selectedClient.action_items || []).length > 0 ? (
- (selectedClient.action_items || []).map((item) => (
-
-
-
-
-
-
- {item.title}
-
-
- {item.status.replace('_', ' ')}
-
-
-
- ))
- ) : (
-
- )}
-
-
-
-
-
-
- Shared resources
-
-
-
-
- setNewResourceTitle(event.target.value)
- }
- className={inputClass()}
- placeholder='Resource title'
- />
-
- setNewResourceUrl(event.target.value)
- }
- className={inputClass()}
- placeholder='https://...'
- />
-
-
- Add resource
-
-
-
-
-
-
-
-
-
-
- Session timeline
-
-
-
- {(selectedClient.sessions || []).length > 0 ? (
- (selectedClient.sessions || []).map((session) => (
-
-
- {session.title}
-
-
- {session.ai_summary}
-
-
- ))
- ) : (
-
- )}
-
-
-
-
-
-
- Pre-session reflection
-
-
-
- {latestReflection ? (
-
-
- {latestReflection.client_reflection}
-
-
- ) : (
-
- )}
-
-
-
- >
- )}
+
+
+
+ Coaching relationships
+
+
+ {clients.length} clients in this workspace
+
-
+
+
+
+
+
+ Client
+ Status
+ Next session
+ Commitments
+ Tags
+
+
+
+
+ {clients.map((client) => {
+ const openActions = (client.action_items || []).filter(
+ (item) => item.status !== 'done',
+ ).length;
+
+ return (
+
+
+
+ {client.name}
+
+
+ {[client.role_title, client.company]
+ .filter(Boolean)
+ .join(' · ') || client.email}
+
+
+
+
+ {client.status}
+
+
+
+ {displayDateTime(client.next_session_at)}
+
+
+ {openActions}
+
+
+ {(client.tags || '').trim() || '—'}
+
+
+
+ Open
+
+
+
+
+ );
+ })}
+
+
+
+
>
diff --git a/frontend/src/pages/clients/view.tsx b/frontend/src/pages/clients/view.tsx
new file mode 100644
index 0000000..ed79ad6
--- /dev/null
+++ b/frontend/src/pages/clients/view.tsx
@@ -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 (
+
+ );
+}
+
+function EmptyState({ label }: { label: string }) {
+ return (
+
+ {label}
+
+ );
+}
+
+function Field({
+ label,
+ children,
+}: {
+ label: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {label}
+
+ {children}
+
+ );
+}
+
+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 ;
+ }
+
+ return (
+
+ {value
+ .split(/\n|;/)
+ .map((item) => item.trim())
+ .filter(Boolean)
+ .map((item) => (
+
+ {item}
+
+ ))}
+
+ );
+}
+
+const ClientDetail = () => {
+ const router = useRouter();
+ const [client, setClient] = React.useState(null);
+ const [clientForm, setClientForm] = React.useState(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 (
+ <>
+
+ {getPageTitle(client?.name || 'Client')}
+
+
+
+
+
+
+ Clients
+
+
+
+
+
+
+
+
+ Client file
+
+
+
+ {client?.name || 'Loading client...'}
+
+
+ Edit profile context, review prep, and manage commitments and
+ shared resources.
+
+
+
+
+ {displayDateTime(client?.next_session_at)}
+
+
+
+ {notice && (
+
+ {notice}
+
+ )}
+
+ {client && clientForm ? (
+
+
+
+
+
+ {client.package?.title || 'Coaching client'}
+
+
+ Profile
+
+
+
+
+ {isSavingClient ? 'Saving...' : 'Save client'}
+
+
+
+
+
+
+ updateClientForm('name', event.target.value)
+ }
+ className={inputClass()}
+ />
+
+
+
+ updateClientForm('email', event.target.value)
+ }
+ className={inputClass()}
+ />
+
+
+
+ updateClientForm('status', event.target.value)
+ }
+ className={inputClass()}
+ >
+ Lead
+ Active
+ Paused
+ Completed
+
+
+
+
+ updateClientForm('next_session_at', event.target.value)
+ }
+ className={inputClass()}
+ />
+
+
+
+ updateClientForm('company', event.target.value)
+ }
+ className={inputClass()}
+ />
+
+
+
+ updateClientForm('role_title', event.target.value)
+ }
+ className={inputClass()}
+ />
+
+
+
+ updateClientForm('tags', event.target.value)
+ }
+ className={inputClass()}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Next-session prep
+
+
+ Coach prep brief
+
+
+ {latestPrepBrief ? (
+
+
+
+ Client reflection
+
+
+
+
+
+ Open commitments
+
+
+
+
+
+ Suggested questions
+
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+
+ Open commitments
+
+
+
+
+
+ setNewActionTitle(event.target.value)
+ }
+ className={inputClass()}
+ placeholder='Add a commitment before the next session'
+ />
+
+
+ Add
+
+
+
+
+ {(client.action_items || []).length > 0 ? (
+ (client.action_items || []).map((item) => (
+
+
+
+
+
+
+ {item.title}
+
+
+ {item.status.replace('_', ' ')}
+
+
+
+ ))
+ ) : (
+
+ )}
+
+
+
+
+
+
+ Shared resources
+
+
+
+
+ setNewResourceTitle(event.target.value)
+ }
+ className={inputClass()}
+ placeholder='Resource title'
+ />
+
+ setNewResourceUrl(event.target.value)
+ }
+ className={inputClass()}
+ placeholder='https://...'
+ />
+
+
+ Add resource
+
+
+
+
+
+
+
+
+
+
+ Session timeline
+
+
+
+ {(client.sessions || []).length > 0 ? (
+ (client.sessions || []).map((session) => (
+
+
+ {session.title}
+
+
+ {session.ai_summary}
+
+
+ ))
+ ) : (
+
+ )}
+
+
+
+
+
+
+ Pre-session reflection
+
+
+
+ {latestReflection ? (
+
+
+ {latestReflection.client_reflection}
+
+
+ ) : (
+
+ )}
+
+
+
+
+ ) : (
+
+ Loading client...
+
+ )}
+
+
+ >
+ );
+};
+
+ClientDetail.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};
+
+export default ClientDetail;