Autosave: 20260526-150322

This commit is contained in:
Flatlogic Bot 2026-05-26 15:03:21 +00:00
parent 26c05d93e5
commit 02741984f9
9 changed files with 928 additions and 14 deletions

View File

@ -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);

View 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;

View 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;
}
}
};

View File

@ -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'

View File

@ -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'

View File

@ -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',

View File

@ -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>

View 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 deals 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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB