From cbebf46653a2beb2273b50aca07ab574d3a9f099 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Tue, 9 Jun 2026 18:01:43 +0000 Subject: [PATCH] Add coach client management flow --- backend/src/routes/coaching.js | 145 +++++- frontend/src/pages/clients.tsx | 841 ++++++++++++++++++++++++--------- 2 files changed, 758 insertions(+), 228 deletions(-) diff --git a/backend/src/routes/coaching.js b/backend/src/routes/coaching.js index 276df21..0a0ed5c 100644 --- a/backend/src/routes/coaching.js +++ b/backend/src/routes/coaching.js @@ -29,6 +29,32 @@ function splitActionItems(value) { .slice(0, 8); } +function clientPayload(data, userId) { + return { + name: data.name, + email: data.email, + status: data.status || "active", + goals: data.goals, + notes: data.notes, + company: data.company, + role_title: data.role_title, + tags: data.tags, + next_session_at: data.next_session_at || null, + last_session_at: data.last_session_at || null, + updatedById: userId, + }; +} + +function clientIncludes() { + return [ + { model: db.packages, as: "package" }, + { model: db.sessions, as: "sessions", order: [["session_at", "DESC"]] }, + { model: db.action_items, as: "action_items", order: [["due_at", "ASC"]] }, + { model: db.resources, as: "resources", order: [["createdAt", "DESC"]] }, + { model: db.prep_briefs, as: "prep_briefs", order: [["next_session_at", "DESC"]] }, + ]; +} + function getFirstUploadedFile(files, fieldName) { const file = files[fieldName]; @@ -270,17 +296,35 @@ router.get( }), ); +router.post( + "/clients", + wrapAsync(async (req, res) => { + const data = req.body.data || req.body; + + if (!String(data.name || "").trim()) { + res.status(400).send({ error: "client_name_required" }); + return; + } + + const client = await db.clients.create({ + ...clientPayload(data, req.currentUser.id), + ownerId: req.currentUser.id, + createdById: req.currentUser.id, + }); + + const savedClient = await db.clients.findByPk(client.id, { + include: clientIncludes(), + }); + + res.status(200).send(savedClient); + }), +); + router.get( "/clients/:id", wrapAsync(async (req, res) => { const client = await db.clients.findByPk(req.params.id, { - include: [ - { model: db.packages, as: "package" }, - { model: db.sessions, as: "sessions", order: [["session_at", "DESC"]] }, - { model: db.action_items, as: "action_items", order: [["due_at", "ASC"]] }, - { model: db.resources, as: "resources" }, - { model: db.prep_briefs, as: "prep_briefs", order: [["next_session_at", "DESC"]] }, - ], + include: clientIncludes(), }); if (!client) { @@ -292,6 +336,93 @@ router.get( }), ); +router.patch( + "/clients/:id", + wrapAsync(async (req, res) => { + const data = req.body.data || req.body; + const client = await db.clients.findByPk(req.params.id); + + if (!client) { + res.status(404).send({ error: "client_not_found" }); + return; + } + + if (!String(data.name || "").trim()) { + res.status(400).send({ error: "client_name_required" }); + return; + } + + await client.update(clientPayload(data, req.currentUser.id)); + + const savedClient = await db.clients.findByPk(client.id, { + include: clientIncludes(), + }); + + res.status(200).send(savedClient); + }), +); + +router.post( + "/clients/:id/action-items", + wrapAsync(async (req, res) => { + const data = req.body.data || req.body; + const client = await db.clients.findByPk(req.params.id); + + if (!client) { + res.status(404).send({ error: "client_not_found" }); + return; + } + + if (!String(data.title || "").trim()) { + res.status(400).send({ error: "action_title_required" }); + return; + } + + const actionItem = await db.action_items.create({ + clientId: client.id, + title: data.title, + due_at: data.due_at || null, + status: data.status || "not_started", + notes: data.notes, + createdById: req.currentUser.id, + updatedById: req.currentUser.id, + }); + + res.status(200).send(actionItem); + }), +); + +router.post( + "/clients/:id/resources", + wrapAsync(async (req, res) => { + const data = req.body.data || req.body; + const client = await db.clients.findByPk(req.params.id); + + if (!client) { + res.status(404).send({ error: "client_not_found" }); + return; + } + + if (!String(data.title || "").trim()) { + res.status(400).send({ error: "resource_title_required" }); + return; + } + + const resource = await db.resources.create({ + clientId: client.id, + title: data.title, + description: data.description, + url: data.url, + resource_type: data.resource_type || "link", + is_shared: data.is_shared !== false, + createdById: req.currentUser.id, + updatedById: req.currentUser.id, + }); + + res.status(200).send(resource); + }), +); + router.get( "/session-memory", wrapAsync(async (req, res) => { diff --git a/frontend/src/pages/clients.tsx b/frontend/src/pages/clients.tsx index d85a299..6220f89 100644 --- a/frontend/src/pages/clients.tsx +++ b/frontend/src/pages/clients.tsx @@ -2,7 +2,10 @@ import { mdiAccountGroup, mdiCalendarClock, mdiCheckCircleOutline, + mdiContentSaveOutline, mdiFileDocumentOutline, + mdiLinkVariant, + mdiPlus, mdiTarget, } from '@mdi/js'; import axios from 'axios'; @@ -19,6 +22,8 @@ type ActionItem = { id: string; title: string; status: string; + due_at?: string; + notes?: string; }; type PrepBrief = { @@ -31,10 +36,21 @@ type PrepBrief = { 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 = { @@ -48,8 +64,10 @@ type Client = { 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; @@ -57,6 +75,77 @@ type Client = { }; }; +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 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 = '', @@ -81,6 +170,27 @@ function EmptyState({ label }: { label: string }) { ); } +function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +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 ; @@ -105,6 +215,17 @@ 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 [notice, setNotice] = React.useState(''); React.useEffect(() => { async function loadClients() { @@ -113,12 +234,14 @@ const Clients = () => { const queryClientId = router.query.clientId; if (typeof queryClientId === 'string') { - setSelectedClientId(queryClientId); + await selectClient(queryClientId, false); return; } if (response.data.length > 0) { - setSelectedClientId(response.data[0].id); + await selectClient(response.data[0].id, false); + } else { + startNewClient(); } } @@ -127,20 +250,134 @@ const Clients = () => { } }, [router.isReady]); - const selectedClient = - clients.find((client) => client.id === selectedClientId) || clients[0]; const latestReflection = selectedClient?.prep_briefs?.find((brief) => { return Boolean(brief.client_reflection); }); const latestPrepBrief = selectedClient?.prep_briefs?.[0]; - function selectClient(clientId: string) { - setSelectedClientId(clientId); - router.replace(`/clients?clientId=${clientId}`, undefined, { - shallow: true, + 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) { + setClientForm((current) => { + return { + ...current, + [field]: value, + }; + }); + } + + async function saveClient() { + setIsSavingClient(true); + setNotice(''); + + try { + const payload = { + ...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.'); + } finally { + setIsSavingClient(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.'); + } + return ( <> @@ -158,21 +395,35 @@ const Clients = () => {

Client records

- Open a client record to review goals, coach notes, recent - sessions, and commitments. + Manage coaching relationships, prep context, commitments, and + shared resources in one working record.

-
- {clients.length} clients -
+ -
+ {notice && ( +
+ {notice} +
+ )} + +

Coaching relationships

+

+ {clients.length} clients in this workspace +

{clients.map((client) => ( @@ -181,7 +432,7 @@ const Clients = () => { type='button' onClick={() => selectClient(client.id)} className={`block w-full p-7 text-left transition ${ - selectedClient?.id === client.id + selectedClientId === client.id ? 'bg-[#fffdf9]' : 'bg-white hover:bg-[#fffdf9]' }`} @@ -192,236 +443,384 @@ const Clients = () => { {client.name}

- {client.role_title} · {client.company} + {[client.role_title, client.company] + .filter(Boolean) + .join(' · ') || client.email}

- + {client.status}
-
- {(client.tags || '') - .split(',') - .map((tag) => tag.trim()) - .filter(Boolean) - .slice(0, 3) - .map((tag) => ( - - {tag} - - ))} -
+

+ Next: {displayDateTime(client.next_session_at)} +

))}
- {selectedClient && ( -
- -
-
-

- {selectedClient.package?.title || 'Coaching client'} -

-

- {selectedClient.name} -

-

- {selectedClient.email} -

-
-
-
- - Next session -
-

- {selectedClient.next_session_at || - 'No session scheduled'} -

-
+
+ +
+
+

+ {isCreatingClient ? 'New coaching client' : 'Client file'} +

+

+ {isCreatingClient + ? 'Create client record' + : selectedClient?.name || 'Client record'} +

+ +
-
-
-
- - Goals -
-

- {selectedClient.goals} -

-
-
-
- - Private coach notes -
-

- {selectedClient.notes} -

-
-
-
+
+ + + updateClientForm('name', event.target.value) + } + className={inputClass()} + /> + + + + updateClientForm('email', event.target.value) + } + className={inputClass()} + /> + + + + + + + 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' + /> + +
- -
-
-
-

- Next-session prep -

-

- Coach prep brief +
+ +