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) => ( + + ))} +
+ )} +
+
+ +