From ddfad057063cb1d6ff43733c10b8f8f23042e949 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Tue, 9 Jun 2026 12:58:28 +0000 Subject: [PATCH] Redesign coaching workspace screens --- frontend/src/layouts/Authenticated.tsx | 320 +++++++++++++++++------- frontend/src/pages/client-portal.tsx | 290 ++++++++++++++++------ frontend/src/pages/clients.tsx | 327 +++++++++++++++++++------ frontend/src/pages/dashboard.tsx | 297 ++++++++++++++++------ frontend/src/pages/session-memory.tsx | 303 ++++++++++++++++------- 5 files changed, 1129 insertions(+), 408 deletions(-) diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index cc97f6a..14ee234 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,129 +1,261 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import { + mdiAccountCircle, + mdiAccountGroup, + mdiBookOpenVariant, + mdiClose, + mdiFileDocumentEditOutline, + mdiLogout, + mdiMenu, + mdiShieldAccountOutline, + mdiViewDashboardOutline, +} from '@mdi/js'; import jwt from 'jsonwebtoken'; -import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' -import menuAside from '../menuAside' -import menuNavBar from '../menuNavBar' -import BaseIcon from '../components/BaseIcon' -import NavBar from '../components/NavBar' -import NavBarItemPlain from '../components/NavBarItemPlain' -import AsideMenu from '../components/AsideMenu' -import FooterBar from '../components/FooterBar' -import { useAppDispatch, useAppSelector } from '../stores/hooks' -import Search from '../components/Search'; -import { useRouter } from 'next/router' -import {findMe, logoutUser} from "../stores/authSlice"; - -import {hasPermission} from "../helpers/userPermissions"; - +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import React, { ReactNode, useEffect, useState } from 'react'; +import BaseIcon from '../components/BaseIcon'; +import { hasPermission } from '../helpers/userPermissions'; +import { findMe, logoutUser } from '../stores/authSlice'; +import { useAppDispatch, useAppSelector } from '../stores/hooks'; type Props = { - children: ReactNode - - permission?: string - -} + children: ReactNode; + permission?: string; +}; -export default function LayoutAuthenticated({ - children, - - permission - -}: Props) { - const dispatch = useAppDispatch() - const router = useRouter() - const { token, currentUser } = useAppSelector((state) => state.auth) - const bgColor = useAppSelector((state) => state.style.bgLayoutColor); - let localToken - if (typeof window !== 'undefined') { - // Perform localStorage action - localToken = localStorage.getItem('token') +const navItems = [ + { + href: '/dashboard', + icon: mdiViewDashboardOutline, + label: 'Dashboard', + }, + { + href: '/clients', + icon: mdiAccountGroup, + label: 'Clients', + }, + { + href: '/session-memory', + icon: mdiFileDocumentEditOutline, + label: 'Session Memory', + }, + { + href: '/client-portal', + icon: mdiBookOpenVariant, + label: 'Client Portal', + }, + { + href: '/users/users-list', + icon: mdiShieldAccountOutline, + label: 'Team', + permission: 'READ_USERS', + }, + { + href: '/profile', + icon: mdiAccountCircle, + label: 'Profile', + }, +]; + +export default function LayoutAuthenticated({ children, permission }: Props) { + const dispatch = useAppDispatch(); + const router = useRouter(); + const { token, currentUser } = useAppSelector((state) => state.auth); + const [isAsideOpen, setIsAsideOpen] = useState(false); + + function getLocalToken() { + if (typeof window === 'undefined') { + return null; + } + + return localStorage.getItem('token'); } - const isTokenValid = () => { - const token = localStorage.getItem('token'); - if (!token) return; - const date = new Date().getTime() / 1000; - const data = jwt.decode(token); - if (!data) return; - return date < data.exp; - }; + function isTokenValid() { + const localToken = getLocalToken(); + if (!localToken) { + return false; + } + + const decodedToken = jwt.decode(localToken); + if (!decodedToken || typeof decodedToken === 'string') { + return false; + } + + if (!decodedToken.exp) { + return false; + } + + const now = new Date().getTime() / 1000; + return now < decodedToken.exp; + } useEffect(() => { dispatch(findMe()); + if (!isTokenValid()) { dispatch(logoutUser()); router.push('/login'); } - }, [token, localToken]); + }, [token]); - useEffect(() => { - if (!permission || !currentUser) return; + if (!permission || !currentUser) { + return; + } - if (!hasPermission(currentUser, permission)) router.push('/error'); + if (!hasPermission(currentUser, permission)) { + router.push('/error'); + } }, [currentUser, permission]); - - - const darkMode = useAppSelector((state) => state.style.darkMode) - - const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false) - const [isAsideLgActive, setIsAsideLgActive] = useState(false) useEffect(() => { - const handleRouteChangeStart = () => { - setIsAsideMobileExpanded(false) - setIsAsideLgActive(false) + function closeAside() { + setIsAsideOpen(false); } - router.events.on('routeChangeStart', handleRouteChangeStart) + router.events.on('routeChangeStart', closeAside); - // If the component is unmounted, unsubscribe - // from the event with the `off` method: return () => { - router.events.off('routeChangeStart', handleRouteChangeStart) + router.events.off('routeChangeStart', closeAside); + }; + }, [router.events]); + + const visibleNavItems = navItems.filter((item) => { + if (!item.permission) { + return true; } - }, [router.events, dispatch]) + if (!currentUser) { + return false; + } - const layoutAsidePadding = 'xl:pl-60' + return hasPermission(currentUser, item.permission); + }); + + const activePath = router.pathname.split('/')[1]; return ( -
-
+ + + {isAsideOpen && ( + +
+

+ Never lose the thread +

+

+ Coach admin, session memory, and client portal in one workspace. +

+
+ + Public site + +
+ + +
{children}
- ) + ); } diff --git a/frontend/src/pages/client-portal.tsx b/frontend/src/pages/client-portal.tsx index b105812..11bd1fe 100644 --- a/frontend/src/pages/client-portal.tsx +++ b/frontend/src/pages/client-portal.tsx @@ -1,13 +1,18 @@ -import * as icon from '@mdi/js'; -import Head from 'next/head' -import React from 'react' +import { + mdiAccountCircle, + mdiBookOpenVariant, + mdiCheckCircleOutline, + mdiChevronRight, + mdiMessageReplyTextOutline, +} from '@mdi/js'; import axios from 'axios'; -import type { ReactElement } from 'react' -import LayoutAuthenticated from '../layouts/Authenticated' -import SectionMain from '../components/SectionMain' -import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton' -import CardBox from '../components/CardBox' -import { getPageTitle } from '../config' +import Head from 'next/head'; +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 PortalClient = { id: string; @@ -15,18 +20,44 @@ type PortalClient = { goals?: string; sessions?: Array<{ id: string; title: string; shared_client_notes?: string }>; action_items?: Array<{ id: string; title: string; status: string }>; - resources?: Array<{ id: string; title: string; description?: string; url?: string }>; + resources?: Array<{ + id: string; + title: string; + description?: string; + url?: string; + }>; }; +function Panel({ + children, + className = '', +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + const ClientPortal = () => { - const [clients, setClients] = React.useState>([]); + const [clients, setClients] = React.useState< + Array<{ id: string; name: string }> + >([]); const [clientId, setClientId] = React.useState(''); - const [portalClient, setPortalClient] = React.useState(null); + const [portalClient, setPortalClient] = React.useState( + null, + ); React.useEffect(() => { async function loadClients() { const response = await axios.get('/coaching/clients'); setClients(response.data); + if (response.data.length > 0) { setClientId(response.data[0].id); } @@ -54,75 +85,182 @@ const ClientPortal = () => { {getPageTitle('Client Portal')} - - {''} - - - - - - - - {portalClient && ( -
- -

Your coaching workspace

-

{portalClient.name}

-

{portalClient.goals}

- -

Shared session notes

-
- {(portalClient.sessions || []).map((session) => ( -
-

{session.title}

-

{session.shared_client_notes}

-
- ))} +
+
+
+
+ + + Client portal +
- - -
- -

Commitments

-
- {(portalClient.action_items || []).map((item) => ( -
-

{item.title}

-

{item.status.replace('_', ' ')}

-
- ))} -
-
- - -

Resources

-
- {(portalClient.resources || []).map((resource) => ( - -

{resource.title}

-

{resource.description}

-
- ))} -
-
+

+ A private space for the client. +

+

+ This preview shows the simpler coachee experience: shared notes, + commitments, resources, and a reflection prompt before the next + session. +

+ + + +

+ MVP note: this is still a coach-visible preview. Final client + access should be a client role or magic-link route. +

+
- )} + + {portalClient && ( +
+ +
+

+ Your coaching workspace +

+

+ {portalClient.name} +

+

+ {portalClient.goals} +

+
+ +
+
+ + + +
+

+ Shared session notes +

+

+ Only coach-approved notes appear here. +

+
+
+ +
+ {(portalClient.sessions || []).map((session) => ( +
+

+ {session.title} +

+

+ {session.shared_client_notes} +

+
+ ))} +
+
+
+ +
+ +
+

+ Commitments +

+
+
+ {(portalClient.action_items || []).map((item) => ( +
+ + + +
+

+ {item.title} +

+

+ {item.status.replace('_', ' ')} +

+
+
+ ))} +
+
+ + +
+

+ Resources +

+
+
+ {(portalClient.resources || []).map((resource) => ( + +
+ + + +
+

+ {resource.title} +

+

+ {resource.description} +

+
+
+ +
+ ))} +
+
+ + +

+ Pre-session reflection +

+

+ What changed since last time? +

+

+ Final MVP should let the client answer this before a + session, then feed the response into the coach prep brief. +

+
+
+
+ )} +
- ) -} + ); +}; ClientPortal.getLayout = function getLayout(page: ReactElement) { - return {page} -} + return {page}; +}; -export default ClientPortal +export default ClientPortal; diff --git a/frontend/src/pages/clients.tsx b/frontend/src/pages/clients.tsx index 251dfec..55ebeb0 100644 --- a/frontend/src/pages/clients.tsx +++ b/frontend/src/pages/clients.tsx @@ -1,13 +1,19 @@ -import * as icon from '@mdi/js'; -import Head from 'next/head' -import React from 'react' +import { + mdiAccountGroup, + mdiCalendarClock, + mdiCheckCircleOutline, + mdiFileDocumentOutline, + mdiTarget, +} from '@mdi/js'; import axios from 'axios'; -import type { ReactElement } from 'react' -import LayoutAuthenticated from '../layouts/Authenticated' -import SectionMain from '../components/SectionMain' -import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton' -import CardBox from '../components/CardBox' -import { getPageTitle } from '../config' +import Head from 'next/head'; +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; @@ -31,6 +37,7 @@ type Client = { company?: string; role_title?: string; tags?: string; + next_session_at?: string; sessions?: Session[]; action_items?: ActionItem[]; package?: { @@ -39,7 +46,32 @@ type Client = { }; }; +function Panel({ + children, + className = '', +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +function EmptyState({ label }: { label: string }) { + return ( +

+ {label} +

+ ); +} + const Clients = () => { + const router = useRouter(); const [clients, setClients] = React.useState([]); const [selectedClientId, setSelectedClientId] = React.useState(''); @@ -47,15 +79,32 @@ const Clients = () => { async function loadClients() { const response = await axios.get('/coaching/clients'); setClients(response.data); + + const queryClientId = router.query.clientId; + if (typeof queryClientId === 'string') { + setSelectedClientId(queryClientId); + return; + } + if (response.data.length > 0) { setSelectedClientId(response.data[0].id); } } - loadClients(); - }, []); + if (router.isReady) { + loadClients(); + } + }, [router.isReady]); - const selectedClient = clients.find((client) => client.id === selectedClientId) || clients[0]; + const selectedClient = + clients.find((client) => client.id === selectedClientId) || clients[0]; + + function selectClient(clientId: string) { + setSelectedClientId(clientId); + router.replace(`/clients?clientId=${clientId}`, undefined, { + shallow: true, + }); + } return ( <> @@ -63,85 +112,203 @@ const Clients = () => { {getPageTitle('Clients')} - - {''} - - -
- -
- {clients.map((client) => ( - - ))} +
+
+
+
+ + + Client CRM + +
+

+ Relationship memory hub +

+

+ Open any client and see goals, coaching notes, recent sessions, + and commitments without digging through scattered docs. +

- +
+ {clients.length} clients +
+
- {selectedClient && ( - -
-

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

-

{selectedClient.name}

-

{selectedClient.email}

+
+ +
+

+ Coaching relationships +

- -
-
-

Goals

-

{selectedClient.goals}

-
-
-

Coach Notes

-

{selectedClient.notes}

-
-
- -
-
-

Recent Sessions

-
- {(selectedClient.sessions || []).map((session) => ( -
-

{session.title}

-

{session.ai_summary}

+
+ {clients.map((client) => ( +
-
-

Action Items

-
- {(selectedClient.action_items || []).map((item) => ( -
-

{item.title}

-

{item.status.replace('_', ' ')}

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

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

+

+ {selectedClient.name} +

+

+ {selectedClient.email} +

+
+
+
+ + Next session
- ))} +

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

+
+ +
+
+
+ + Goals +
+

+ {selectedClient.goals} +

+
+
+
+ + Private coach notes +
+

+ {selectedClient.notes} +

+
+
+
+ +
+ +
+

+ Session timeline +

+
+
+ {(selectedClient.sessions || []).length > 0 ? ( + (selectedClient.sessions || []).map((session) => ( +
+

+ {session.title} +

+

+ {session.ai_summary} +

+
+ )) + ) : ( + + )} +
+
+ + +
+

+ Open commitments +

+
+
+ {(selectedClient.action_items || []).length > 0 ? ( + (selectedClient.action_items || []).map((item) => ( +
+ + + +
+

+ {item.title} +

+

+ {item.status.replace('_', ' ')} +

+
+
+ )) + ) : ( + + )} +
+
- - )} + )} +
- ) -} + ); +}; Clients.getLayout = function getLayout(page: ReactElement) { - return {page} -} + return {page}; +}; -export default Clients +export default Clients; diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index 41a504b..cf9c965 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -1,15 +1,20 @@ -import * as icon from '@mdi/js'; -import Head from 'next/head' -import React from 'react' +import { + mdiAccountGroup, + mdiArrowRight, + mdiCheckCircleOutline, + mdiClockOutline, + mdiFileDocumentEditOutline, + mdiViewDashboardOutline, +} from '@mdi/js'; import axios from 'axios'; -import type { ReactElement } from 'react' +import Head from 'next/head'; import Link from 'next/link'; -import LayoutAuthenticated from '../layouts/Authenticated' -import SectionMain from '../components/SectionMain' -import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton' -import CardBox from '../components/CardBox' -import BaseIcon from '../components/BaseIcon' -import { getPageTitle } from '../config' +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 Client = { id: string; @@ -54,6 +59,22 @@ const emptySummary: Summary = { nextSessions: [], }; +function ShellCard({ + children, + className = '', +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + const Dashboard = () => { const [summary, setSummary] = React.useState(emptySummary); const [loading, setLoading] = React.useState(true); @@ -68,12 +89,32 @@ const Dashboard = () => { loadSummary(); }, []); + const nextSession = summary.nextSessions[0]; const stats = [ - ['Clients', summary.counts.clients, icon.mdiAccountGroup, '/clients'], - ['Sessions', summary.counts.sessions, icon.mdiTable, '/session-memory'], - ['Open actions', summary.counts.actionItems, icon.mdiTable, '/clients'], - ['Shared resources', summary.counts.resources, icon.mdiTable, '/client-portal'], - ['Prep briefs', summary.counts.prepBriefs, icon.mdiTable, '/session-memory'], + { + href: '/clients', + icon: mdiAccountGroup, + label: 'Active clients', + value: summary.counts.clients, + }, + { + href: '/session-memory', + icon: mdiFileDocumentEditOutline, + label: 'Session memories', + value: summary.counts.sessions, + }, + { + href: '/clients', + icon: mdiCheckCircleOutline, + label: 'Open commitments', + value: summary.counts.actionItems, + }, + { + href: '/session-memory', + icon: mdiClockOutline, + label: 'Prep briefs', + value: summary.counts.prepBriefs, + }, ]; return ( @@ -82,70 +123,182 @@ const Dashboard = () => { {getPageTitle('Dashboard')} - - {''} - - -
- {stats.map(([label, value, iconPath, href]) => ( - - -
-
-

{label}

-

{loading ? '...' : value}

-
- -
-
- - ))} -
- -
- -
-

Active Clients

- View all -
-
- {summary.activeClients.map((client) => ( - -
-
-

{client.name}

-

{client.role_title} · {client.company}

-
-

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

-
+
+
+
+
+ + + Coach command center + +
+

+ Keep every client relationship moving between sessions. +

+

+ Review the next conversation, follow up on commitments, and keep + session memory close to the work that needs attention today. +

+
+ + Generate session memory - ))} + + Open client records + +
- - -
-

Recent Session Memory

- Open memory -
-
- {summary.nextSessions.map((session) => ( -
-

{session.title}

-

{session.client?.name}

-

{session.ai_summary}

+ +

+ Next session prep +

+ {nextSession ? ( +
+

+ {nextSession.client?.name || 'Client session'} +

+

+ {nextSession.title} +

+

+ {nextSession.ai_summary} +

+ + Review memory + +
- ))} -
- + ) : ( +

+ {loading + ? 'Loading your coaching workspace...' + : 'No upcoming session memory yet.'} +

+ )} + +
+ +
+ {stats.map((stat) => ( + + +
+
+

+ {stat.label} +

+

+ {loading ? '...' : stat.value} +

+
+ + + +
+
+ + ))} +
+ +
+ +
+
+
+

+ Relationship memory +

+

+ Active clients +

+
+ + View all + +
+
+
+ {summary.activeClients.map((client) => ( + +
+
+

+ {client.name} +

+

+ {client.role_title} · {client.company} +

+
+ + {client.package?.title || 'Coaching'} + +
+ + ))} +
+
+ + +
+
+
+

+ Recent intelligence +

+

+ Session memory +

+
+ + Open + +
+
+
+ {summary.nextSessions.map((session) => ( +
+

+ {session.title} +

+

+ {session.client?.name} +

+

+ {session.ai_summary} +

+
+ ))} +
+
+
- ) -} + ); +}; Dashboard.getLayout = function getLayout(page: ReactElement) { - return {page} -} + return {page}; +}; -export default Dashboard +export default Dashboard; diff --git a/frontend/src/pages/session-memory.tsx b/frontend/src/pages/session-memory.tsx index 8bcc2b8..14e9c3d 100644 --- a/frontend/src/pages/session-memory.tsx +++ b/frontend/src/pages/session-memory.tsx @@ -1,14 +1,19 @@ -import * as icon from '@mdi/js'; -import Head from 'next/head' -import React from 'react' +import { + mdiCheckCircleOutline, + mdiContentCopy, + mdiFileDocumentEditOutline, + mdiLightbulbOnOutline, + mdiSendOutline, +} from '@mdi/js'; import axios from 'axios'; -import type { ReactElement } from 'react' -import LayoutAuthenticated from '../layouts/Authenticated' -import SectionMain from '../components/SectionMain' -import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton' -import CardBox from '../components/CardBox' -import BaseButton from '../components/BaseButton' -import { getPageTitle } from '../config' +import Head from 'next/head'; +import React from 'react'; +import type { ReactElement } from 'react'; +import BaseButton from '../components/BaseButton'; +import BaseIcon from '../components/BaseIcon'; +import SectionMain from '../components/SectionMain'; +import { getPageTitle } from '../config'; +import LayoutAuthenticated from '../layouts/Authenticated'; type Client = { id: string; @@ -25,12 +30,47 @@ type Session = { client?: Client; }; +function Panel({ + children, + className = '', +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +function OutputBlock({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + const SessionMemory = () => { const [clients, setClients] = React.useState([]); const [sessions, setSessions] = React.useState([]); const [clientId, setClientId] = React.useState(''); const [transcript, setTranscript] = React.useState(''); - const [generatedMemory, setGeneratedMemory] = React.useState(null); + const [generatedMemory, setGeneratedMemory] = React.useState( + null, + ); const [isGenerating, setIsGenerating] = React.useState(false); async function loadData() { @@ -40,6 +80,7 @@ const SessionMemory = () => { ]); setClients(clientsResponse.data); setSessions(sessionsResponse.data); + if (!clientId && clientsResponse.data.length > 0) { setClientId(clientsResponse.data[0].id); } @@ -65,85 +106,175 @@ const SessionMemory = () => { {getPageTitle('Session Memory')} - - {''} - - -
- -

Extract a Session

- - - -