Build coaching SaaS workspace foundation

This commit is contained in:
Flatlogic Bot 2026-06-09 11:15:03 +00:00
parent 2fbcee2c80
commit 05ae5235ee
25 changed files with 1754 additions and 8992 deletions

View File

@ -39,7 +39,7 @@ const config = {
},
uploadDir: os.tmpdir(),
email: {
from: 'Sales Pipeline CRM <app@flatlogic.app>',
from: 'Coaching SaaS Workspace <app@flatlogic.app>',
host: 'email-smtp.us-east-1.amazonaws.com',
port: 587,
auth: {
@ -56,7 +56,7 @@ const config = {
user: 'Sales Ops',
user: 'Coach',
},
@ -69,7 +69,7 @@ const config = {
config.pexelsKey = process.env.PEXELS_KEY || '';
config.pexelsQuery = 'Climbers ascending a mountain ridge';
config.pexelsQuery = 'executive coaching workspace';
config.host = process.env.NODE_ENV === "production" ? config.remote : "http://localhost";
config.apiUrl = `${config.host}${config.port ? `:${config.port}` : ``}/api`;
config.swaggerUrl = `${config.swaggerUI}${config.swaggerPort}`;

View File

@ -403,29 +403,7 @@ module.exports = class UsersDBApi {
output.accounts_owner = await users.getAccounts_owner({
transaction
});
output.contacts_owner = await users.getContacts_owner({
transaction
});
output.leads_owner = await users.getLeads_owner({
transaction
});
output.deals_owner = await users.getDeals_owner({
transaction
});
output.activities_owner = await users.getActivities_owner({
output.clients_owner = await users.getClients_owner({
transaction
});
@ -951,4 +929,3 @@ module.exports = class UsersDBApi {
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
module.exports = function(sequelize, DataTypes) {
const action_items = sequelize.define(
"action_items",
{
id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
title: { type: DataTypes.TEXT },
due_at: { type: DataTypes.DATE },
status: { type: DataTypes.ENUM("not_started", "in_progress", "done"), defaultValue: "not_started" },
notes: { type: DataTypes.TEXT },
importHash: { type: DataTypes.STRING(255), allowNull: true, unique: true },
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
action_items.associate = (db) => {
db.action_items.belongsTo(db.clients, { as: "client", foreignKey: { name: "clientId" }, constraints: false });
db.action_items.belongsTo(db.sessions, { as: "session", foreignKey: { name: "sessionId" }, constraints: false });
db.action_items.belongsTo(db.users, { as: "createdBy", constraints: false });
db.action_items.belongsTo(db.users, { as: "updatedBy", constraints: false });
};
return action_items;
};

View File

@ -0,0 +1,37 @@
module.exports = function(sequelize, DataTypes) {
const clients = sequelize.define(
"clients",
{
id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
name: { type: DataTypes.TEXT },
email: { type: DataTypes.TEXT },
status: { type: DataTypes.ENUM("lead", "active", "paused", "completed"), defaultValue: "active" },
goals: { type: DataTypes.TEXT },
notes: { type: DataTypes.TEXT },
company: { type: DataTypes.TEXT },
role_title: { type: DataTypes.TEXT },
tags: { type: DataTypes.TEXT },
next_session_at: { type: DataTypes.DATE },
last_session_at: { type: DataTypes.DATE },
importHash: { type: DataTypes.STRING(255), allowNull: true, unique: true },
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
clients.associate = (db) => {
db.clients.belongsTo(db.packages, { as: "package", foreignKey: { name: "packageId" }, constraints: false });
db.clients.belongsTo(db.users, { as: "owner", foreignKey: { name: "ownerId" }, constraints: false });
db.clients.hasMany(db.sessions, { as: "sessions", foreignKey: { name: "clientId" }, constraints: false });
db.clients.hasMany(db.action_items, { as: "action_items", foreignKey: { name: "clientId" }, constraints: false });
db.clients.hasMany(db.resources, { as: "resources", foreignKey: { name: "clientId" }, constraints: false });
db.clients.hasMany(db.prep_briefs, { as: "prep_briefs", foreignKey: { name: "clientId" }, constraints: false });
db.clients.belongsTo(db.users, { as: "createdBy", constraints: false });
db.clients.belongsTo(db.users, { as: "updatedBy", constraints: false });
};
return clients;
};

View File

@ -0,0 +1,31 @@
module.exports = function(sequelize, DataTypes) {
const intake_leads = sequelize.define(
"intake_leads",
{
id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
name: { type: DataTypes.TEXT },
email: { type: DataTypes.TEXT },
company: { type: DataTypes.TEXT },
role_title: { type: DataTypes.TEXT },
goal: { type: DataTypes.TEXT },
challenge: { type: DataTypes.TEXT },
desired_outcome: { type: DataTypes.TEXT },
source: { type: DataTypes.TEXT },
status: { type: DataTypes.ENUM("new", "reviewed", "invited", "converted", "archived"), defaultValue: "new" },
consent_ai_notes: { type: DataTypes.BOOLEAN, defaultValue: false },
importHash: { type: DataTypes.STRING(255), allowNull: true, unique: true },
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
intake_leads.associate = (db) => {
db.intake_leads.belongsTo(db.users, { as: "createdBy", constraints: false });
db.intake_leads.belongsTo(db.users, { as: "updatedBy", constraints: false });
};
return intake_leads;
};

View File

@ -0,0 +1,30 @@
module.exports = function(sequelize, DataTypes) {
const packages = sequelize.define(
"packages",
{
id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
title: { type: DataTypes.TEXT },
description: { type: DataTypes.TEXT },
price: { type: DataTypes.TEXT },
duration: { type: DataTypes.TEXT },
cta: { type: DataTypes.TEXT },
included_sessions: { type: DataTypes.INTEGER },
is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
importHash: { type: DataTypes.STRING(255), allowNull: true, unique: true },
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
packages.associate = (db) => {
db.packages.hasMany(db.clients, { as: "clients", foreignKey: { name: "packageId" }, constraints: false });
db.packages.hasMany(db.resources, { as: "resources", foreignKey: { name: "packageId" }, constraints: false });
db.packages.belongsTo(db.users, { as: "createdBy", constraints: false });
db.packages.belongsTo(db.users, { as: "updatedBy", constraints: false });
};
return packages;
};

View File

@ -0,0 +1,29 @@
module.exports = function(sequelize, DataTypes) {
const prep_briefs = sequelize.define(
"prep_briefs",
{
id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
next_session_at: { type: DataTypes.DATE },
previous_summary: { type: DataTypes.TEXT },
open_commitments: { type: DataTypes.TEXT },
suggested_questions: { type: DataTypes.TEXT },
sensitive_topics: { type: DataTypes.TEXT },
status: { type: DataTypes.ENUM("draft", "ready", "archived"), defaultValue: "ready" },
importHash: { type: DataTypes.STRING(255), allowNull: true, unique: true },
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
prep_briefs.associate = (db) => {
db.prep_briefs.belongsTo(db.clients, { as: "client", foreignKey: { name: "clientId" }, constraints: false });
db.prep_briefs.belongsTo(db.sessions, { as: "session", foreignKey: { name: "sessionId" }, constraints: false });
db.prep_briefs.belongsTo(db.users, { as: "createdBy", constraints: false });
db.prep_briefs.belongsTo(db.users, { as: "updatedBy", constraints: false });
};
return prep_briefs;
};

View File

@ -0,0 +1,28 @@
module.exports = function(sequelize, DataTypes) {
const resources = sequelize.define(
"resources",
{
id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
title: { type: DataTypes.TEXT },
description: { type: DataTypes.TEXT },
url: { type: DataTypes.TEXT },
resource_type: { type: DataTypes.ENUM("link", "worksheet", "pdf", "video"), defaultValue: "link" },
is_shared: { type: DataTypes.BOOLEAN, defaultValue: true },
importHash: { type: DataTypes.STRING(255), allowNull: true, unique: true },
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
resources.associate = (db) => {
db.resources.belongsTo(db.clients, { as: "client", foreignKey: { name: "clientId" }, constraints: false });
db.resources.belongsTo(db.packages, { as: "package", foreignKey: { name: "packageId" }, constraints: false });
db.resources.belongsTo(db.users, { as: "createdBy", constraints: false });
db.resources.belongsTo(db.users, { as: "updatedBy", constraints: false });
};
return resources;
};

View File

@ -0,0 +1,40 @@
module.exports = function(sequelize, DataTypes) {
const sessions = sequelize.define(
"sessions",
{
id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
title: { type: DataTypes.TEXT },
session_at: { type: DataTypes.DATE },
status: { type: DataTypes.ENUM("planned", "completed", "draft_review", "shared"), defaultValue: "completed" },
transcript_notes: { type: DataTypes.TEXT },
ai_summary: { type: DataTypes.TEXT },
key_topics: { type: DataTypes.TEXT },
goals_discussed: { type: DataTypes.TEXT },
blockers: { type: DataTypes.TEXT },
commitments: { type: DataTypes.TEXT },
homework: { type: DataTypes.TEXT },
emotional_themes: { type: DataTypes.TEXT },
important_quotes: { type: DataTypes.TEXT },
follow_up_email: { type: DataTypes.TEXT },
next_session_prep: { type: DataTypes.TEXT },
private_coach_notes: { type: DataTypes.TEXT },
shared_client_notes: { type: DataTypes.TEXT },
importHash: { type: DataTypes.STRING(255), allowNull: true, unique: true },
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
sessions.associate = (db) => {
db.sessions.belongsTo(db.clients, { as: "client", foreignKey: { name: "clientId" }, constraints: false });
db.sessions.hasMany(db.action_items, { as: "action_items", foreignKey: { name: "sessionId" }, constraints: false });
db.sessions.hasMany(db.prep_briefs, { as: "prep_briefs", foreignKey: { name: "sessionId" }, constraints: false });
db.sessions.belongsTo(db.users, { as: "createdBy", constraints: false });
db.sessions.belongsTo(db.users, { as: "updatedBy", constraints: false });
};
return sessions;
};

View File

@ -0,0 +1,26 @@
module.exports = function(sequelize, DataTypes) {
const testimonials = sequelize.define(
"testimonials",
{
id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
name: { type: DataTypes.TEXT },
role_company: { type: DataTypes.TEXT },
quote: { type: DataTypes.TEXT },
photo_url: { type: DataTypes.TEXT },
visible_on_site: { type: DataTypes.BOOLEAN, defaultValue: true },
importHash: { type: DataTypes.STRING(255), allowNull: true, unique: true },
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
testimonials.associate = (db) => {
db.testimonials.belongsTo(db.users, { as: "createdBy", constraints: false });
db.testimonials.belongsTo(db.users, { as: "updatedBy", constraints: false });
};
return testimonials;
};

View File

@ -144,46 +144,8 @@ provider: {
db.users.hasMany(db.accounts, {
as: 'accounts_owner',
foreignKey: {
name: 'ownerId',
},
constraints: false,
});
db.users.hasMany(db.contacts, {
as: 'contacts_owner',
foreignKey: {
name: 'ownerId',
},
constraints: false,
});
db.users.hasMany(db.leads, {
as: 'leads_owner',
foreignKey: {
name: 'ownerId',
},
constraints: false,
});
db.users.hasMany(db.deals, {
as: 'deals_owner',
foreignKey: {
name: 'ownerId',
},
constraints: false,
});
db.users.hasMany(db.activities, {
as: 'activities_owner',
db.users.hasMany(db.clients, {
as: 'clients_owner',
foreignKey: {
name: 'ownerId',
},
@ -270,4 +232,3 @@ function trimStringFields(users) {
return users;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -14,10 +14,10 @@ const swaggerJsDoc = require('swagger-jsdoc');
const authRoutes = require('./routes/auth');
const fileRoutes = require('./routes/file');
const searchRoutes = require('./routes/search');
const sqlRoutes = require('./routes/sql');
const pexelsRoutes = require('./routes/pexels');
const openaiRoutes = require('./routes/openai');
const coachingRoutes = require('./routes/coaching');
@ -27,21 +27,6 @@ const rolesRoutes = require('./routes/roles');
const permissionsRoutes = require('./routes/permissions');
const accountsRoutes = require('./routes/accounts');
const contactsRoutes = require('./routes/contacts');
const lead_sourcesRoutes = require('./routes/lead_sources');
const leadsRoutes = require('./routes/leads');
const pipeline_stagesRoutes = require('./routes/pipeline_stages');
const dealsRoutes = require('./routes/deals');
const activitiesRoutes = require('./routes/activities');
const getBaseUrl = (url) => {
if (!url) return '';
return url.endsWith('/api') ? url.slice(0, -4) : url;
@ -52,8 +37,8 @@ const options = {
openapi: "3.0.0",
info: {
version: "1.0.0",
title: "Sales Pipeline CRM",
description: "Sales Pipeline CRM Online REST API for Testing and Prototyping application. You can perform all major operations with your entities - create, delete and etc.",
title: "Coaching SaaS Workspace",
description: "Coaching SaaS Workspace REST API for clients, session memory, resources, and coach operations.",
},
servers: [
{
@ -105,20 +90,7 @@ app.use('/api/roles', passport.authenticate('jwt', {session: false}), rolesRoute
app.use('/api/permissions', passport.authenticate('jwt', {session: false}), permissionsRoutes);
app.use('/api/accounts', passport.authenticate('jwt', {session: false}), accountsRoutes);
app.use('/api/contacts', passport.authenticate('jwt', {session: false}), contactsRoutes);
app.use('/api/lead_sources', passport.authenticate('jwt', {session: false}), lead_sourcesRoutes);
app.use('/api/leads', passport.authenticate('jwt', {session: false}), leadsRoutes);
app.use('/api/pipeline_stages', passport.authenticate('jwt', {session: false}), pipeline_stagesRoutes);
app.use('/api/deals', passport.authenticate('jwt', {session: false}), dealsRoutes);
app.use('/api/activities', passport.authenticate('jwt', {session: false}), activitiesRoutes);
app.use('/api/coaching', passport.authenticate('jwt', {session: false}), coachingRoutes);
app.use(
'/api/openai',
passport.authenticate('jwt', { session: false }),
@ -134,11 +106,6 @@ app.use(
'/api/search',
passport.authenticate('jwt', { session: false }),
searchRoutes);
app.use(
'/api/sql',
passport.authenticate('jwt', { session: false }),
sqlRoutes);
const publicDir = path.join(
__dirname,

View File

@ -0,0 +1,248 @@
const express = require("express");
const db = require("../db/models");
const wrapAsync = require("../helpers").wrapAsync;
const { LocalAIApi } = require("../ai/LocalAIApi");
const router = express.Router();
router.get(
"/summary",
wrapAsync(async (req, res) => {
const [clients, sessions, actionItems, resources, prepBriefs] = await Promise.all([
db.clients.count(),
db.sessions.count(),
db.action_items.count({ where: { status: ["not_started", "in_progress"] } }),
db.resources.count({ where: { is_shared: true } }),
db.prep_briefs.count({ where: { status: "ready" } }),
]);
const nextSessions = await db.sessions.findAll({
limit: 4,
order: [["session_at", "DESC"]],
include: [{ model: db.clients, as: "client" }],
});
const activeClients = await db.clients.findAll({
limit: 4,
order: [["next_session_at", "ASC"]],
include: [{ model: db.packages, as: "package" }],
});
res.status(200).send({
counts: {
clients,
sessions,
actionItems,
resources,
prepBriefs,
},
nextSessions,
activeClients,
});
}),
);
router.get(
"/clients",
wrapAsync(async (req, res) => {
const clients = await db.clients.findAll({
order: [["next_session_at", "ASC"]],
include: [
{ model: db.packages, as: "package" },
{ model: db.sessions, as: "sessions", limit: 2, order: [["session_at", "DESC"]] },
{ model: db.action_items, as: "action_items", limit: 3, order: [["due_at", "ASC"]] },
],
});
res.status(200).send(clients);
}),
);
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"]] },
],
});
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
res.status(200).send(client);
}),
);
router.get(
"/session-memory",
wrapAsync(async (req, res) => {
const sessions = await db.sessions.findAll({
limit: 20,
order: [["session_at", "DESC"]],
include: [{ model: db.clients, as: "client" }],
});
res.status(200).send(sessions);
}),
);
router.post(
"/sessions",
wrapAsync(async (req, res) => {
const data = req.body.data || req.body;
const session = await db.sessions.create({
clientId: data.clientId,
title: data.title,
session_at: data.session_at || new Date(),
status: data.status || "completed",
transcript_notes: data.transcript_notes,
ai_summary: data.ai_summary,
key_topics: data.key_topics,
goals_discussed: data.goals_discussed,
blockers: data.blockers,
commitments: data.commitments,
homework: data.homework,
emotional_themes: data.emotional_themes,
important_quotes: data.important_quotes,
follow_up_email: data.follow_up_email,
next_session_prep: data.next_session_prep,
private_coach_notes: data.private_coach_notes,
shared_client_notes: data.shared_client_notes,
createdById: req.currentUser.id,
updatedById: req.currentUser.id,
});
res.status(200).send(session);
}),
);
router.post(
"/session-memory/generate",
wrapAsync(async (req, res) => {
const { clientId, sessionId, transcript } = req.body;
if (!clientId) {
res.status(400).send({ error: "client_id_required" });
return;
}
if (!transcript || !String(transcript).trim()) {
res.status(400).send({ error: "transcript_required" });
return;
}
const client = await db.clients.findByPk(clientId, {
include: [
{ model: db.sessions, as: "sessions", limit: 5, order: [["session_at", "DESC"]] },
{ model: db.action_items, as: "action_items", limit: 10, order: [["due_at", "ASC"]] },
],
});
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
const response = await LocalAIApi.createResponse(
{
input: [
{
role: "system",
content: [
{
type: "input_text",
text: [
"You are a coaching operations assistant.",
"Extract structured session memory for a professional coaching workspace.",
"Return strict JSON only with these string fields:",
"title, ai_summary, key_topics, goals_discussed, blockers, commitments, homework, emotional_themes, important_quotes, follow_up_email, next_session_prep, private_coach_notes, shared_client_notes.",
].join(" "),
},
],
},
{
role: "user",
content: [
{
type: "input_text",
text: JSON.stringify({
client: {
name: client.name,
company: client.company,
role_title: client.role_title,
goals: client.goals,
notes: client.notes,
},
recent_sessions: client.sessions || [],
open_action_items: client.action_items || [],
transcript,
}),
},
],
},
],
},
{ poll_timeout: 180, poll_interval: 3 },
);
if (!response.success) {
res.status(502).send(response);
return;
}
const memory = LocalAIApi.decodeJsonFromResponse(response);
if (sessionId) {
await db.sessions.update(
{
title: memory.title,
ai_summary: memory.ai_summary,
key_topics: memory.key_topics,
goals_discussed: memory.goals_discussed,
blockers: memory.blockers,
commitments: memory.commitments,
homework: memory.homework,
emotional_themes: memory.emotional_themes,
important_quotes: memory.important_quotes,
follow_up_email: memory.follow_up_email,
next_session_prep: memory.next_session_prep,
private_coach_notes: memory.private_coach_notes,
shared_client_notes: memory.shared_client_notes,
updatedById: req.currentUser.id,
},
{ where: { id: sessionId } },
);
}
res.status(200).send(memory);
}),
);
router.get(
"/client-portal/:clientId",
wrapAsync(async (req, res) => {
const client = await db.clients.findByPk(req.params.clientId, {
include: [
{ 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", where: { is_shared: true }, required: false },
],
});
if (!client) {
res.status(404).send({ error: "client_not_found" });
return;
}
res.status(200).send(client);
}),
);
module.exports = router;

View File

@ -8,7 +8,7 @@ export const localStorageStyleKey = 'style'
export const containerMaxW = 'xl:max-w-full xl:mx-auto 2xl:mx-20'
export const appTitle = 'created by Flatlogic generator!'
export const appTitle = 'Coaching SaaS Workspace'
export const getPageTitle = (currentPageTitle: string) => `${currentPageTitle}${appTitle}`

View File

@ -122,7 +122,7 @@ export default function LayoutAuthenticated({
onAsideLgClose={() => setIsAsideLgActive(false)}
/>
{children}
<FooterBar>Hand-crafted & Made with </FooterBar>
<FooterBar>Coaching SaaS Workspace</FooterBar>
</div>
</div>
)

View File

@ -7,101 +7,32 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
},
{
href: '/clients',
label: 'Clients',
icon: icon.mdiAccountGroup,
},
{
href: '/session-memory',
label: 'Session Memory',
icon: icon.mdiTable,
},
{
href: '/client-portal',
label: 'Client Portal',
icon: icon.mdiAccountCircle,
},
{
href: '/users/users-list',
label: 'Users',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiAccountGroup ?? icon.mdiTable,
label: 'Team',
icon: icon.mdiAccountGroup,
permissions: 'READ_USERS'
},
{
href: '/roles/roles-list',
label: 'Roles',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
permissions: 'READ_ROLES'
},
{
href: '/permissions/permissions-list',
label: 'Permissions',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
permissions: 'READ_PERMISSIONS'
},
{
href: '/accounts/accounts-list',
label: 'Accounts',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiDomain' in icon ? icon['mdiDomain' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_ACCOUNTS'
},
{
href: '/contacts/contacts-list',
label: 'Contacts',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiAccountBox' in icon ? icon['mdiAccountBox' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_CONTACTS'
},
{
href: '/lead_sources/lead_sources-list',
label: 'Lead sources',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiSourceBranch' in icon ? icon['mdiSourceBranch' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_LEAD_SOURCES'
},
{
href: '/leads/leads-list',
label: 'Leads',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiAccountSearch' in icon ? icon['mdiAccountSearch' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_LEADS'
},
{
href: '/pipeline_stages/pipeline_stages-list',
label: 'Pipeline stages',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiTransitConnectionVariant' in icon ? icon['mdiTransitConnectionVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_PIPELINE_STAGES'
},
{
href: '/deals/deals-list',
label: 'Deals',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiHandshake' in icon ? icon['mdiHandshake' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_DEALS'
},
{
href: '/activities/activities-list',
label: 'Activities',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiCalendarCheck' in icon ? icon['mdiCalendarCheck' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_ACTIVITIES'
},
{
href: '/profile',
label: 'Profile',
icon: icon.mdiAccountCircle,
},
{
href: '/api-docs',
target: '_blank',
label: 'Swagger API',
icon: icon.mdiFileCode,
permissions: 'READ_API_DOCS'
},
]
export default menuAside

View File

@ -149,8 +149,8 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
setStepsEnabled(false);
};
const title = 'Sales Pipeline CRM'
const description = "Internal sales pipeline CRM to track leads, deals, contacts, and follow-ups with clear stage visibility."
const title = 'Coaching SaaS Workspace'
const description = "A coaching workspace for client context, session memory, action items, resources, and client portal delivery."
const url = "https://flatlogic.com/"
const image = "https://project-screens.s3.amazonaws.com/screenshots/40234/app-hero-20260609-100604.png"
const imageWidth = '1920'

View File

@ -0,0 +1,128 @@
import * as icon from '@mdi/js';
import Head from 'next/head'
import React from 'react'
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'
type PortalClient = {
id: string;
name: string;
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 }>;
};
const ClientPortal = () => {
const [clients, setClients] = React.useState<Array<{ id: string; name: string }>>([]);
const [clientId, setClientId] = React.useState('');
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);
}
}
loadClients();
}, []);
React.useEffect(() => {
async function loadPortal() {
if (!clientId) {
return;
}
const response = await axios.get(`/coaching/client-portal/${clientId}`);
setPortalClient(response.data);
}
loadPortal();
}, [clientId]);
return (
<>
<Head>
<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>
</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>
</div>
</div>
)}
</SectionMain>
</>
)
}
ClientPortal.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}
export default ClientPortal

View File

@ -0,0 +1,147 @@
import * as icon from '@mdi/js';
import Head from 'next/head'
import React from 'react'
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'
type ActionItem = {
id: string;
title: string;
status: string;
};
type Session = {
id: string;
title: string;
ai_summary?: string;
};
type Client = {
id: string;
name: string;
email: string;
status: string;
goals?: string;
notes?: string;
company?: string;
role_title?: string;
tags?: string;
sessions?: Session[];
action_items?: ActionItem[];
package?: {
title?: string;
duration?: string;
};
};
const Clients = () => {
const [clients, setClients] = React.useState<Client[]>([]);
const [selectedClientId, setSelectedClientId] = React.useState<string>('');
React.useEffect(() => {
async function loadClients() {
const response = await axios.get('/coaching/clients');
setClients(response.data);
if (response.data.length > 0) {
setSelectedClientId(response.data[0].id);
}
}
loadClients();
}, []);
const selectedClient = clients.find((client) => client.id === selectedClientId) || clients[0];
return (
<>
<Head>
<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>
</CardBox>
{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>
<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>
))}
</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>
</div>
))}
</div>
</div>
</div>
</CardBox>
)}
</div>
</SectionMain>
</>
)
}
Clients.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}
export default Clients

View File

@ -3,429 +3,141 @@ import Head from 'next/head'
import React from 'react'
import axios from 'axios';
import type { ReactElement } from 'react'
import Link from 'next/link';
import LayoutAuthenticated from '../layouts/Authenticated'
import SectionMain from '../components/SectionMain'
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
import BaseIcon from "../components/BaseIcon";
import CardBox from '../components/CardBox'
import BaseIcon from '../components/BaseIcon'
import { getPageTitle } from '../config'
import Link from "next/link";
import { hasPermission } from "../helpers/userPermissions";
import { fetchWidgets } from '../stores/roles/rolesSlice';
import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator';
import { SmartWidget } from '../components/SmartWidget/SmartWidget';
type Client = {
id: string;
name: string;
company?: string;
role_title?: string;
next_session_at?: string;
package?: {
title?: string;
};
};
type Session = {
id: string;
title?: string;
ai_summary?: string;
session_at?: string;
client?: Client;
};
type Summary = {
counts: {
clients: number;
sessions: number;
actionItems: number;
resources: number;
prepBriefs: number;
};
activeClients: Client[];
nextSessions: Session[];
};
const emptySummary: Summary = {
counts: {
clients: 0,
sessions: 0,
actionItems: 0,
resources: 0,
prepBriefs: 0,
},
activeClients: [],
nextSessions: [],
};
import { useAppDispatch, useAppSelector } from '../stores/hooks';
const Dashboard = () => {
const dispatch = useAppDispatch();
const iconsColor = useAppSelector((state) => state.style.iconsColor);
const corners = useAppSelector((state) => state.style.corners);
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
const [summary, setSummary] = React.useState<Summary>(emptySummary);
const [loading, setLoading] = React.useState(true);
const loadingMessage = 'Loading...';
const [users, setUsers] = React.useState(loadingMessage);
const [roles, setRoles] = React.useState(loadingMessage);
const [permissions, setPermissions] = React.useState(loadingMessage);
const [accounts, setAccounts] = React.useState(loadingMessage);
const [contacts, setContacts] = React.useState(loadingMessage);
const [lead_sources, setLead_sources] = React.useState(loadingMessage);
const [leads, setLeads] = React.useState(loadingMessage);
const [pipeline_stages, setPipeline_stages] = React.useState(loadingMessage);
const [deals, setDeals] = React.useState(loadingMessage);
const [activities, setActivities] = React.useState(loadingMessage);
const [widgetsRole, setWidgetsRole] = React.useState({
role: { value: '', label: '' },
});
const { currentUser } = useAppSelector((state) => state.auth);
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
const { rolesWidgets, loading } = useAppSelector((state) => state.roles);
async function loadData() {
const entities = ['users','roles','permissions','accounts','contacts','lead_sources','leads','pipeline_stages','deals','activities',];
const fns = [setUsers,setRoles,setPermissions,setAccounts,setContacts,setLead_sources,setLeads,setPipeline_stages,setDeals,setActivities,];
const requests = entities.map((entity, index) => {
if(hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) {
return axios.get(`/${entity.toLowerCase()}/count`);
} else {
fns[index](null);
return Promise.resolve({data: {count: null}});
}
});
Promise.allSettled(requests).then((results) => {
results.forEach((result, i) => {
if (result.status === 'fulfilled') {
fns[i](result.value.data.count);
} else {
fns[i](result.reason.message);
}
});
});
React.useEffect(() => {
async function loadSummary() {
const response = await axios.get('/coaching/summary');
setSummary(response.data);
setLoading(false);
}
async function getWidgets(roleId) {
await dispatch(fetchWidgets(roleId));
}
React.useEffect(() => {
if (!currentUser) return;
loadData().then();
setWidgetsRole({ role: { value: currentUser?.app_role?.id, label: currentUser?.app_role?.name } });
}, [currentUser]);
React.useEffect(() => {
if (!currentUser || !widgetsRole?.role?.value) return;
getWidgets(widgetsRole?.role?.value || '').then();
}, [widgetsRole?.role?.value]);
loadSummary();
}, []);
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'],
];
return (
<>
<Head>
<title>
{getPageTitle('Overview')}
</title>
<title>{getPageTitle('Dashboard')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={icon.mdiChartTimelineVariant}
title='Overview'
main>
<SectionTitleLineWithButton icon={icon.mdiViewDashboardOutline} title="Coaching Dashboard" main>
{''}
</SectionTitleLineWithButton>
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator
currentUser={currentUser}
isFetchingQuery={isFetchingQuery}
setWidgetsRole={setWidgetsRole}
widgetsRole={widgetsRole}
/>}
{!!rolesWidgets.length &&
hasPermission(currentUser, 'CREATE_ROLES') && (
<p className=' text-gray-500 dark:text-gray-400 mb-4'>
{`${widgetsRole?.role?.label || 'Users'}'s widgets`}
</p>
)}
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6 grid-flow-dense'>
{(isFetchingQuery || loading) && (
<div className={` ${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 text-lg leading-tight text-gray-500 flex items-center ${cardsStyle} dark:border-dark-700 p-6`}>
<BaseIcon
className={`${iconsColor} animate-spin mr-5`}
w='w-16'
h='h-16'
size={48}
path={icon.mdiLoading}
/>{' '}
Loading widgets...
<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>
)}
{ rolesWidgets &&
rolesWidgets.map((widget) => (
<SmartWidget
key={widget.id}
userId={currentUser?.id}
widget={widget}
roleId={widgetsRole?.role?.value || ''}
admin={hasPermission(currentUser, 'CREATE_ROLES')}
/>
))}
</CardBox>
</Link>
))}
</div>
{!!rolesWidgets.length && <hr className='my-6 ' />}
<div id="dashboard" className='grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6'>
{hasPermission(currentUser, 'READ_USERS') && <Link href={'/users/users-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Users
</div>
<div className="text-3xl leading-tight font-semibold">
{users}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiAccountGroup || icon.mdiTable}
/>
</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>
</Link>
))}
</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>
</div>
</Link>}
{hasPermission(currentUser, 'READ_ROLES') && <Link href={'/roles/roles-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Roles
</div>
<div className="text-3xl leading-tight font-semibold">
{roles}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiShieldAccountVariantOutline || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_PERMISSIONS') && <Link href={'/permissions/permissions-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Permissions
</div>
<div className="text-3xl leading-tight font-semibold">
{permissions}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiShieldAccountOutline || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_ACCOUNTS') && <Link href={'/accounts/accounts-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Accounts
</div>
<div className="text-3xl leading-tight font-semibold">
{accounts}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiDomain' in icon ? icon['mdiDomain' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_CONTACTS') && <Link href={'/contacts/contacts-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Contacts
</div>
<div className="text-3xl leading-tight font-semibold">
{contacts}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiAccountBox' in icon ? icon['mdiAccountBox' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_LEAD_SOURCES') && <Link href={'/lead_sources/lead_sources-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Lead sources
</div>
<div className="text-3xl leading-tight font-semibold">
{lead_sources}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiSourceBranch' in icon ? icon['mdiSourceBranch' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_LEADS') && <Link href={'/leads/leads-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Leads
</div>
<div className="text-3xl leading-tight font-semibold">
{leads}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiAccountSearch' in icon ? icon['mdiAccountSearch' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_PIPELINE_STAGES') && <Link href={'/pipeline_stages/pipeline_stages-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Pipeline stages
</div>
<div className="text-3xl leading-tight font-semibold">
{pipeline_stages}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiTransitConnectionVariant' in icon ? icon['mdiTransitConnectionVariant' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_DEALS') && <Link href={'/deals/deals-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Deals
</div>
<div className="text-3xl leading-tight font-semibold">
{deals}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiHandshake' in icon ? icon['mdiHandshake' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_ACTIVITIES') && <Link href={'/activities/activities-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Activities
</div>
<div className="text-3xl leading-tight font-semibold">
{activities}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiCalendarCheck' in icon ? icon['mdiCalendarCheck' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
))}
</div>
</CardBox>
</div>
</SectionMain>
</>

View File

@ -1,166 +1,99 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import BaseButton from '../components/BaseButton';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('left');
const textColor = useAppSelector((state) => state.style.linkColor);
const title = 'Sales Pipeline CRM'
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<>
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('Coaching Workspace')}</title>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your Sales Pipeline CRM app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
<main className="min-h-screen bg-[#f7f3ec] text-slate-900">
<section className="mx-auto grid min-h-[88vh] max-w-7xl grid-cols-1 gap-10 px-6 py-10 lg:grid-cols-[1.05fr_0.95fr] lg:px-10">
<div className="flex flex-col justify-center">
<p className="mb-5 text-sm font-semibold uppercase tracking-[0.18em] text-emerald-700">
Coaching SaaS Workspace
</p>
<h1 className="max-w-3xl text-5xl font-semibold leading-tight text-slate-950 md:text-6xl">
Turn every coaching session into clear memory, next steps, and client momentum.
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">
A vertical SaaS workspace for coaches who need client context, AI-assisted session notes,
prep briefs, shared resources, and a client portal without stitching together generic tools.
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<BaseButton href="/login" label="Login" color="info" className="w-full sm:w-auto" />
<BaseButton href="/register" label="Create account" color="white" className="w-full sm:w-auto" />
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</div>
</BaseButtons>
</CardBox>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
<div className="flex items-center">
<div className="w-full rounded bg-white p-5 shadow-sm ring-1 ring-slate-200">
<div className="mb-5 flex items-center justify-between border-b border-slate-200 pb-4">
<div>
<p className="text-sm text-slate-500">Next session</p>
<h2 className="text-xl font-semibold">Maya Chen</h2>
</div>
<span className="rounded bg-emerald-50 px-3 py-1 text-sm font-medium text-emerald-700">
Ready
</span>
</div>
<div className="space-y-4">
<div>
<p className="text-sm font-semibold text-slate-500">Prep brief</p>
<p className="mt-1 text-slate-700">
Review delegation boundaries, decision-rights matrix, and where Maya still feels exposed.
</p>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{['Open commitments', 'Suggested questions', 'Shared resources', 'Follow-up email'].map((item) => (
<div key={item} className="rounded border border-slate-200 bg-slate-50 p-4">
<p className="font-medium">{item}</p>
<p className="mt-2 text-sm leading-6 text-slate-500">
Generated from previous sessions and coach notes.
</p>
</div>
))}
</div>
</div>
</div>
</div>
</section>
</div>
<section className="border-t border-slate-200 bg-white">
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-6 px-6 py-10 md:grid-cols-3 lg:px-10">
{[
['Session Memory', 'Extract summaries, blockers, homework, quotes, and follow-up notes from session transcripts.'],
['Client Workspace', 'Keep goals, private notes, resources, and action items organized around every client.'],
['Client Portal', 'Share the right notes, resources, and commitments without exposing coach-only context.'],
].map(([title, copy]) => (
<div key={title} className="rounded border border-slate-200 p-5">
<h3 className="text-lg font-semibold">{title}</h3>
<p className="mt-3 leading-7 text-slate-600">{copy}</p>
</div>
))}
</div>
</section>
<footer className="bg-slate-950 px-6 py-6 text-sm text-white">
<div className="mx-auto flex max-w-7xl flex-col justify-between gap-3 md:flex-row">
<p>© 2026 Coaching SaaS Workspace. All rights reserved.</p>
<div className="flex gap-4">
<Link href="/privacy-policy/">Privacy Policy</Link>
<Link href="/terms-of-use/">Terms of Use</Link>
</div>
</div>
</footer>
</main>
</>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -0,0 +1,149 @@
import * as icon from '@mdi/js';
import Head from 'next/head'
import React from 'react'
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'
type Client = {
id: string;
name: string;
};
type Session = {
id: string;
title?: string;
ai_summary?: string;
key_topics?: string;
homework?: string;
follow_up_email?: string;
client?: Client;
};
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 [isGenerating, setIsGenerating] = React.useState(false);
async function loadData() {
const [clientsResponse, sessionsResponse] = await Promise.all([
axios.get('/coaching/clients'),
axios.get('/coaching/session-memory'),
]);
setClients(clientsResponse.data);
setSessions(sessionsResponse.data);
if (!clientId && clientsResponse.data.length > 0) {
setClientId(clientsResponse.data[0].id);
}
}
React.useEffect(() => {
loadData();
}, []);
async function generateMemory() {
setIsGenerating(true);
const response = await axios.post('/coaching/session-memory/generate', {
clientId,
transcript,
});
setGeneratedMemory(response.data);
setIsGenerating(false);
}
return (
<>
<Head>
<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>
</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>
))}
</div>
</CardBox>
</SectionMain>
</>
)
}
SessionMemory.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}
export default SessionMemory