Redesign coaching workspace screens
This commit is contained in:
parent
275a110243
commit
ddfad05706
@ -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 (
|
||||
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
|
||||
<div
|
||||
className={`${layoutAsidePadding} ${
|
||||
isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''
|
||||
} pt-14 min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
|
||||
<div className='min-h-screen bg-[#f7f5f1] text-[#17201b]'>
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-40 w-72 border-r border-[#ded8cc] bg-[#fbfaf6] px-4 py-5 shadow-[16px_0_45px_rgba(23,32,27,0.08)] transition-transform duration-300 lg:translate-x-0 ${
|
||||
isAsideOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<NavBar
|
||||
menu={menuNavBar}
|
||||
className={`${layoutAsidePadding} ${isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''}`}
|
||||
>
|
||||
<NavBarItemPlain
|
||||
display="flex lg:hidden"
|
||||
onClick={() => setIsAsideMobileExpanded(!isAsideMobileExpanded)}
|
||||
<div className='flex items-center justify-between'>
|
||||
<Link href='/dashboard' className='flex items-center gap-3'>
|
||||
<span className='grid h-10 w-10 place-items-center rounded-full border border-[#d8b15e]/50 bg-[#245c4c] text-sm font-black text-white'>
|
||||
CW
|
||||
</span>
|
||||
<span>
|
||||
<span className='block text-sm font-semibold uppercase tracking-[0.22em] text-[#9a6a2f]'>
|
||||
AppWizzy
|
||||
</span>
|
||||
<span className='block text-xl font-semibold'>
|
||||
Coaching Workspace
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
<button
|
||||
type='button'
|
||||
className='grid h-10 w-10 place-items-center rounded-full border border-[#ded8cc] text-[#526159] lg:hidden'
|
||||
onClick={() => setIsAsideOpen(false)}
|
||||
>
|
||||
<BaseIcon path={isAsideMobileExpanded ? mdiBackburger : mdiForwardburger} size="24" />
|
||||
</NavBarItemPlain>
|
||||
<NavBarItemPlain
|
||||
display="hidden lg:flex xl:hidden"
|
||||
onClick={() => setIsAsideLgActive(true)}
|
||||
<BaseIcon path={mdiClose} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 rounded-lg border border-[#ded8cc] bg-white p-4'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-[#9a6a2f]'>
|
||||
Today
|
||||
</p>
|
||||
<p className='mt-2 text-sm leading-6 text-[#5f6b64]'>
|
||||
Prepare, follow up, and keep every coaching relationship warm
|
||||
between sessions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav className='mt-6 space-y-1'>
|
||||
{visibleNavItems.map((item) => {
|
||||
const itemPath = item.href.split('/')[1];
|
||||
const isActive = itemPath === activePath;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold transition ${
|
||||
isActive
|
||||
? 'bg-[#245c4c] text-white shadow-[0_14px_28px_rgba(36,92,76,0.2)]'
|
||||
: 'text-[#56645d] hover:bg-[#efeae0] hover:text-[#17201b]'
|
||||
}`}
|
||||
>
|
||||
<BaseIcon path={item.icon} size={20} />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className='absolute inset-x-4 bottom-5 rounded-lg border border-[#ded8cc] bg-[#f0ede6] p-4'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-[#9a6a2f]'>
|
||||
Signed in
|
||||
</p>
|
||||
<p className='mt-2 truncate text-sm font-semibold'>
|
||||
{currentUser?.email || 'Workspace user'}
|
||||
</p>
|
||||
<button
|
||||
type='button'
|
||||
className='mt-4 flex w-full items-center justify-center gap-2 rounded-full bg-[#17201b] px-4 py-2 text-sm font-semibold text-white'
|
||||
onClick={() => {
|
||||
dispatch(logoutUser());
|
||||
router.push('/login');
|
||||
}}
|
||||
>
|
||||
<BaseIcon path={mdiMenu} size="24" />
|
||||
</NavBarItemPlain>
|
||||
<NavBarItemPlain useMargin>
|
||||
<Search />
|
||||
</NavBarItemPlain>
|
||||
</NavBar>
|
||||
<AsideMenu
|
||||
isAsideMobileExpanded={isAsideMobileExpanded}
|
||||
isAsideLgActive={isAsideLgActive}
|
||||
menu={menuAside}
|
||||
onAsideLgClose={() => setIsAsideLgActive(false)}
|
||||
<BaseIcon path={mdiLogout} size={18} />
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{isAsideOpen && (
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Close menu'
|
||||
className='fixed inset-0 z-30 bg-[#17201b]/40 lg:hidden'
|
||||
onClick={() => setIsAsideOpen(false)}
|
||||
/>
|
||||
{children}
|
||||
<FooterBar>Coaching SaaS Workspace</FooterBar>
|
||||
)}
|
||||
|
||||
<div className='lg:pl-72'>
|
||||
<header className='sticky top-0 z-20 border-b border-[#ded8cc]/80 bg-[#f7f5f1]/92 px-4 py-3 backdrop-blur lg:px-8'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<button
|
||||
type='button'
|
||||
className='grid h-10 w-10 place-items-center rounded-full border border-[#ded8cc] bg-white text-[#526159] lg:hidden'
|
||||
onClick={() => setIsAsideOpen(true)}
|
||||
>
|
||||
<BaseIcon path={mdiMenu} size={22} />
|
||||
</button>
|
||||
<div className='hidden lg:block'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#9a6a2f]'>
|
||||
Never lose the thread
|
||||
</p>
|
||||
<p className='mt-1 text-sm text-[#647068]'>
|
||||
Coach admin, session memory, and client portal in one workspace.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href='/'
|
||||
className='rounded-full border border-[#ded8cc] bg-white px-4 py-2 text-sm font-semibold text-[#17201b] shadow-sm'
|
||||
>
|
||||
Public site
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<section
|
||||
className={`rounded-lg border border-[#ded8cc] bg-white shadow-[0_18px_60px_rgba(23,32,27,0.07)] ${className}`}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const ClientPortal = () => {
|
||||
const [clients, setClients] = React.useState<Array<{ id: string; name: string }>>([]);
|
||||
const [clients, setClients] = React.useState<
|
||||
Array<{ id: string; name: string }>
|
||||
>([]);
|
||||
const [clientId, setClientId] = React.useState('');
|
||||
const [portalClient, setPortalClient] = React.useState<PortalClient | null>(null);
|
||||
const [portalClient, setPortalClient] = React.useState<PortalClient | null>(
|
||||
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 = () => {
|
||||
<title>{getPageTitle('Client Portal')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={icon.mdiAccountCircle} title="Client Portal Preview" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<CardBox className="mb-6">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-600">Preview as client</label>
|
||||
<select
|
||||
value={clientId}
|
||||
onChange={(event) => setClientId(event.target.value)}
|
||||
className="w-full rounded border border-gray-300 px-3 py-2 md:w-96"
|
||||
>
|
||||
{clients.map((client) => (
|
||||
<option key={client.id} value={client.id}>{client.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</CardBox>
|
||||
|
||||
{portalClient && (
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.05fr_0.95fr]">
|
||||
<CardBox>
|
||||
<p className="text-sm font-medium uppercase tracking-wide text-emerald-700">Your coaching workspace</p>
|
||||
<h2 className="mt-1 text-2xl font-semibold">{portalClient.name}</h2>
|
||||
<p className="mt-4 leading-7 text-gray-600">{portalClient.goals}</p>
|
||||
|
||||
<h3 className="mt-6 mb-3 font-semibold">Shared session notes</h3>
|
||||
<div className="space-y-3">
|
||||
{(portalClient.sessions || []).map((session) => (
|
||||
<div key={session.id} className="rounded border border-gray-200 p-4">
|
||||
<p className="font-medium">{session.title}</p>
|
||||
<p className="mt-2 leading-6 text-gray-600">{session.shared_client_notes}</p>
|
||||
</div>
|
||||
))}
|
||||
<div className='mx-auto max-w-7xl'>
|
||||
<div className='mb-6 grid gap-6 xl:grid-cols-[0.95fr_1.05fr]'>
|
||||
<div className='rounded-lg bg-[#17201b] p-7 text-white'>
|
||||
<div className='flex items-center gap-3 text-[#d8b15e]'>
|
||||
<BaseIcon path={mdiAccountCircle} size={22} />
|
||||
<span className='text-xs font-semibold uppercase tracking-[0.22em]'>
|
||||
Client portal
|
||||
</span>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<div className="space-y-6">
|
||||
<CardBox>
|
||||
<h3 className="mb-3 font-semibold">Commitments</h3>
|
||||
<div className="space-y-3">
|
||||
{(portalClient.action_items || []).map((item) => (
|
||||
<div key={item.id} className="rounded border border-gray-200 p-3">
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="mt-1 text-sm text-gray-500">{item.status.replace('_', ' ')}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox>
|
||||
<h3 className="mb-3 font-semibold">Resources</h3>
|
||||
<div className="space-y-3">
|
||||
{(portalClient.resources || []).map((resource) => (
|
||||
<a key={resource.id} href={resource.url} className="block rounded border border-gray-200 p-3 hover:bg-gray-50">
|
||||
<p className="font-medium">{resource.title}</p>
|
||||
<p className="mt-1 text-sm leading-6 text-gray-500">{resource.description}</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</CardBox>
|
||||
<h1 className='mt-4 text-4xl font-semibold'>
|
||||
A private space for the client.
|
||||
</h1>
|
||||
<p className='mt-3 max-w-2xl leading-7 text-[#d9d5ca]'>
|
||||
This preview shows the simpler coachee experience: shared notes,
|
||||
commitments, resources, and a reflection prompt before the next
|
||||
session.
|
||||
</p>
|
||||
</div>
|
||||
<Panel className='p-6'>
|
||||
<label className='block text-xs font-semibold uppercase tracking-[0.22em] text-[#9a6a2f]'>
|
||||
Preview as client
|
||||
</label>
|
||||
<select
|
||||
value={clientId}
|
||||
onChange={(event) => setClientId(event.target.value)}
|
||||
className='mt-4 w-full rounded-lg border border-[#d8d1c2] bg-white px-4 py-3 text-[#17201b] outline-none focus:border-[#245c4c] focus:ring-2 focus:ring-[#245c4c]/15'
|
||||
>
|
||||
{clients.map((client) => (
|
||||
<option key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className='mt-4 text-sm leading-6 text-[#68736c]'>
|
||||
MVP note: this is still a coach-visible preview. Final client
|
||||
access should be a client role or magic-link route.
|
||||
</p>
|
||||
</Panel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{portalClient && (
|
||||
<div className='grid gap-6 xl:grid-cols-[1.05fr_0.95fr]'>
|
||||
<Panel className='overflow-hidden'>
|
||||
<div className='bg-[#fbfaf6] p-7'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#9a6a2f]'>
|
||||
Your coaching workspace
|
||||
</p>
|
||||
<h2 className='mt-2 text-3xl font-semibold text-[#17201b]'>
|
||||
{portalClient.name}
|
||||
</h2>
|
||||
<p className='mt-4 max-w-2xl leading-7 text-[#4f5a53]'>
|
||||
{portalClient.goals}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='p-7'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='grid h-10 w-10 place-items-center rounded-full bg-[#e8f1ec] text-[#245c4c]'>
|
||||
<BaseIcon path={mdiMessageReplyTextOutline} size={21} />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className='text-xl font-semibold text-[#17201b]'>
|
||||
Shared session notes
|
||||
</h3>
|
||||
<p className='text-sm text-[#68736c]'>
|
||||
Only coach-approved notes appear here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-5 space-y-4'>
|
||||
{(portalClient.sessions || []).map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className='rounded-lg border border-[#ded8cc] bg-white p-5'
|
||||
>
|
||||
<p className='font-semibold text-[#17201b]'>
|
||||
{session.title}
|
||||
</p>
|
||||
<p className='mt-3 leading-7 text-[#4f5a53]'>
|
||||
{session.shared_client_notes}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<div className='space-y-6'>
|
||||
<Panel>
|
||||
<div className='border-b border-[#ded8cc] p-5'>
|
||||
<h3 className='text-xl font-semibold text-[#17201b]'>
|
||||
Commitments
|
||||
</h3>
|
||||
</div>
|
||||
<div className='space-y-3 p-5'>
|
||||
{(portalClient.action_items || []).map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className='flex gap-3 rounded-lg border border-[#ded8cc] bg-[#fbfaf6] p-4'
|
||||
>
|
||||
<span className='mt-0.5 grid h-8 w-8 flex-none place-items-center rounded-full bg-[#245c4c] text-white'>
|
||||
<BaseIcon path={mdiCheckCircleOutline} size={18} />
|
||||
</span>
|
||||
<div>
|
||||
<p className='font-semibold text-[#17201b]'>
|
||||
{item.title}
|
||||
</p>
|
||||
<p className='mt-1 text-sm text-[#68736c]'>
|
||||
{item.status.replace('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel>
|
||||
<div className='border-b border-[#ded8cc] p-5'>
|
||||
<h3 className='text-xl font-semibold text-[#17201b]'>
|
||||
Resources
|
||||
</h3>
|
||||
</div>
|
||||
<div className='space-y-3 p-5'>
|
||||
{(portalClient.resources || []).map((resource) => (
|
||||
<a
|
||||
key={resource.id}
|
||||
href={resource.url}
|
||||
className='flex items-center justify-between gap-4 rounded-lg border border-[#ded8cc] bg-white p-4 transition hover:bg-[#fbfaf6]'
|
||||
>
|
||||
<div className='flex gap-3'>
|
||||
<span className='grid h-10 w-10 place-items-center rounded-full bg-[#f0ede6] text-[#9a6a2f]'>
|
||||
<BaseIcon path={mdiBookOpenVariant} size={20} />
|
||||
</span>
|
||||
<div>
|
||||
<p className='font-semibold text-[#17201b]'>
|
||||
{resource.title}
|
||||
</p>
|
||||
<p className='mt-1 text-sm leading-6 text-[#68736c]'>
|
||||
{resource.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<BaseIcon
|
||||
path={mdiChevronRight}
|
||||
size={20}
|
||||
className='text-[#245c4c]'
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel className='bg-[#e8f1ec] p-6'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#245c4c]'>
|
||||
Pre-session reflection
|
||||
</p>
|
||||
<h3 className='mt-2 text-2xl font-semibold text-[#17201b]'>
|
||||
What changed since last time?
|
||||
</h3>
|
||||
<p className='mt-3 leading-7 text-[#4f5a53]'>
|
||||
Final MVP should let the client answer this before a
|
||||
session, then feed the response into the coach prep brief.
|
||||
</p>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
ClientPortal.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
|
||||
}
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default ClientPortal
|
||||
export default ClientPortal;
|
||||
|
||||
@ -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 (
|
||||
<section
|
||||
className={`rounded-lg border border-[#ded8cc] bg-white shadow-[0_18px_60px_rgba(23,32,27,0.07)] ${className}`}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ label }: { label: string }) {
|
||||
return (
|
||||
<p className='rounded-lg border border-dashed border-[#d8d1c2] bg-[#fbfaf6] p-4 text-sm text-[#68736c]'>
|
||||
{label}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const Clients = () => {
|
||||
const router = useRouter();
|
||||
const [clients, setClients] = React.useState<Client[]>([]);
|
||||
const [selectedClientId, setSelectedClientId] = React.useState<string>('');
|
||||
|
||||
@ -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 = () => {
|
||||
<title>{getPageTitle('Clients')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={icon.mdiAccountGroup} title="Clients" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[0.85fr_1.15fr]">
|
||||
<CardBox>
|
||||
<div className="space-y-3">
|
||||
{clients.map((client) => (
|
||||
<button
|
||||
key={client.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedClientId(client.id)}
|
||||
className={`w-full rounded border p-4 text-left ${selectedClient?.id === client.id ? 'border-emerald-500 bg-emerald-50' : 'border-gray-200 bg-white'}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-semibold">{client.name}</p>
|
||||
<p className="text-sm text-gray-500">{client.role_title} · {client.company}</p>
|
||||
</div>
|
||||
<span className="rounded bg-gray-100 px-2 py-1 text-xs font-medium text-gray-600">{client.status}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
<div className='mx-auto max-w-7xl'>
|
||||
<div className='mb-6 flex flex-col justify-between gap-4 rounded-lg bg-[#17201b] p-7 text-white md:flex-row md:items-end'>
|
||||
<div>
|
||||
<div className='flex items-center gap-3 text-[#d8b15e]'>
|
||||
<BaseIcon path={mdiAccountGroup} size={22} />
|
||||
<span className='text-xs font-semibold uppercase tracking-[0.22em]'>
|
||||
Client CRM
|
||||
</span>
|
||||
</div>
|
||||
<h1 className='mt-4 text-4xl font-semibold'>
|
||||
Relationship memory hub
|
||||
</h1>
|
||||
<p className='mt-3 max-w-2xl text-[#d9d5ca]'>
|
||||
Open any client and see goals, coaching notes, recent sessions,
|
||||
and commitments without digging through scattered docs.
|
||||
</p>
|
||||
</div>
|
||||
</CardBox>
|
||||
<div className='rounded-full bg-white/10 px-4 py-2 text-sm font-semibold text-[#f5ead5]'>
|
||||
{clients.length} clients
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedClient && (
|
||||
<CardBox>
|
||||
<div className="mb-5 border-b border-gray-200 pb-5">
|
||||
<p className="text-sm font-medium uppercase tracking-wide text-emerald-700">{selectedClient.package?.title || 'Coaching client'}</p>
|
||||
<h2 className="mt-1 text-2xl font-semibold">{selectedClient.name}</h2>
|
||||
<p className="mt-2 text-gray-500">{selectedClient.email}</p>
|
||||
<div className='grid gap-6 xl:grid-cols-[0.85fr_1.15fr]'>
|
||||
<Panel className='overflow-hidden'>
|
||||
<div className='border-b border-[#ded8cc] p-5'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#9a6a2f]'>
|
||||
Coaching relationships
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="font-semibold">Goals</h3>
|
||||
<p className="mt-2 leading-7 text-gray-600">{selectedClient.goals}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">Coach Notes</h3>
|
||||
<p className="mt-2 leading-7 text-gray-600">{selectedClient.notes}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="mb-3 font-semibold">Recent Sessions</h3>
|
||||
<div className="space-y-3">
|
||||
{(selectedClient.sessions || []).map((session) => (
|
||||
<div key={session.id} className="rounded border border-gray-200 p-3">
|
||||
<p className="font-medium">{session.title}</p>
|
||||
<p className="mt-2 text-sm leading-6 text-gray-500">{session.ai_summary}</p>
|
||||
<div className='divide-y divide-[#eee8dc]'>
|
||||
{clients.map((client) => (
|
||||
<button
|
||||
key={client.id}
|
||||
type='button'
|
||||
onClick={() => selectClient(client.id)}
|
||||
className={`block w-full p-5 text-left transition ${
|
||||
selectedClient?.id === client.id
|
||||
? 'bg-[#e8f1ec]'
|
||||
: 'bg-white hover:bg-[#fbfaf6]'
|
||||
}`}
|
||||
>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div>
|
||||
<p className='font-semibold text-[#17201b]'>
|
||||
{client.name}
|
||||
</p>
|
||||
<p className='mt-1 text-sm text-[#68736c]'>
|
||||
{client.role_title} · {client.company}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-3 font-semibold">Action Items</h3>
|
||||
<div className="space-y-3">
|
||||
{(selectedClient.action_items || []).map((item) => (
|
||||
<div key={item.id} className="rounded border border-gray-200 p-3">
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="mt-1 text-sm text-gray-500">{item.status.replace('_', ' ')}</p>
|
||||
<span className='rounded-full bg-[#f0ede6] px-3 py-1 text-xs font-semibold text-[#6e5730]'>
|
||||
{client.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className='mt-4 flex flex-wrap gap-2'>
|
||||
{(client.tags || '')
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3)
|
||||
.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className='rounded-full bg-white px-3 py-1 text-xs text-[#68736c]'
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{selectedClient && (
|
||||
<div className='space-y-6'>
|
||||
<Panel className='p-6'>
|
||||
<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-[#9a6a2f]'>
|
||||
{selectedClient.package?.title || 'Coaching client'}
|
||||
</p>
|
||||
<h2 className='mt-2 text-3xl font-semibold text-[#17201b]'>
|
||||
{selectedClient.name}
|
||||
</h2>
|
||||
<p className='mt-2 text-[#68736c]'>
|
||||
{selectedClient.email}
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-lg border border-[#ded8cc] bg-[#fbfaf6] p-4 md:min-w-56'>
|
||||
<div className='flex items-center gap-2 text-sm font-semibold text-[#245c4c]'>
|
||||
<BaseIcon path={mdiCalendarClock} size={18} />
|
||||
Next session
|
||||
</div>
|
||||
))}
|
||||
<p className='mt-2 text-sm text-[#68736c]'>
|
||||
{selectedClient.next_session_at ||
|
||||
'No session scheduled'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 grid gap-4 lg:grid-cols-2'>
|
||||
<div className='rounded-lg bg-[#f7f5f1] p-5'>
|
||||
<div className='flex items-center gap-2 text-sm font-semibold text-[#245c4c]'>
|
||||
<BaseIcon path={mdiTarget} size={18} />
|
||||
Goals
|
||||
</div>
|
||||
<p className='mt-3 leading-7 text-[#4f5a53]'>
|
||||
{selectedClient.goals}
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-lg bg-[#f7f5f1] p-5'>
|
||||
<div className='flex items-center gap-2 text-sm font-semibold text-[#245c4c]'>
|
||||
<BaseIcon path={mdiFileDocumentOutline} size={18} />
|
||||
Private coach notes
|
||||
</div>
|
||||
<p className='mt-3 leading-7 text-[#4f5a53]'>
|
||||
{selectedClient.notes}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<div className='grid gap-6 lg:grid-cols-2'>
|
||||
<Panel>
|
||||
<div className='border-b border-[#ded8cc] p-5'>
|
||||
<h3 className='text-xl font-semibold text-[#17201b]'>
|
||||
Session timeline
|
||||
</h3>
|
||||
</div>
|
||||
<div className='space-y-4 p-5'>
|
||||
{(selectedClient.sessions || []).length > 0 ? (
|
||||
(selectedClient.sessions || []).map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className='rounded-lg border border-[#ded8cc] bg-[#fbfaf6] p-4'
|
||||
>
|
||||
<p className='font-semibold text-[#17201b]'>
|
||||
{session.title}
|
||||
</p>
|
||||
<p className='mt-2 text-sm leading-6 text-[#68736c]'>
|
||||
{session.ai_summary}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyState label='No saved session memories yet.' />
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel>
|
||||
<div className='border-b border-[#ded8cc] p-5'>
|
||||
<h3 className='text-xl font-semibold text-[#17201b]'>
|
||||
Open commitments
|
||||
</h3>
|
||||
</div>
|
||||
<div className='space-y-4 p-5'>
|
||||
{(selectedClient.action_items || []).length > 0 ? (
|
||||
(selectedClient.action_items || []).map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className='flex gap-3 rounded-lg border border-[#ded8cc] bg-white p-4'
|
||||
>
|
||||
<span className='mt-0.5 grid h-8 w-8 flex-none place-items-center rounded-full bg-[#e8f1ec] text-[#245c4c]'>
|
||||
<BaseIcon
|
||||
path={mdiCheckCircleOutline}
|
||||
size={18}
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<p className='font-semibold text-[#17201b]'>
|
||||
{item.title}
|
||||
</p>
|
||||
<p className='mt-1 text-sm text-[#68736c]'>
|
||||
{item.status.replace('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyState label='No open commitments for this client.' />
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
Clients.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
|
||||
}
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default Clients
|
||||
export default Clients;
|
||||
|
||||
@ -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 (
|
||||
<section
|
||||
className={`rounded-lg border border-[#ded8cc] bg-white shadow-[0_18px_60px_rgba(23,32,27,0.07)] ${className}`}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const Dashboard = () => {
|
||||
const [summary, setSummary] = React.useState<Summary>(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 = () => {
|
||||
<title>{getPageTitle('Dashboard')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={icon.mdiViewDashboardOutline} title="Coaching Dashboard" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
{stats.map(([label, value, iconPath, href]) => (
|
||||
<Link key={String(label)} href={String(href)}>
|
||||
<CardBox className="h-full">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{label}</p>
|
||||
<p className="mt-2 text-3xl font-semibold">{loading ? '...' : value}</p>
|
||||
</div>
|
||||
<BaseIcon path={String(iconPath)} size={32} className="text-emerald-600" />
|
||||
</div>
|
||||
</CardBox>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
||||
<CardBox>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Active Clients</h2>
|
||||
<Link className="text-sm font-medium text-emerald-700" href="/clients">View all</Link>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{summary.activeClients.map((client) => (
|
||||
<Link key={client.id} href={`/clients?clientId=${client.id}`} className="block rounded border border-gray-200 p-4 hover:bg-gray-50">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-semibold">{client.name}</p>
|
||||
<p className="text-sm text-gray-500">{client.role_title} · {client.company}</p>
|
||||
</div>
|
||||
<p className="text-right text-sm text-gray-500">{client.package?.title || 'Coaching package'}</p>
|
||||
</div>
|
||||
<div className='mx-auto max-w-7xl'>
|
||||
<div className='grid gap-6 xl:grid-cols-[1.1fr_0.9fr]'>
|
||||
<div className='rounded-lg bg-[#17201b] p-8 text-white shadow-[0_24px_80px_rgba(23,32,27,0.18)]'>
|
||||
<div className='flex items-center gap-3 text-[#d8b15e]'>
|
||||
<BaseIcon path={mdiViewDashboardOutline} size={22} />
|
||||
<span className='text-xs font-semibold uppercase tracking-[0.22em]'>
|
||||
Coach command center
|
||||
</span>
|
||||
</div>
|
||||
<h1 className='mt-6 max-w-2xl text-4xl font-semibold leading-tight md:text-5xl'>
|
||||
Keep every client relationship moving between sessions.
|
||||
</h1>
|
||||
<p className='mt-5 max-w-2xl text-lg leading-8 text-[#d9d5ca]'>
|
||||
Review the next conversation, follow up on commitments, and keep
|
||||
session memory close to the work that needs attention today.
|
||||
</p>
|
||||
<div className='mt-8 flex flex-wrap gap-3'>
|
||||
<Link
|
||||
href='/session-memory'
|
||||
className='rounded-full bg-[#d8b15e] px-5 py-3 text-sm font-semibold text-[#17201b]'
|
||||
>
|
||||
Generate session memory
|
||||
</Link>
|
||||
))}
|
||||
<Link
|
||||
href='/clients'
|
||||
className='rounded-full border border-white/25 px-5 py-3 text-sm font-semibold text-white'
|
||||
>
|
||||
Open client records
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Recent Session Memory</h2>
|
||||
<Link className="text-sm font-medium text-emerald-700" href="/session-memory">Open memory</Link>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{summary.nextSessions.map((session) => (
|
||||
<div key={session.id} className="rounded border border-gray-200 p-4">
|
||||
<p className="font-semibold">{session.title}</p>
|
||||
<p className="mt-1 text-sm text-gray-500">{session.client?.name}</p>
|
||||
<p className="mt-3 leading-6 text-gray-600">{session.ai_summary}</p>
|
||||
<ShellCard className='p-6'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#9a6a2f]'>
|
||||
Next session prep
|
||||
</p>
|
||||
{nextSession ? (
|
||||
<div>
|
||||
<h2 className='mt-4 text-2xl font-semibold text-[#17201b]'>
|
||||
{nextSession.client?.name || 'Client session'}
|
||||
</h2>
|
||||
<p className='mt-1 text-sm text-[#68736c]'>
|
||||
{nextSession.title}
|
||||
</p>
|
||||
<p className='mt-5 leading-7 text-[#4f5a53]'>
|
||||
{nextSession.ai_summary}
|
||||
</p>
|
||||
<Link
|
||||
href='/session-memory'
|
||||
className='mt-6 inline-flex items-center gap-2 rounded-full bg-[#245c4c] px-4 py-2 text-sm font-semibold text-white'
|
||||
>
|
||||
Review memory
|
||||
<BaseIcon path={mdiArrowRight} size={18} />
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardBox>
|
||||
) : (
|
||||
<p className='mt-4 leading-7 text-[#68736c]'>
|
||||
{loading
|
||||
? 'Loading your coaching workspace...'
|
||||
: 'No upcoming session memory yet.'}
|
||||
</p>
|
||||
)}
|
||||
</ShellCard>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
{stats.map((stat) => (
|
||||
<Link key={stat.label} href={stat.href}>
|
||||
<ShellCard className='h-full p-5 transition hover:-translate-y-0.5 hover:shadow-[0_22px_60px_rgba(23,32,27,0.1)]'>
|
||||
<div className='flex items-start justify-between gap-4'>
|
||||
<div>
|
||||
<p className='text-sm font-semibold text-[#68736c]'>
|
||||
{stat.label}
|
||||
</p>
|
||||
<p className='mt-3 text-4xl font-semibold text-[#17201b]'>
|
||||
{loading ? '...' : stat.value}
|
||||
</p>
|
||||
</div>
|
||||
<span className='grid h-11 w-11 place-items-center rounded-full bg-[#e8f1ec] text-[#245c4c]'>
|
||||
<BaseIcon path={stat.icon} size={23} />
|
||||
</span>
|
||||
</div>
|
||||
</ShellCard>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='mt-6 grid gap-6 xl:grid-cols-[0.95fr_1.05fr]'>
|
||||
<ShellCard>
|
||||
<div className='border-b border-[#ded8cc] p-6'>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#9a6a2f]'>
|
||||
Relationship memory
|
||||
</p>
|
||||
<h2 className='mt-2 text-2xl font-semibold'>
|
||||
Active clients
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
className='text-sm font-semibold text-[#245c4c]'
|
||||
href='/clients'
|
||||
>
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className='divide-y divide-[#eee8dc]'>
|
||||
{summary.activeClients.map((client) => (
|
||||
<Link
|
||||
key={client.id}
|
||||
href={`/clients?clientId=${client.id}`}
|
||||
className='block p-5 transition hover:bg-[#fbfaf6]'
|
||||
>
|
||||
<div className='flex items-start justify-between gap-4'>
|
||||
<div>
|
||||
<p className='font-semibold text-[#17201b]'>
|
||||
{client.name}
|
||||
</p>
|
||||
<p className='mt-1 text-sm text-[#68736c]'>
|
||||
{client.role_title} · {client.company}
|
||||
</p>
|
||||
</div>
|
||||
<span className='rounded-full bg-[#f0ede6] px-3 py-1 text-xs font-semibold text-[#6e5730]'>
|
||||
{client.package?.title || 'Coaching'}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</ShellCard>
|
||||
|
||||
<ShellCard>
|
||||
<div className='border-b border-[#ded8cc] p-6'>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#9a6a2f]'>
|
||||
Recent intelligence
|
||||
</p>
|
||||
<h2 className='mt-2 text-2xl font-semibold'>
|
||||
Session memory
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
className='text-sm font-semibold text-[#245c4c]'
|
||||
href='/session-memory'
|
||||
>
|
||||
Open
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className='divide-y divide-[#eee8dc]'>
|
||||
{summary.nextSessions.map((session) => (
|
||||
<div key={session.id} className='p-5'>
|
||||
<p className='font-semibold text-[#17201b]'>
|
||||
{session.title}
|
||||
</p>
|
||||
<p className='mt-1 text-sm text-[#68736c]'>
|
||||
{session.client?.name}
|
||||
</p>
|
||||
<p className='mt-3 leading-7 text-[#4f5a53]'>
|
||||
{session.ai_summary}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ShellCard>
|
||||
</div>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
Dashboard.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
|
||||
}
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default Dashboard
|
||||
export default Dashboard;
|
||||
|
||||
@ -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 (
|
||||
<section
|
||||
className={`rounded-lg border border-[#ded8cc] bg-white shadow-[0_18px_60px_rgba(23,32,27,0.07)] ${className}`}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function OutputBlock({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className='rounded-lg border border-[#ded8cc] bg-[#fbfaf6] p-5'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-[#9a6a2f]'>
|
||||
{title}
|
||||
</p>
|
||||
<div className='mt-3 leading-7 text-[#4f5a53]'>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SessionMemory = () => {
|
||||
const [clients, setClients] = React.useState<Client[]>([]);
|
||||
const [sessions, setSessions] = React.useState<Session[]>([]);
|
||||
const [clientId, setClientId] = React.useState('');
|
||||
const [transcript, setTranscript] = React.useState('');
|
||||
const [generatedMemory, setGeneratedMemory] = React.useState<Session | null>(null);
|
||||
const [generatedMemory, setGeneratedMemory] = React.useState<Session | null>(
|
||||
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 = () => {
|
||||
<title>{getPageTitle('Session Memory')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={icon.mdiTable} title="Session Memory" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
||||
<CardBox>
|
||||
<h2 className="mb-4 text-xl font-semibold">Extract a Session</h2>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-600">Client</label>
|
||||
<select
|
||||
value={clientId}
|
||||
onChange={(event) => setClientId(event.target.value)}
|
||||
className="mb-4 w-full rounded border border-gray-300 px-3 py-2"
|
||||
>
|
||||
{clients.map((client) => (
|
||||
<option key={client.id} value={client.id}>{client.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-600">Transcript or raw notes</label>
|
||||
<textarea
|
||||
value={transcript}
|
||||
onChange={(event) => setTranscript(event.target.value)}
|
||||
className="min-h-[220px] w-full rounded border border-gray-300 px-3 py-2"
|
||||
placeholder="Paste session transcript, coach notes, or a rough debrief..."
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<BaseButton
|
||||
label={isGenerating ? 'Generating...' : 'Generate memory'}
|
||||
color="info"
|
||||
disabled={isGenerating || !clientId || !transcript.trim()}
|
||||
onClick={generateMemory}
|
||||
/>
|
||||
<div className='mx-auto max-w-7xl'>
|
||||
<div className='mb-6 rounded-lg bg-[#17201b] p-7 text-white'>
|
||||
<div className='flex items-center gap-3 text-[#d8b15e]'>
|
||||
<BaseIcon path={mdiFileDocumentEditOutline} size={22} />
|
||||
<span className='text-xs font-semibold uppercase tracking-[0.22em]'>
|
||||
Session Memory
|
||||
</span>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox>
|
||||
<h2 className="mb-4 text-xl font-semibold">AI Output</h2>
|
||||
{generatedMemory ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-500">Summary</p>
|
||||
<p className="mt-1 leading-7">{generatedMemory.ai_summary}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-500">Homework</p>
|
||||
<p className="mt-1 leading-7">{generatedMemory.homework}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-500">Follow-up Email</p>
|
||||
<p className="mt-1 whitespace-pre-line leading-7">{generatedMemory.follow_up_email}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="leading-7 text-gray-500">
|
||||
Paste notes from a coaching session and generate structured memory through the AppWizzy AI proxy.
|
||||
</p>
|
||||
)}
|
||||
</CardBox>
|
||||
</div>
|
||||
|
||||
<CardBox className="mt-6">
|
||||
<h2 className="mb-4 text-xl font-semibold">Recent Memories</h2>
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
{sessions.map((session) => (
|
||||
<div key={session.id} className="rounded border border-gray-200 p-4">
|
||||
<p className="font-semibold">{session.title}</p>
|
||||
<p className="mt-1 text-sm text-gray-500">{session.client?.name}</p>
|
||||
<p className="mt-3 leading-6 text-gray-600">{session.ai_summary}</p>
|
||||
<p className="mt-3 text-sm font-medium text-emerald-700">{session.key_topics}</p>
|
||||
</div>
|
||||
))}
|
||||
<h1 className='mt-4 max-w-3xl text-4xl font-semibold'>
|
||||
Turn rough notes into follow-up, commitments, and next-session
|
||||
prep.
|
||||
</h1>
|
||||
<p className='mt-3 max-w-3xl leading-7 text-[#d9d5ca]'>
|
||||
Paste the messy reality of a coaching session. The workspace turns
|
||||
it into a reviewable draft, so the coach stays in control before
|
||||
anything reaches a client.
|
||||
</p>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-[0.85fr_1.15fr]'>
|
||||
<Panel className='p-6'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#9a6a2f]'>
|
||||
Raw session input
|
||||
</p>
|
||||
<h2 className='mt-2 text-2xl font-semibold text-[#17201b]'>
|
||||
Extract a session
|
||||
</h2>
|
||||
|
||||
<label className='mb-2 mt-6 block text-sm font-semibold text-[#526159]'>
|
||||
Client
|
||||
</label>
|
||||
<select
|
||||
value={clientId}
|
||||
onChange={(event) => setClientId(event.target.value)}
|
||||
className='w-full rounded-lg border border-[#d8d1c2] bg-white px-4 py-3 text-[#17201b] outline-none focus:border-[#245c4c] focus:ring-2 focus:ring-[#245c4c]/15'
|
||||
>
|
||||
{clients.map((client) => (
|
||||
<option key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className='mb-2 mt-5 block text-sm font-semibold text-[#526159]'>
|
||||
Transcript or raw notes
|
||||
</label>
|
||||
<textarea
|
||||
value={transcript}
|
||||
onChange={(event) => setTranscript(event.target.value)}
|
||||
className='min-h-[320px] w-full rounded-lg border border-[#d8d1c2] bg-[#fbfaf6] px-4 py-3 leading-7 text-[#17201b] outline-none focus:border-[#245c4c] focus:ring-2 focus:ring-[#245c4c]/15'
|
||||
placeholder='Paste session transcript, coach notes, commitments, blockers, or a rough debrief...'
|
||||
/>
|
||||
<div className='mt-5'>
|
||||
<BaseButton
|
||||
label={isGenerating ? 'Generating...' : 'Generate memory'}
|
||||
color='info'
|
||||
disabled={isGenerating || !clientId || !transcript.trim()}
|
||||
onClick={generateMemory}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<div className='space-y-6'>
|
||||
<Panel className='p-6'>
|
||||
<div className='flex items-start justify-between gap-4'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-[#9a6a2f]'>
|
||||
AI draft
|
||||
</p>
|
||||
<h2 className='mt-2 text-2xl font-semibold text-[#17201b]'>
|
||||
Review before sharing
|
||||
</h2>
|
||||
</div>
|
||||
<span className='rounded-full bg-[#e8f1ec] px-3 py-1 text-xs font-semibold text-[#245c4c]'>
|
||||
Coach approved
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{generatedMemory ? (
|
||||
<div className='mt-6 grid gap-4'>
|
||||
<OutputBlock title='Summary'>
|
||||
{generatedMemory.ai_summary}
|
||||
</OutputBlock>
|
||||
<OutputBlock title='Commitments and homework'>
|
||||
{generatedMemory.homework}
|
||||
</OutputBlock>
|
||||
<OutputBlock title='Follow-up email'>
|
||||
<p className='whitespace-pre-line'>
|
||||
{generatedMemory.follow_up_email}
|
||||
</p>
|
||||
</OutputBlock>
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<button
|
||||
type='button'
|
||||
className='inline-flex items-center gap-2 rounded-full bg-[#245c4c] px-4 py-2 text-sm font-semibold text-white'
|
||||
>
|
||||
<BaseIcon path={mdiCheckCircleOutline} size={18} />
|
||||
Save final memory
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className='inline-flex items-center gap-2 rounded-full border border-[#ded8cc] bg-white px-4 py-2 text-sm font-semibold text-[#17201b]'
|
||||
>
|
||||
<BaseIcon path={mdiContentCopy} size={18} />
|
||||
Copy follow-up
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='mt-6 rounded-lg border border-dashed border-[#d8d1c2] bg-[#fbfaf6] p-6'>
|
||||
<BaseIcon
|
||||
path={mdiLightbulbOnOutline}
|
||||
size={28}
|
||||
className='text-[#d8b15e]'
|
||||
/>
|
||||
<p className='mt-4 leading-7 text-[#68736c]'>
|
||||
Generate a structured memory through the AppWizzy AI
|
||||
proxy. The result should stay reviewable: summary,
|
||||
commitments, blockers, homework, follow-up, and prep for
|
||||
the next conversation.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel>
|
||||
<div className='border-b border-[#ded8cc] p-5'>
|
||||
<h2 className='text-xl font-semibold text-[#17201b]'>
|
||||
Recent memories
|
||||
</h2>
|
||||
</div>
|
||||
<div className='divide-y divide-[#eee8dc]'>
|
||||
{sessions.map((session) => (
|
||||
<div key={session.id} className='p-5'>
|
||||
<div className='flex items-start justify-between gap-4'>
|
||||
<div>
|
||||
<p className='font-semibold text-[#17201b]'>
|
||||
{session.title}
|
||||
</p>
|
||||
<p className='mt-1 text-sm text-[#68736c]'>
|
||||
{session.client?.name}
|
||||
</p>
|
||||
</div>
|
||||
<BaseIcon
|
||||
path={mdiSendOutline}
|
||||
size={20}
|
||||
className='text-[#245c4c]'
|
||||
/>
|
||||
</div>
|
||||
<p className='mt-3 leading-7 text-[#4f5a53]'>
|
||||
{session.ai_summary}
|
||||
</p>
|
||||
<p className='mt-3 text-sm font-semibold text-[#245c4c]'>
|
||||
{session.key_topics}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
SessionMemory.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
|
||||
}
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default SessionMemory
|
||||
export default SessionMemory;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user