Build coaching SaaS workspace foundation
This commit is contained in:
parent
2fbcee2c80
commit
05ae5235ee
@ -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}`;
|
||||
|
||||
@ -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
27
backend/src/db/models/action_items.js
Normal file
27
backend/src/db/models/action_items.js
Normal 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;
|
||||
};
|
||||
37
backend/src/db/models/clients.js
Normal file
37
backend/src/db/models/clients.js
Normal 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;
|
||||
};
|
||||
31
backend/src/db/models/intake_leads.js
Normal file
31
backend/src/db/models/intake_leads.js
Normal 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;
|
||||
};
|
||||
30
backend/src/db/models/packages.js
Normal file
30
backend/src/db/models/packages.js
Normal 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;
|
||||
};
|
||||
29
backend/src/db/models/prep_briefs.js
Normal file
29
backend/src/db/models/prep_briefs.js
Normal 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;
|
||||
};
|
||||
28
backend/src/db/models/resources.js
Normal file
28
backend/src/db/models/resources.js
Normal 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;
|
||||
};
|
||||
40
backend/src/db/models/sessions.js
Normal file
40
backend/src/db/models/sessions.js
Normal 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;
|
||||
};
|
||||
26
backend/src/db/models/testimonials.js
Normal file
26
backend/src/db/models/testimonials.js
Normal 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;
|
||||
};
|
||||
@ -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
@ -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,
|
||||
|
||||
248
backend/src/routes/coaching.js
Normal file
248
backend/src/routes/coaching.js
Normal 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;
|
||||
@ -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}`
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
128
frontend/src/pages/client-portal.tsx
Normal file
128
frontend/src/pages/client-portal.tsx
Normal 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
|
||||
147
frontend/src/pages/clients.tsx
Normal file
147
frontend/src/pages/clients.tsx
Normal 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
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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>;
|
||||
};
|
||||
|
||||
|
||||
149
frontend/src/pages/session-memory.tsx
Normal file
149
frontend/src/pages/session-memory.tsx
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user