Autosave: 20260526-150322
This commit is contained in:
parent
26c05d93e5
commit
02741984f9
@ -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);
|
||||
|
||||
24
backend/src/routes/salesWorkflow.js
Normal file
24
backend/src/routes/salesWorkflow.js
Normal file
@ -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;
|
||||
114
backend/src/services/salesWorkflow.js
Normal file
114
backend/src/services/salesWorkflow.js
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -111,7 +111,7 @@ export default function Starter() {
|
||||
}
|
||||
>
|
||||
<Head>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
<title>{getPageTitle('Sales CRM Pipeline')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
@ -127,22 +127,36 @@ export default function Starter() {
|
||||
? 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 CRM Pipeline app!"/>
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3 border-0 shadow-2xl'>
|
||||
<div className='text-center'>
|
||||
<div className='mx-auto mb-4 inline-flex rounded-full bg-teal-50 px-4 py-2 text-xs font-bold uppercase tracking-[0.2em] text-teal-700 ring-1 ring-teal-100'>
|
||||
Sales CRM Pipeline
|
||||
</div>
|
||||
<CardBoxComponentTitle title="Close every deal with clear ownership and follow-ups"/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div className="space-y-4">
|
||||
<p className='text-center text-gray-500'>Track leads, contacts, companies, deals, and activity reminders from one focused workspace for your internal sales team.</p>
|
||||
<div className='grid grid-cols-1 gap-3 text-center text-sm md:grid-cols-3'>
|
||||
<div className='rounded-2xl bg-slate-50 p-4 ring-1 ring-slate-100'>Pipeline visibility</div>
|
||||
<div className='rounded-2xl bg-slate-50 p-4 ring-1 ring-slate-100'>Deal ownership</div>
|
||||
<div className='rounded-2xl bg-slate-50 p-4 ring-1 ring-slate-100'>Follow-up rhythm</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
href='/sales-command-center'
|
||||
label='Open command center'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login / Admin'
|
||||
color='whiteDark'
|
||||
className='w-full'
|
||||
/>
|
||||
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
|
||||
753
frontend/src/pages/sales-command-center.tsx
Normal file
753
frontend/src/pages/sales-command-center.tsx
Normal file
@ -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<string, string> = {
|
||||
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<Deal[]>([]);
|
||||
const [activities, setActivities] = React.useState<Activity[]>([]);
|
||||
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<FollowUpForm>({
|
||||
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<Record<string, { label: string; order: number; deals: Deal[] }>>(
|
||||
(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<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>,
|
||||
) => {
|
||||
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 (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Sales Command Center')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton
|
||||
icon={icon.mdiChartTimelineVariant}
|
||||
title="Sales command center"
|
||||
main
|
||||
>
|
||||
<BaseButton
|
||||
label="Refresh"
|
||||
color="whiteDark"
|
||||
onClick={() => loadData().then()}
|
||||
disabled={loading}
|
||||
/>
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<div className="mb-6 overflow-hidden rounded-3xl bg-slate-950 shadow-xl ring-1 ring-slate-900/10">
|
||||
<div className="relative p-6 md:p-8">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(45,212,191,0.35),_transparent_35%),radial-gradient(circle_at_top_right,_rgba(96,165,250,0.30),_transparent_30%)]" />
|
||||
<div className="relative grid gap-6 lg:grid-cols-[1.3fr_0.7fr] lg:items-end">
|
||||
<div>
|
||||
<p className="mb-3 inline-flex rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-teal-200 ring-1 ring-white/15">
|
||||
Pipeline visibility + follow-ups
|
||||
</p>
|
||||
<h2 className="max-w-3xl text-3xl font-black tracking-tight text-white md:text-5xl">
|
||||
Know what is moving, who owns it, and what needs follow-up next.
|
||||
</h2>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-6 text-slate-300 md:text-base">
|
||||
A focused sales workspace layered on top of your existing Leads, Deals,
|
||||
Contacts, Companies, and Activities data.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white/10 p-4 text-white ring-1 ring-white/15 backdrop-blur">
|
||||
<p className="text-sm text-slate-300">Rep focus</p>
|
||||
<p className="mt-2 text-2xl font-bold">
|
||||
{metrics.dueThisWeek} follow-ups due this week
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-slate-300">
|
||||
{metrics.overdue} overdue · {metrics.ownedDeals} owned by you
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="mb-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-medium text-rose-700">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
{successMessage && (
|
||||
<div className="mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-700">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<MetricCard label="Open pipeline" value={formatCurrency(metrics.pipelineValue)} helper="Active deal value" />
|
||||
<MetricCard label="Open deals" value={String(metrics.openDealCount)} helper="Not won/lost" />
|
||||
<MetricCard label="Due this week" value={String(metrics.dueThisWeek)} helper="Planned activities" />
|
||||
<MetricCard label="Overdue" value={String(metrics.overdue)} helper="Needs attention" tone="danger" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.4fr_0.6fr]">
|
||||
<div className="space-y-6">
|
||||
<CardBox className="border-0 shadow-sm">
|
||||
<div className="mb-4 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-slate-900 dark:text-white">Pipeline board</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Grouped by stage when available, with forecast fallback.
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton href="/deals/deals-new" label="New deal" color="info" />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="rounded-2xl border border-dashed border-slate-300 p-8 text-center text-slate-500">
|
||||
Loading the pipeline...
|
||||
</div>
|
||||
) : pipelineColumns.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-slate-300 p-8 text-center">
|
||||
<p className="text-lg font-semibold text-slate-800 dark:text-white">No deals yet</p>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
Create your first deal, then schedule follow-ups from this workspace.
|
||||
</p>
|
||||
<BaseButton href="/deals/deals-new" label="Create first deal" color="info" className="mt-4" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 2xl:grid-cols-4">
|
||||
{pipelineColumns.map((column) => {
|
||||
const columnValue = column.deals.reduce(
|
||||
(sum, deal) => sum + Number(deal.amount || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={column.label} className="rounded-2xl bg-slate-50 p-3 ring-1 ring-slate-200 dark:bg-dark-800 dark:ring-dark-700">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-bold text-slate-900 dark:text-white">{column.label}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{column.deals.length} deals · {formatCurrency(columnValue)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{column.deals.map((deal) => {
|
||||
const isSelected = selectedDeal?.id === deal.id;
|
||||
return (
|
||||
<button
|
||||
key={deal.id}
|
||||
type="button"
|
||||
onClick={() => handleDealSelect(deal.id)}
|
||||
className={`w-full rounded-2xl border bg-white p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-teal-400 dark:bg-dark-900 ${
|
||||
isSelected
|
||||
? 'border-teal-400 ring-2 ring-teal-200'
|
||||
: 'border-slate-200 dark:border-dark-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="font-semibold text-slate-900 dark:text-white">{deal.name || 'Untitled deal'}</p>
|
||||
{deal.priority && (
|
||||
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold ${badgeStyles[deal.priority] || 'bg-slate-100 text-slate-600'}`}>
|
||||
{labelize(deal.priority)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between text-sm">
|
||||
<span className="font-bold text-slate-900 dark:text-white">{formatCurrency(deal.amount)}</span>
|
||||
<span className="text-slate-500">Close {formatDate(deal.expected_close_at)}</span>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-slate-500">
|
||||
Next follow-up: {formatDateTime(deal.next_follow_up_at)}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
|
||||
<CardBox className="border-0 shadow-sm">
|
||||
<div className="mb-4 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-slate-900 dark:text-white">Next follow-ups</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Planned calls, emails, meetings, demos, notes, and tasks.
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton href="/activities/activities-list" label="All activities" color="whiteDark" />
|
||||
</div>
|
||||
|
||||
{plannedActivities.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-slate-300 p-8 text-center text-slate-500">
|
||||
No planned follow-ups. Select a deal and schedule one to keep momentum.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100 dark:divide-dark-700">
|
||||
{plannedActivities.slice(0, 8).map((activity) => (
|
||||
<button
|
||||
key={activity.id}
|
||||
type="button"
|
||||
onClick={() => activity.deal?.id && handleDealSelect(activity.deal.id)}
|
||||
className="grid w-full gap-3 py-4 text-left transition hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-teal-400 dark:hover:bg-dark-800 md:grid-cols-[0.9fr_1.2fr_0.8fr]"
|
||||
>
|
||||
<div>
|
||||
<span className="rounded-full bg-teal-50 px-2.5 py-1 text-xs font-bold uppercase tracking-wide text-teal-700 ring-1 ring-teal-100">
|
||||
{labelize(activity.activity_type)}
|
||||
</span>
|
||||
<p className="mt-2 text-sm text-slate-500">{formatDateTime(activity.due_at || activity.scheduled_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 dark:text-white">{activity.subject || 'Untitled activity'}</p>
|
||||
<p className="mt-1 text-sm text-slate-500">{activity.deal?.name || activity.lead?.lead_name || 'No linked deal'}</p>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 md:text-right">
|
||||
Owner: {getOwnerName(activity.assigned_to)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
</div>
|
||||
|
||||
<aside className="space-y-6">
|
||||
<CardBox className="border-0 shadow-sm">
|
||||
<h3 className="text-xl font-bold text-slate-900 dark:text-white">Deal detail</h3>
|
||||
{selectedDeal ? (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="rounded-2xl bg-gradient-to-br from-teal-50 to-blue-50 p-4 ring-1 ring-teal-100 dark:from-dark-800 dark:to-dark-900 dark:ring-dark-700">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-teal-700">Selected deal</p>
|
||||
<h4 className="mt-2 text-2xl font-black text-slate-950 dark:text-white">{selectedDeal.name || 'Untitled deal'}</h4>
|
||||
<p className="mt-2 text-sm text-slate-600 dark:text-slate-400">{selectedDeal.description || 'No description added yet.'}</p>
|
||||
</div>
|
||||
<DetailRow label="Value" value={formatCurrency(selectedDeal.amount)} />
|
||||
<DetailRow label="Forecast" value={labelize(selectedDeal.forecast_category)} />
|
||||
<DetailRow label="Owner" value={getOwnerName(selectedDeal.owner)} />
|
||||
<DetailRow label="Company" value={selectedDeal.company?.name || 'No company'} />
|
||||
<DetailRow label="Contact" value={getContactName(selectedDeal.primary_contact)} />
|
||||
<DetailRow label="Expected close" value={formatDate(selectedDeal.expected_close_at)} />
|
||||
<DetailRow label="Next follow-up" value={formatDateTime(selectedDeal.next_follow_up_at)} />
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<BaseButton href={`/deals/${selectedDeal.id}`} label="Open deal" color="info" />
|
||||
<BaseButton href="/deals/deals-list" label="Deals list" color="whiteDark" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-4 text-sm text-slate-500">Select a deal to see ownership, contacts, and follow-up context.</p>
|
||||
)}
|
||||
</CardBox>
|
||||
|
||||
<CardBox className="border-0 shadow-sm">
|
||||
<h3 className="text-xl font-bold text-slate-900 dark:text-white">Schedule follow-up</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Creates an Activity and updates the deal’s next follow-up date in one workflow.
|
||||
</p>
|
||||
|
||||
{!canScheduleFollowUp && (
|
||||
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||
You need Create Activities and Update Deals permissions to use this quick action.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="mt-5 space-y-4" onSubmit={handleSubmit}>
|
||||
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||
Deal
|
||||
<select
|
||||
name="dealId"
|
||||
value={form.dealId || selectedDeal?.id || ''}
|
||||
onChange={(event) => handleDealSelect(event.target.value)}
|
||||
className="mt-2 h-11 w-full rounded-xl border border-slate-300 bg-white px-3 text-slate-900 focus:border-teal-400 focus:outline-none focus:ring-2 focus:ring-teal-200 dark:border-dark-700 dark:bg-dark-800 dark:text-white"
|
||||
>
|
||||
{deals.length === 0 && <option value="">No deals available</option>}
|
||||
{deals.map((deal) => (
|
||||
<option key={deal.id} value={deal.id}>{deal.name || 'Untitled deal'}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
|
||||
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||
Type
|
||||
<select
|
||||
name="activity_type"
|
||||
value={form.activity_type}
|
||||
onChange={handleFieldChange}
|
||||
className="mt-2 h-11 w-full rounded-xl border border-slate-300 bg-white px-3 text-slate-900 focus:border-teal-400 focus:outline-none focus:ring-2 focus:ring-teal-200 dark:border-dark-700 dark:bg-dark-800 dark:text-white"
|
||||
>
|
||||
<option value="call">Call</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="meeting">Meeting</option>
|
||||
<option value="task">Task</option>
|
||||
<option value="demo">Demo</option>
|
||||
<option value="note">Note</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||
Due
|
||||
<input
|
||||
name="due_at"
|
||||
type="datetime-local"
|
||||
value={form.due_at}
|
||||
onChange={handleFieldChange}
|
||||
className="mt-2 h-11 w-full rounded-xl border border-slate-300 bg-white px-3 text-slate-900 focus:border-teal-400 focus:outline-none focus:ring-2 focus:ring-teal-200 dark:border-dark-700 dark:bg-dark-800 dark:text-white"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||
Subject
|
||||
<input
|
||||
name="subject"
|
||||
value={form.subject}
|
||||
onChange={handleFieldChange}
|
||||
placeholder={selectedDeal ? `Follow up on ${selectedDeal.name}` : 'Follow up with prospect'}
|
||||
className="mt-2 h-11 w-full rounded-xl border border-slate-300 bg-white px-3 text-slate-900 focus:border-teal-400 focus:outline-none focus:ring-2 focus:ring-teal-200 dark:border-dark-700 dark:bg-dark-800 dark:text-white"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||
Notes
|
||||
<textarea
|
||||
name="details"
|
||||
value={form.details}
|
||||
onChange={handleFieldChange}
|
||||
placeholder="Agenda, next step, or reminder context..."
|
||||
className="mt-2 min-h-24 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-teal-400 focus:outline-none focus:ring-2 focus:ring-teal-200 dark:border-dark-700 dark:bg-dark-800 dark:text-white"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 rounded-2xl bg-slate-50 p-3 text-sm font-semibold text-slate-700 ring-1 ring-slate-200 dark:bg-dark-800 dark:text-slate-200 dark:ring-dark-700">
|
||||
<input
|
||||
name="is_reminder_enabled"
|
||||
type="checkbox"
|
||||
checked={form.is_reminder_enabled}
|
||||
onChange={handleFieldChange}
|
||||
className="h-4 w-4 rounded border-slate-300 text-teal-600 focus:ring-teal-500"
|
||||
/>
|
||||
Enable reminder 30 minutes before due time
|
||||
</label>
|
||||
|
||||
<BaseButton
|
||||
type="submit"
|
||||
label={submitting ? 'Scheduling...' : 'Schedule follow-up'}
|
||||
color="info"
|
||||
className="w-full"
|
||||
disabled={submitting || !canScheduleFollowUp || deals.length === 0}
|
||||
/>
|
||||
</form>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className="border-0 shadow-sm">
|
||||
<h3 className="text-xl font-bold text-slate-900 dark:text-white">Selected deal follow-ups</h3>
|
||||
{upcomingForSelectedDeal.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-slate-500">No planned activities for this deal yet.</p>
|
||||
) : (
|
||||
<div className="mt-3 space-y-3">
|
||||
{upcomingForSelectedDeal.slice(0, 4).map((activity) => (
|
||||
<Link
|
||||
key={activity.id}
|
||||
href={`/activities/${activity.id}`}
|
||||
className="block rounded-2xl border border-slate-200 p-3 transition hover:border-teal-300 hover:bg-teal-50 dark:border-dark-700 dark:hover:bg-dark-800"
|
||||
>
|
||||
<p className="font-semibold text-slate-900 dark:text-white">{activity.subject}</p>
|
||||
<p className="text-sm text-slate-500">{formatDateTime(activity.due_at || activity.scheduled_at)}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
</aside>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MetricCard = ({
|
||||
label,
|
||||
value,
|
||||
helper,
|
||||
tone = 'default',
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
helper: string;
|
||||
tone?: 'default' | 'danger';
|
||||
}) => (
|
||||
<CardBox className="border-0 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-500 dark:text-slate-400">{label}</p>
|
||||
<p className="mt-2 text-3xl font-black tracking-tight text-slate-950 dark:text-white">{value}</p>
|
||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">{helper}</p>
|
||||
</div>
|
||||
<div className={`h-12 w-12 rounded-2xl ${tone === 'danger' ? 'bg-rose-100' : 'bg-teal-100'}`} />
|
||||
</div>
|
||||
</CardBox>
|
||||
);
|
||||
|
||||
const DetailRow = ({ label, value }: { label: string; value: string }) => (
|
||||
<div className="flex items-start justify-between gap-4 border-b border-slate-100 pb-3 text-sm last:border-0 dark:border-dark-700">
|
||||
<span className="text-slate-500 dark:text-slate-400">{label}</span>
|
||||
<span className="max-w-[60%] text-right font-semibold text-slate-900 dark:text-white">{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
SalesCommandCenter.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated permission="READ_DEALS">{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default SalesCommandCenter;
|
||||
BIN
permissions-page-screenshot.png
Normal file
BIN
permissions-page-screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
Loading…
x
Reference in New Issue
Block a user