diff --git a/backend/src/index.js b/backend/src/index.js
index 4e83c80..9f0fbbf 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -6,7 +6,7 @@ const passport = require('passport');
const path = require('path');
const fs = require('fs');
const bodyParser = require('body-parser');
-const db = require('./db/models');
+require('./db/models');
const config = require('./config');
const swaggerUI = require('swagger-ui-express');
const swaggerJsDoc = require('swagger-jsdoc');
@@ -41,6 +41,8 @@ const dealsRoutes = require('./routes/deals');
const activitiesRoutes = require('./routes/activities');
+const salesWorkflowRoutes = require('./routes/salesWorkflow');
+
const activity_filesRoutes = require('./routes/activity_files');
const deal_productsRoutes = require('./routes/deal_products');
@@ -129,6 +131,8 @@ app.use('/api/deals', passport.authenticate('jwt', {session: false}), dealsRoute
app.use('/api/activities', passport.authenticate('jwt', {session: false}), activitiesRoutes);
+app.use('/api/sales-workflow', passport.authenticate('jwt', {session: false}), salesWorkflowRoutes);
+
app.use('/api/activity_files', passport.authenticate('jwt', {session: false}), activity_filesRoutes);
app.use('/api/deal_products', passport.authenticate('jwt', {session: false}), deal_productsRoutes);
diff --git a/backend/src/routes/salesWorkflow.js b/backend/src/routes/salesWorkflow.js
new file mode 100644
index 0000000..a6b68b6
--- /dev/null
+++ b/backend/src/routes/salesWorkflow.js
@@ -0,0 +1,24 @@
+const express = require('express');
+const SalesWorkflowService = require('../services/salesWorkflow');
+const wrapAsync = require('../helpers').wrapAsync;
+const { checkPermissions } = require('../middlewares/check-permissions');
+
+const router = express.Router();
+
+router.post(
+ '/follow-up',
+ checkPermissions('CREATE_ACTIVITIES'),
+ checkPermissions('UPDATE_DEALS'),
+ wrapAsync(async (req, res) => {
+ const payload = await SalesWorkflowService.scheduleFollowUp(
+ req.body,
+ req.currentUser,
+ );
+
+ res.status(200).send(payload);
+ }),
+);
+
+router.use('/', require('../helpers').commonErrorHandler);
+
+module.exports = router;
diff --git a/backend/src/services/salesWorkflow.js b/backend/src/services/salesWorkflow.js
new file mode 100644
index 0000000..5cf46b1
--- /dev/null
+++ b/backend/src/services/salesWorkflow.js
@@ -0,0 +1,114 @@
+const db = require('../db/models');
+const ActivitiesDBApi = require('../db/api/activities');
+
+const ACTIVITY_TYPES = new Set(['call', 'email', 'meeting', 'task', 'demo', 'note']);
+
+function httpError(message, code = 400) {
+ const error = new Error(message);
+ error.code = code;
+ return error;
+}
+
+function cleanText(value) {
+ return typeof value === 'string' ? value.trim() : '';
+}
+
+function parseRequiredDate(value, fieldName) {
+ const date = new Date(value);
+
+ if (!value || Number.isNaN(date.getTime())) {
+ throw httpError(`${fieldName} must be a valid date and time.`);
+ }
+
+ return date;
+}
+
+module.exports = class SalesWorkflowService {
+ static async scheduleFollowUp(data, currentUser) {
+ if (!currentUser || !currentUser.id) {
+ throw httpError('You must be signed in to schedule a follow-up.', 403);
+ }
+
+ const dealId = cleanText(data?.dealId || data?.deal);
+ const activityType = cleanText(data?.activity_type) || 'task';
+ const subject = cleanText(data?.subject);
+ const details = cleanText(data?.details);
+ const dueAt = parseRequiredDate(data?.due_at, 'Follow-up time');
+ const reminderAt = data?.reminder_at
+ ? parseRequiredDate(data.reminder_at, 'Reminder time')
+ : new Date(dueAt.getTime() - 30 * 60 * 1000);
+
+ if (!dealId) {
+ throw httpError('A deal is required before scheduling a follow-up.');
+ }
+
+ if (!ACTIVITY_TYPES.has(activityType)) {
+ throw httpError('Activity type must be call, email, meeting, task, demo, or note.');
+ }
+
+ if (!subject) {
+ throw httpError('Subject is required before scheduling a follow-up.');
+ }
+
+ const transaction = await db.sequelize.transaction();
+
+ try {
+ const deal = await db.deals.findByPk(dealId, { transaction });
+
+ if (!deal) {
+ throw httpError('Deal not found.', 404);
+ }
+
+ const activityPayload = {
+ activity_type: activityType,
+ subject,
+ details,
+ scheduled_at: dueAt,
+ due_at: dueAt,
+ status: 'planned',
+ is_reminder_enabled: data?.is_reminder_enabled !== false,
+ reminder_at: reminderAt,
+ assigned_to: currentUser.id,
+ deal: deal.id,
+ lead: deal.leadId || null,
+ contact: deal.primary_contactId || null,
+ };
+
+ const activity = await ActivitiesDBApi.create(
+ activityPayload,
+ {
+ currentUser,
+ transaction,
+ },
+ );
+
+ await deal.update(
+ {
+ next_follow_up_at: dueAt,
+ updatedById: currentUser.id,
+ },
+ { transaction },
+ );
+
+ await transaction.commit();
+
+ return {
+ activity: {
+ id: activity.id,
+ activity_type: activity.activity_type,
+ subject: activity.subject,
+ due_at: activity.due_at,
+ status: activity.status,
+ },
+ deal: {
+ id: deal.id,
+ name: deal.name,
+ next_follow_up_at: deal.next_follow_up_at,
+ },
+ };
+ } catch (error) {
+ await transaction.rollback();
+ throw error;
+ }
+ }
+};
diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx
index 72935e6..fcbd9b9 100644
--- a/frontend/src/components/NavBarItem.tsx
+++ b/frontend/src/components/NavBarItem.tsx
@@ -1,6 +1,5 @@
-import React, {useEffect, useRef} from 'react'
+import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
-import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'
diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx
index 1b9907d..73d8391 100644
--- a/frontend/src/layouts/Authenticated.tsx
+++ b/frontend/src/layouts/Authenticated.tsx
@@ -1,5 +1,4 @@
-import React, { ReactNode, useEffect } from 'react'
-import { useState } from 'react'
+import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'
diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts
index 3f1d2eb..041b6aa 100644
--- a/frontend/src/menuAside.ts
+++ b/frontend/src/menuAside.ts
@@ -8,6 +8,13 @@ const menuAside: MenuAsideItem[] = [
label: 'Dashboard',
},
+ {
+ href: '/sales-command-center',
+ icon: icon.mdiChartTimelineVariant,
+ label: 'Sales command center',
+ permissions: 'READ_DEALS'
+ },
+
{
href: '/users/users-list',
label: 'Users',
diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx
index 8f4466d..e419055 100644
--- a/frontend/src/pages/index.tsx
+++ b/frontend/src/pages/index.tsx
@@ -111,7 +111,7 @@ export default function Starter() {
}
>
- {getPageTitle('Starter Page')}
+ {getPageTitle('Sales CRM Pipeline')}
@@ -127,22 +127,36 @@ export default function Starter() {
? videoBlock(illustrationVideo)
: null}
-
-
+
+
+
+ Sales CRM Pipeline
+
+
+
-
-
This is a React.js/Node.js app generated by the Flatlogic Web App Generator
-
For guides and documentation please check
- your local README.md and the Flatlogic documentation
+
+
Track leads, contacts, companies, deals, and activity reminders from one focused workspace for your internal sales team.
+
+
Pipeline visibility
+
Deal ownership
+
Follow-up rhythm
+
+
diff --git a/frontend/src/pages/sales-command-center.tsx b/frontend/src/pages/sales-command-center.tsx
new file mode 100644
index 0000000..d04a751
--- /dev/null
+++ b/frontend/src/pages/sales-command-center.tsx
@@ -0,0 +1,753 @@
+import * as icon from '@mdi/js';
+import axios from 'axios';
+import Head from 'next/head';
+import Link from 'next/link';
+import React, { ReactElement } from 'react';
+import BaseButton from '../components/BaseButton';
+import CardBox from '../components/CardBox';
+import SectionMain from '../components/SectionMain';
+import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
+import LayoutAuthenticated from '../layouts/Authenticated';
+import { getPageTitle } from '../config';
+import { hasPermission } from '../helpers/userPermissions';
+import { useAppSelector } from '../stores/hooks';
+
+type UserRef = {
+ id?: string;
+ firstName?: string;
+ lastName?: string;
+ email?: string;
+};
+
+type EntityRef = {
+ id?: string;
+ name?: string;
+ lead_name?: string;
+ first_name?: string;
+ last_name?: string;
+ email?: string;
+ sort_order?: number;
+ win_probability?: number;
+};
+
+type Deal = {
+ id: string;
+ name?: string;
+ amount?: string | number | null;
+ forecast_category?: string | null;
+ expected_close_at?: string | null;
+ next_follow_up_at?: string | null;
+ priority?: string | null;
+ description?: string | null;
+ stage?: EntityRef | null;
+ company?: EntityRef | null;
+ primary_contact?: EntityRef | null;
+ lead?: EntityRef | null;
+ owner?: UserRef | null;
+};
+
+type Activity = {
+ id: string;
+ activity_type?: string | null;
+ subject?: string | null;
+ due_at?: string | null;
+ scheduled_at?: string | null;
+ completed_at?: string | null;
+ status?: string | null;
+ deal?: Deal | null;
+ lead?: EntityRef | null;
+ contact?: EntityRef | null;
+ assigned_to?: UserRef | null;
+};
+
+type FollowUpForm = {
+ dealId: string;
+ activity_type: string;
+ due_at: string;
+ subject: string;
+ details: string;
+ is_reminder_enabled: boolean;
+};
+
+const currencyFormatter = new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'USD',
+ maximumFractionDigits: 0,
+});
+
+function labelize(value?: string | null) {
+ if (!value) return 'Unstaged';
+
+ return value
+ .split('_')
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(' ');
+}
+
+function formatCurrency(value?: string | number | null) {
+ const amount = Number(value || 0);
+
+ if (Number.isNaN(amount)) return '$0';
+
+ return currencyFormatter.format(amount);
+}
+
+function formatDate(value?: string | null) {
+ if (!value) return 'Not set';
+
+ const date = new Date(value);
+
+ if (Number.isNaN(date.getTime())) return 'Not set';
+
+ return date.toLocaleDateString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+}
+
+function formatDateTime(value?: string | null) {
+ if (!value) return 'Not scheduled';
+
+ const date = new Date(value);
+
+ if (Number.isNaN(date.getTime())) return 'Not scheduled';
+
+ return date.toLocaleString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ });
+}
+
+function dateTimeInputValue(date: Date) {
+ const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
+ return local.toISOString().slice(0, 16);
+}
+
+function defaultDueAt() {
+ const date = new Date();
+ date.setDate(date.getDate() + 1);
+ date.setHours(9, 0, 0, 0);
+ return dateTimeInputValue(date);
+}
+
+function getOwnerName(owner?: UserRef | null) {
+ const name = [owner?.firstName, owner?.lastName].filter(Boolean).join(' ');
+ return name || owner?.email || 'Unassigned';
+}
+
+function getContactName(contact?: EntityRef | null) {
+ const name = [contact?.first_name, contact?.last_name].filter(Boolean).join(' ');
+ return name || contact?.email || 'No primary contact';
+}
+
+function isOpenDeal(deal: Deal) {
+ return !['closed_won', 'closed_lost'].includes(deal.forecast_category || '');
+}
+
+function getApiErrorMessage(error: any) {
+ const response = error?.response?.data;
+
+ if (typeof response === 'string') return response;
+ if (response?.message) return response.message;
+
+ return error?.message || 'Something went wrong. Please try again.';
+}
+
+const badgeStyles: Record
= {
+ urgent: 'bg-rose-100 text-rose-700 ring-1 ring-rose-200',
+ high: 'bg-orange-100 text-orange-700 ring-1 ring-orange-200',
+ medium: 'bg-blue-100 text-blue-700 ring-1 ring-blue-200',
+ low: 'bg-emerald-100 text-emerald-700 ring-1 ring-emerald-200',
+};
+
+const SalesCommandCenter = () => {
+ const { currentUser } = useAppSelector((state) => state.auth);
+ const [deals, setDeals] = React.useState([]);
+ const [activities, setActivities] = React.useState([]);
+ const [selectedDealId, setSelectedDealId] = React.useState('');
+ const [loading, setLoading] = React.useState(true);
+ const [submitting, setSubmitting] = React.useState(false);
+ const [errorMessage, setErrorMessage] = React.useState('');
+ const [successMessage, setSuccessMessage] = React.useState('');
+ const [form, setForm] = React.useState({
+ dealId: '',
+ activity_type: 'task',
+ due_at: defaultDueAt(),
+ subject: '',
+ details: '',
+ is_reminder_enabled: true,
+ });
+
+ const canScheduleFollowUp =
+ hasPermission(currentUser, 'CREATE_ACTIVITIES') &&
+ hasPermission(currentUser, 'UPDATE_DEALS');
+
+ const loadData = React.useCallback(async () => {
+ if (!currentUser) return;
+
+ setLoading(true);
+ setErrorMessage('');
+
+ try {
+ const [dealsResponse, activitiesResponse] = await Promise.all([
+ axios.get('/deals', {
+ params: { limit: 100, page: 0, field: 'next_follow_up_at', sort: 'asc' },
+ }),
+ axios.get('/activities', {
+ params: { limit: 100, page: 0, field: 'due_at', sort: 'asc' },
+ }),
+ ]);
+
+ const nextDeals = Array.isArray(dealsResponse.data?.rows)
+ ? dealsResponse.data.rows
+ : [];
+ const nextActivities = Array.isArray(activitiesResponse.data?.rows)
+ ? activitiesResponse.data.rows
+ : [];
+
+ setDeals(nextDeals);
+ setActivities(nextActivities);
+ setSelectedDealId((current) => current || nextDeals[0]?.id || '');
+ setForm((current) => ({
+ ...current,
+ dealId: current.dealId || nextDeals[0]?.id || '',
+ }));
+ } catch (error) {
+ console.error('Failed to load sales command center data', error);
+ setErrorMessage(getApiErrorMessage(error));
+ } finally {
+ setLoading(false);
+ }
+ }, [currentUser]);
+
+ React.useEffect(() => {
+ loadData().then();
+ }, [loadData]);
+
+ React.useEffect(() => {
+ if (!selectedDealId && deals[0]?.id) {
+ setSelectedDealId(deals[0].id);
+ }
+ }, [deals, selectedDealId]);
+
+ const selectedDeal = React.useMemo(
+ () => deals.find((deal) => deal.id === selectedDealId) || deals[0],
+ [deals, selectedDealId],
+ );
+
+ const pipelineColumns = React.useMemo(() => {
+ const columns = deals.reduce>(
+ (acc, deal) => {
+ const label = deal.stage?.name || labelize(deal.forecast_category);
+ const order = deal.stage?.sort_order || 999;
+
+ if (!acc[label]) {
+ acc[label] = { label, order, deals: [] };
+ }
+
+ acc[label].deals.push(deal);
+ acc[label].order = Math.min(acc[label].order, order);
+
+ return acc;
+ },
+ {},
+ );
+
+ return Object.values(columns).sort((a, b) => a.order - b.order || a.label.localeCompare(b.label));
+ }, [deals]);
+
+ const openDeals = React.useMemo(() => deals.filter(isOpenDeal), [deals]);
+
+ const plannedActivities = React.useMemo(
+ () =>
+ activities
+ .filter((activity) => !['completed', 'canceled'].includes(activity.status || ''))
+ .sort(
+ (a, b) =>
+ new Date(a.due_at || a.scheduled_at || 0).getTime() -
+ new Date(b.due_at || b.scheduled_at || 0).getTime(),
+ ),
+ [activities],
+ );
+
+ const metrics = React.useMemo(() => {
+ const now = new Date();
+ const weekFromNow = new Date();
+ weekFromNow.setDate(now.getDate() + 7);
+
+ const pipelineValue = openDeals.reduce(
+ (sum, deal) => sum + Number(deal.amount || 0),
+ 0,
+ );
+ const dueThisWeek = plannedActivities.filter((activity) => {
+ const due = new Date(activity.due_at || activity.scheduled_at || 0);
+ return due >= now && due <= weekFromNow;
+ }).length;
+ const overdue = plannedActivities.filter((activity) => {
+ const due = new Date(activity.due_at || activity.scheduled_at || 0);
+ return due < now;
+ }).length;
+ const ownedDeals = deals.filter((deal) => deal.owner?.id === currentUser?.id).length;
+
+ return {
+ pipelineValue,
+ openDealCount: openDeals.length,
+ dueThisWeek,
+ overdue,
+ ownedDeals,
+ };
+ }, [currentUser?.id, deals, openDeals, plannedActivities]);
+
+ const handleFieldChange = (
+ event: React.ChangeEvent,
+ ) => {
+ const { name, value, type } = event.target;
+ const checked = 'checked' in event.target ? event.target.checked : false;
+
+ setForm((current) => ({
+ ...current,
+ [name]: type === 'checkbox' ? checked : value,
+ }));
+ };
+
+ const handleDealSelect = (dealId: string) => {
+ setSelectedDealId(dealId);
+ setForm((current) => ({ ...current, dealId }));
+ setSuccessMessage('');
+ };
+
+ const handleSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ setErrorMessage('');
+ setSuccessMessage('');
+
+ if (!form.dealId) {
+ setErrorMessage('Choose a deal before scheduling a follow-up.');
+ return;
+ }
+
+ if (!form.subject.trim()) {
+ setErrorMessage('Add a clear subject so the follow-up is actionable.');
+ return;
+ }
+
+ if (!form.due_at) {
+ setErrorMessage('Choose when the follow-up is due.');
+ return;
+ }
+
+ const payload = {
+ ...form,
+ subject: form.subject.trim(),
+ details: form.details.trim(),
+ };
+
+ setSubmitting(true);
+
+ try {
+ await axios.post('/sales-workflow/follow-up', payload);
+ const dealName = deals.find((deal) => deal.id === form.dealId)?.name || 'the deal';
+ setSuccessMessage(`Follow-up scheduled for ${dealName}.`);
+ setForm((current) => ({
+ ...current,
+ due_at: defaultDueAt(),
+ subject: '',
+ details: '',
+ }));
+ await loadData();
+ } catch (error) {
+ console.error('Failed to schedule follow-up', { payload, error });
+ setErrorMessage(getApiErrorMessage(error));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const upcomingForSelectedDeal = plannedActivities.filter(
+ (activity) => activity.deal?.id === selectedDeal?.id,
+ );
+
+ return (
+ <>
+
+ {getPageTitle('Sales Command Center')}
+
+
+
+ loadData().then()}
+ disabled={loading}
+ />
+
+
+
+
+
+
+
+
+ Pipeline visibility + follow-ups
+
+
+ Know what is moving, who owns it, and what needs follow-up next.
+
+
+ A focused sales workspace layered on top of your existing Leads, Deals,
+ Contacts, Companies, and Activities data.
+
+
+
+
Rep focus
+
+ {metrics.dueThisWeek} follow-ups due this week
+
+
+ {metrics.overdue} overdue · {metrics.ownedDeals} owned by you
+
+
+
+
+
+
+ {errorMessage && (
+
+ {errorMessage}
+
+ )}
+ {successMessage && (
+
+ {successMessage}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Pipeline board
+
+ Grouped by stage when available, with forecast fallback.
+
+
+
+
+
+ {loading ? (
+
+ Loading the pipeline...
+
+ ) : pipelineColumns.length === 0 ? (
+
+
No deals yet
+
+ Create your first deal, then schedule follow-ups from this workspace.
+
+
+
+ ) : (
+
+ {pipelineColumns.map((column) => {
+ const columnValue = column.deals.reduce(
+ (sum, deal) => sum + Number(deal.amount || 0),
+ 0,
+ );
+
+ return (
+
+
+
+
{column.label}
+
+ {column.deals.length} deals · {formatCurrency(columnValue)}
+
+
+
+
+ {column.deals.map((deal) => {
+ const isSelected = selectedDeal?.id === deal.id;
+ return (
+
+ );
+ })}
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+
Next follow-ups
+
+ Planned calls, emails, meetings, demos, notes, and tasks.
+
+
+
+
+
+ {plannedActivities.length === 0 ? (
+
+ No planned follow-ups. Select a deal and schedule one to keep momentum.
+
+ ) : (
+
+ {plannedActivities.slice(0, 8).map((activity) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ >
+ );
+};
+
+const MetricCard = ({
+ label,
+ value,
+ helper,
+ tone = 'default',
+}: {
+ label: string;
+ value: string;
+ helper: string;
+ tone?: 'default' | 'danger';
+}) => (
+
+
+
+
{label}
+
{value}
+
{helper}
+
+
+
+
+);
+
+const DetailRow = ({ label, value }: { label: string; value: string }) => (
+
+ {label}
+ {value}
+
+);
+
+SalesCommandCenter.getLayout = function getLayout(page: ReactElement) {
+ return {page};
+};
+
+export default SalesCommandCenter;
diff --git a/permissions-page-screenshot.png b/permissions-page-screenshot.png
new file mode 100644
index 0000000..9d2d8f0
Binary files /dev/null and b/permissions-page-screenshot.png differ