From a46f271b12951a63a6f6e35814df85e385d24ba0 Mon Sep 17 00:00:00 2001 From: Elemar Rodrigues Severo Junior Date: Mon, 25 Nov 2024 15:16:44 +0100 Subject: [PATCH 1/6] Add financial sidebar items to OmniCommands Integrated financial items into OmniCommands with a new state and useEffect hook. Modified the components to map these financial items into a new command group within the UI. --- frontend/src/app/components/OmniCommands.tsx | 24 ++++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/components/OmniCommands.tsx b/frontend/src/app/components/OmniCommands.tsx index 3d1957f1..5ba02456 100644 --- a/frontend/src/app/components/OmniCommands.tsx +++ b/frontend/src/app/components/OmniCommands.tsx @@ -10,10 +10,11 @@ import { CommandItem, CommandList, } from "@/components/ui/command" -import { - getAnalyticsSidebarItems, - getAboutUsSidebarItems, - getAdministrativeSidebarItems +import { + getAnalyticsSidebarItems, + getAboutUsSidebarItems, + getAdministrativeSidebarItems, + getFinancialSidebarItems } from "@/app/navigation" import { Button } from "@/components/ui/button" import { MagnifyingGlassIcon } from "@radix-ui/react-icons" @@ -80,16 +81,19 @@ export function OmniCommands({ open, setOpen }: OmniCommandsProps) { const router = useRouter() const { data } = useQuery(GET_CONSULTANTS) const { data: session } = useSession(); + const [finantialsItems, setFinantialsItems] = React.useState>([]) const [analyticsItems, setAnalyticsItems] = React.useState>([]) const [aboutUsItems, setAboutUsItems] = React.useState>([]) const [adminItems, setAdminItems] = React.useState>([]) React.useEffect(() => { async function loadItems() { + const finantials = await getFinancialSidebarItems(session?.user?.email) const analytics = await getAnalyticsSidebarItems(session?.user?.email) const aboutUs = await getAboutUsSidebarItems() const admin = await getAdministrativeSidebarItems() + setFinantialsItems(finantials) setAnalyticsItems(analytics) setAboutUsItems(aboutUs) setAdminItems(admin) @@ -118,7 +122,17 @@ export function OmniCommands({ open, setOpen }: OmniCommandsProps) { No results found. - + + {finantialsItems.map((item) => ( + runCommand(() => router.push(item.url))} + > + + {item.title} + + ))} + {analyticsItems.map((item) => ( Date: Mon, 25 Nov 2024 15:19:27 +0100 Subject: [PATCH 2/6] Enhance financial permission check for partial email matches Previously, financial permission checks relied solely on full email matches. This update introduces a check to allow partial email matches based on the username, improving flexibility and user experience. It ensures that users with variations of their email domain are correctly identified and granted the appropriate permissions. --- frontend/src/app/permissions.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/app/permissions.ts b/frontend/src/app/permissions.ts index a96e25cb..9aed130c 100644 --- a/frontend/src/app/permissions.ts +++ b/frontend/src/app/permissions.ts @@ -15,6 +15,10 @@ export const FINANCIAL_USERS = [ switch (permission) { case 'financial': + const username = email.split('@')[0]; + if (FINANCIAL_USERS.some(financialEmail => financialEmail.startsWith(username + '@'))) { + return true; + } return FINANCIAL_USERS.includes(email as any); case 'admin': return email.endsWith('@eximia.co') || email.endsWith('@elemarjr.com'); From 292acd0502d0450816859bafde747cd62128119b Mon Sep 17 00:00:00 2001 From: Elemar Rodrigues Severo Junior Date: Mon, 25 Nov 2024 16:46:54 +0100 Subject: [PATCH 3/6] Add new fee types and refactor revenue forecast display Included consultingFee, consultingPreFee, handsOnFee, and squadFee fields to queries. Refactored client data merging and sorting logic in `RevenueForecastPage` to handle new service types. Improved table rendering to dynamically handle different service categories. --- .../app/analytics/revenue-forecast/page.tsx | 377 ++++++++---------- .../app/analytics/revenue-forecast/query.ts | 12 + 2 files changed, 174 insertions(+), 215 deletions(-) diff --git a/frontend/src/app/analytics/revenue-forecast/page.tsx b/frontend/src/app/analytics/revenue-forecast/page.tsx index 69d089c9..42d91253 100644 --- a/frontend/src/app/analytics/revenue-forecast/page.tsx +++ b/frontend/src/app/analytics/revenue-forecast/page.tsx @@ -19,10 +19,15 @@ import Link from "next/link"; export default function RevenueForecastPage() { const [date, setDate] = useState(new Date()); const [showPartialPreviousMonth, setShowPartialPreviousMonth] = useState(false); - const [sortConfig, setSortConfig] = useState<{ + const [sortConfigs, setSortConfigs] = useState({ key: 'current.total', direction: 'desc' }); + }>>({ + consulting: { key: 'current', direction: 'desc' }, + consultingPre: { key: 'current', direction: 'desc' }, + handsOn: { key: 'current', direction: 'desc' }, + squad: { key: 'current', direction: 'desc' } + }); useEffect(() => { const today = new Date(); @@ -31,7 +36,6 @@ export default function RevenueForecastPage() { const previousMonthDate = endOfMonth(subMonths(date, 1)); - // Calculate the partial previous month date const getPreviousMonthPartialDate = () => { const previousMonth = subMonths(date, 1); const currentDay = date.getDate(); @@ -53,51 +57,61 @@ export default function RevenueForecastPage() { if (loading) return
Loading...
; if (error) return
Error: {error.message}
; - // Merge client data from both queries - const clients = new Map(); + // Process data for each service type + const processServiceData = (type: 'regular' | 'preContracted' | 'consultingFee' | 'consultingPreFee' | 'handsOnFee' | 'squadFee') => { + const clients = new Map(); - const previousData = showPartialPreviousMonth ? data.previous_month_partial : data.previous_month; + data.previous_month.summaries.byClient.forEach((client: any) => { + if (client[type]) { + clients.set(client.slug, { + name: client.name, + slug: client.slug, + previousFull: client[type], + previousPartial: 0, + current: 0 + }); + } + }); - previousData.summaries.byClient.forEach((client: any) => { - clients.set(client.slug, { - name: client.name, - previous: { - regular: client.regular, - preContracted: client.preContracted, - total: client.total, - }, - current: { - regular: 0, - preContracted: 0, - total: 0, - }, + data.previous_month_partial.summaries.byClient.forEach((client: any) => { + if (client[type]) { + if (clients.has(client.slug)) { + clients.get(client.slug).previousPartial = client[type]; + } else { + clients.set(client.slug, { + name: client.name, + slug: client.slug, + previousFull: 0, + previousPartial: client[type], + current: 0 + }); + } + } }); - }); - data.in_analysis.summaries.byClient.forEach((client: any) => { - if (clients.has(client.slug)) { - const existingClient = clients.get(client.slug); - existingClient.current = { - regular: client.regular, - preContracted: client.preContracted, - total: client.total, - }; - } else { - clients.set(client.slug, { - name: client.name, - previous: { - regular: 0, - preContracted: 0, - total: 0, - }, - current: { - regular: client.regular, - preContracted: client.preContracted, - total: client.total, - }, - }); - } - }); + data.in_analysis.summaries.byClient.forEach((client: any) => { + if (client[type]) { + if (clients.has(client.slug)) { + clients.get(client.slug).current = client[type]; + } else { + clients.set(client.slug, { + name: client.name, + slug: client.slug, + previousFull: 0, + previousPartial: 0, + current: client[type] + }); + } + } + }); + + return clients; + }; + + const consultingClients = processServiceData('consultingFee'); + const consultingPreClients = processServiceData('consultingPreFee'); + const handsOnClients = processServiceData('handsOnFee'); + const squadClients = processServiceData('squadFee'); const formatCurrency = (value: number) => { return new Intl.NumberFormat("en-US", { @@ -107,50 +121,26 @@ export default function RevenueForecastPage() { }).format(value); }; - // Calculate totals - const totals = Array.from(clients.values()).reduce((acc, client) => ({ - previous: { - regular: acc.previous.regular + client.previous.regular, - preContracted: acc.previous.preContracted + client.previous.preContracted, - total: acc.previous.total + client.previous.total, - }, - current: { - regular: acc.current.regular + client.current.regular, - preContracted: acc.current.preContracted + client.current.preContracted, - total: acc.current.total + client.current.total, - } - }), { - previous: { regular: 0, preContracted: 0, total: 0 }, - current: { regular: 0, preContracted: 0, total: 0 } - }); - - const requestSort = (key: string) => { - let direction: 'asc' | 'desc' = 'desc'; - if (sortConfig.key === key && sortConfig.direction === 'desc') { - direction = 'asc'; - } - setSortConfig({ key, direction }); + const requestSort = (key: string, tableId: string) => { + setSortConfigs(prevConfigs => { + const newConfigs = { ...prevConfigs }; + let direction: 'asc' | 'desc' = 'desc'; + if (newConfigs[tableId].key === key && newConfigs[tableId].direction === 'desc') { + direction = 'asc'; + } + newConfigs[tableId] = { key, direction }; + return newConfigs; + }); }; - const getSortedClients = () => { + const getSortedClients = (clients: Map, tableId: string) => { const clientsArray = Array.from(clients.values()); - if (!sortConfig.key) return clientsArray; + const sortConfig = sortConfigs[tableId]; + if (!sortConfig?.key) return clientsArray; return clientsArray.sort((a, b) => { - let aValue = 0; - let bValue = 0; - - // Extract values based on sort key - if (sortConfig.key.includes('.')) { - const [period, type] = sortConfig.key.split('.'); - if (period === 'diff') { - aValue = a.current[type] - a.previous[type]; - bValue = b.current[type] - b.previous[type]; - } else { - aValue = a[period][type]; - bValue = b[period][type]; - } - } + const aValue = a[sortConfig.key]; + const bValue = b[sortConfig.key]; if (aValue < bValue) { return sortConfig.direction === 'asc' ? -1 : 1; @@ -162,154 +152,96 @@ export default function RevenueForecastPage() { }); }; - const sortedClients = getSortedClients(); + const renderTable = (title: string, clients: Map, tableId: string) => { + const sortedClients = getSortedClients(clients, tableId); + const sortConfig = sortConfigs[tableId]; + const total = sortedClients.reduce((acc, client) => ({ + previousFull: acc.previousFull + client.previousFull, + previousPartial: acc.previousPartial + client.previousPartial, + current: acc.current + client.current + }), { previousFull: 0, previousPartial: 0, current: 0 }); - return ( -
-
- -
-
- -
+ return ( +
+

{title}

- # - Client - -
- {format(previousMonthDate, 'MMMM yyyy')} -
- setShowPartialPreviousMonth(checked as boolean)} - /> - -
-
+ # + Client + + {format(previousMonthDate, 'MMM yyyy')} + + requestSort('current', tableId)} className="text-right cursor-pointer hover:bg-gray-100 w-[120px]"> + {format(date, "MMM yyyy")} {sortConfig.key === 'current' && (sortConfig.direction === 'asc' ? '↑' : '↓')} - {format(date, "MMMM yyyy 'until' EEEE, dd")} - Difference + Difference
- requestSort('previous.regular')} className="text-right w-[100px] relative border-l border-gray-300 cursor-pointer hover:bg-gray-100">Regular {sortConfig.key === 'previous.regular' && (sortConfig.direction === 'asc' ? '↑' : '↓')} - requestSort('previous.preContracted')} className="text-right w-[100px] relative border-l border-gray-100 cursor-pointer hover:bg-gray-100">Pre {sortConfig.key === 'previous.preContracted' && (sortConfig.direction === 'asc' ? '↑' : '↓')} - requestSort('previous.total')} className="text-right w-[100px] relative font-bold border-l border-gray-100 cursor-pointer hover:bg-gray-100">Total {sortConfig.key === 'previous.total' && (sortConfig.direction === 'asc' ? '↑' : '↓')} - requestSort('current.regular')} className="text-right w-[100px] relative border-l border-gray-300 cursor-pointer hover:bg-gray-100">Regular {sortConfig.key === 'current.regular' && (sortConfig.direction === 'asc' ? '↑' : '↓')} - requestSort('current.preContracted')} className="text-right w-[100px] relative border-l border-gray-100 cursor-pointer hover:bg-gray-100">Pre {sortConfig.key === 'current.preContracted' && (sortConfig.direction === 'asc' ? '↑' : '↓')} - requestSort('current.total')} className="text-right w-[100px] relative font-bold border-l border-gray-100 cursor-pointer hover:bg-gray-100">Total {sortConfig.key === 'current.total' && (sortConfig.direction === 'asc' ? '↑' : '↓')} - requestSort('diff.regular')} className="text-right w-[100px] relative border-l border-gray-300 cursor-pointer hover:bg-gray-100">Regular {sortConfig.key === 'diff.regular' && (sortConfig.direction === 'asc' ? '↑' : '↓')} - requestSort('diff.preContracted')} className="text-right w-[100px] relative border-l border-gray-100 cursor-pointer hover:bg-gray-100">Pre {sortConfig.key === 'diff.preContracted' && (sortConfig.direction === 'asc' ? '↑' : '↓')} - requestSort('diff.total')} className="text-right w-[100px] relative font-bold border-l border-gray-100 cursor-pointer hover:bg-gray-100">Total {sortConfig.key === 'diff.total' && (sortConfig.direction === 'asc' ? '↑' : '↓')} + + + Until {format(previousMonthPartialDate, "dd")} + Full Month + +
- {sortedClients.map((client: any, index: number) => ( - - {index + 1} - - {client.slug ? ( - - {client.name} - - ) : ( - {client.name} - )} - - client.current.regular ? 'bg-red-50' : ''}`}>{formatCurrency(client.previous.regular)} - client.current.preContracted ? 'bg-red-50' : ''}`}> - {formatCurrency(client.previous.preContracted)} - - client.current.total ? 'bg-red-50' : ''}`}>{formatCurrency(client.previous.total)} - client.previous.regular ? 'bg-green-50' : ''}`}>{formatCurrency(client.current.regular)} - client.previous.preContracted ? 'bg-green-50' : ''}`}> - {formatCurrency(client.current.preContracted)} - - client.previous.total ? 'bg-green-50' : ''}`}>{formatCurrency(client.current.total)} - - {formatCurrency(client.current.regular - client.previous.regular)} - {client.current.regular - client.previous.regular !== 0 && ( -
client.previous.regular ? 'text-green-600' : 'text-red-600'}`}> - {new Intl.NumberFormat('en-US', { - style: 'percent', - minimumFractionDigits: 1, - maximumFractionDigits: 1, - }).format((client.current.regular - client.previous.regular) / client.previous.regular)} -
- )} -
- - {formatCurrency(client.current.preContracted - client.previous.preContracted)} - {client.current.preContracted - client.previous.preContracted !== 0 && ( -
client.previous.preContracted ? 'text-green-600' : 'text-red-600'}`}> - {new Intl.NumberFormat('en-US', { - style: 'percent', - minimumFractionDigits: 1, - maximumFractionDigits: 1, - }).format((client.current.preContracted - client.previous.preContracted) / client.previous.preContracted)} -
- )} -
- - {formatCurrency(client.current.total - client.previous.total)} - {client.current.total - client.previous.total !== 0 && ( -
client.previous.total ? 'text-green-600' : 'text-red-600'}`}> - {new Intl.NumberFormat('en-US', { - style: 'percent', - minimumFractionDigits: 1, - maximumFractionDigits: 1, - }).format((client.current.total - client.previous.total) / client.previous.total)} -
- )} -
-
- ))} - + {sortedClients.map((client: any, index: number) => { + const diff = client.current - client.previousFull; + const percentChange = client.previousFull !== 0 ? diff / client.previousFull : 0; + + return ( + + {index + 1} + + {client.slug ? ( + + {client.name} + + ) : ( + {client.name} + )} + + + {formatCurrency(client.previousPartial)} + + + {formatCurrency(client.previousFull)} + + + {formatCurrency(client.current)} + + + {formatCurrency(diff)} + {diff !== 0 && ( +
0 ? 'text-green-600' : 'text-red-600'}`}> + {new Intl.NumberFormat('en-US', { + style: 'percent', + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }).format(percentChange)} +
+ )} +
+
+ ); + })} + Total - totals.current.regular ? 'bg-red-50' : ''}`}>{formatCurrency(totals.previous.regular)} - totals.current.preContracted ? 'bg-red-50' : ''}`}>{formatCurrency(totals.previous.preContracted)} - totals.current.total ? 'bg-red-50' : ''}`}>{formatCurrency(totals.previous.total)} - totals.previous.regular ? 'bg-green-50' : ''}`}>{formatCurrency(totals.current.regular)} - totals.previous.preContracted ? 'bg-green-50' : ''}`}>{formatCurrency(totals.current.preContracted)} - totals.previous.total ? 'bg-green-50' : ''}`}>{formatCurrency(totals.current.total)} - - {formatCurrency(totals.current.regular - totals.previous.regular)} - {totals.current.regular - totals.previous.regular !== 0 && ( -
totals.previous.regular ? 'text-green-600' : 'text-red-600'}`}> - {new Intl.NumberFormat('en-US', { - style: 'percent', - minimumFractionDigits: 1, - maximumFractionDigits: 1, - }).format((totals.current.regular - totals.previous.regular) / totals.previous.regular)} -
- )} -
- - {formatCurrency(totals.current.preContracted - totals.previous.preContracted)} - {totals.current.preContracted - totals.previous.preContracted !== 0 && ( -
totals.previous.preContracted ? 'text-green-600' : 'text-red-600'}`}> - {new Intl.NumberFormat('en-US', { - style: 'percent', - minimumFractionDigits: 1, - maximumFractionDigits: 1, - }).format((totals.current.preContracted - totals.previous.preContracted) / totals.previous.preContracted)} -
- )} -
- - {formatCurrency(totals.current.total - totals.previous.total)} - {totals.current.total - totals.previous.total !== 0 && ( -
totals.previous.total ? 'text-green-600' : 'text-red-600'}`}> + {formatCurrency(total.previousPartial)} + {formatCurrency(total.previousFull)} + {formatCurrency(total.current)} + + {formatCurrency(total.current - total.previousFull)} + {total.previousFull !== 0 && ( +
total.previousFull ? 'text-green-600' : 'text-red-600'}`}> {new Intl.NumberFormat('en-US', { style: 'percent', minimumFractionDigits: 1, maximumFractionDigits: 1, - }).format((totals.current.total - totals.previous.total) / totals.previous.total)} + }).format((total.current - total.previousFull) / total.previousFull)}
)}
@@ -317,6 +249,21 @@ export default function RevenueForecastPage() {
+ ); + }; + + return ( +
+
+ +
+ +
+ {renderTable('Consulting', consultingClients, 'consulting')} + {renderTable('Consulting Pre', consultingPreClients, 'consultingPre')} + {renderTable('Hands On', handsOnClients, 'handsOn')} + {renderTable('Squad', squadClients, 'squad')} +
); } diff --git a/frontend/src/app/analytics/revenue-forecast/query.ts b/frontend/src/app/analytics/revenue-forecast/query.ts index 84ab23d8..caffa98d 100644 --- a/frontend/src/app/analytics/revenue-forecast/query.ts +++ b/frontend/src/app/analytics/revenue-forecast/query.ts @@ -9,6 +9,10 @@ export const REVENUE_FORECAST_QUERY = gql` slug regular preContracted + consultingFee + consultingPreFee + handsOnFee + squadFee total } } @@ -21,6 +25,10 @@ export const REVENUE_FORECAST_QUERY = gql` slug regular preContracted + consultingFee + consultingPreFee + handsOnFee + squadFee total } } @@ -35,6 +43,10 @@ export const REVENUE_FORECAST_QUERY = gql` slug regular preContracted + consultingFee + consultingPreFee + handsOnFee + squadFee total } } From 5563e9e3752ee82ce70e0d6b6f775b5aac38a97a Mon Sep 17 00:00:00 2001 From: Elemar Rodrigues Severo Junior Date: Mon, 25 Nov 2024 17:24:32 +0100 Subject: [PATCH 4/6] Add detailed revenue tracking for the past three months Extended the revenue forecast query to include data from two and three months ago. Updated the page to process, display, and calculate projected and expected revenue based on these additions. Enhanced the UI to show new metrics and reflect these changes accurately. --- .../app/analytics/revenue-forecast/page.tsx | 305 ++++++++++++++---- .../app/analytics/revenue-forecast/query.ts | 66 +++- 2 files changed, 303 insertions(+), 68 deletions(-) diff --git a/frontend/src/app/analytics/revenue-forecast/page.tsx b/frontend/src/app/analytics/revenue-forecast/page.tsx index 42d91253..93e486a3 100644 --- a/frontend/src/app/analytics/revenue-forecast/page.tsx +++ b/frontend/src/app/analytics/revenue-forecast/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useQuery } from "@apollo/client"; -import { format, endOfMonth, subMonths, isSameDay, getDaysInMonth } from "date-fns"; +import { format, endOfMonth, subMonths, isSameDay, getDaysInMonth, getDate } from "date-fns"; import { useState, useEffect } from "react"; import { DatePicker } from "@/components/DatePicker"; import { REVENUE_FORECAST_QUERY } from "./query"; @@ -35,6 +35,8 @@ export default function RevenueForecastPage() { }, []); const previousMonthDate = endOfMonth(subMonths(date, 1)); + const twoMonthsAgoDate = endOfMonth(subMonths(date, 2)); + const threeMonthsAgoDate = endOfMonth(subMonths(date, 3)); const getPreviousMonthPartialDate = () => { const previousMonth = subMonths(date, 1); @@ -44,13 +46,35 @@ export default function RevenueForecastPage() { return new Date(previousMonth.getFullYear(), previousMonth.getMonth(), targetDay); }; + const getTwoMonthsAgoPartialDate = () => { + const twoMonthsAgo = subMonths(date, 2); + const currentDay = date.getDate(); + const daysInTwoMonthsAgo = getDaysInMonth(twoMonthsAgo); + const targetDay = Math.min(currentDay, daysInTwoMonthsAgo); + return new Date(twoMonthsAgo.getFullYear(), twoMonthsAgo.getMonth(), targetDay); + }; + + const getThreeMonthsAgoPartialDate = () => { + const threeMonthsAgo = subMonths(date, 3); + const currentDay = date.getDate(); + const daysInThreeMonthsAgo = getDaysInMonth(threeMonthsAgo); + const targetDay = Math.min(currentDay, daysInThreeMonthsAgo); + return new Date(threeMonthsAgo.getFullYear(), threeMonthsAgo.getMonth(), targetDay); + }; + const previousMonthPartialDate = getPreviousMonthPartialDate(); + const twoMonthsAgoPartialDate = getTwoMonthsAgoPartialDate(); + const threeMonthsAgoPartialDate = getThreeMonthsAgoPartialDate(); const { loading, error, data } = useQuery(REVENUE_FORECAST_QUERY, { variables: { inAnalysisDate: format(date, "yyyy-MM-dd"), previousMonthDate: format(previousMonthDate, "yyyy-MM-dd"), previousMonthPartialDate: format(previousMonthPartialDate, "yyyy-MM-dd"), + twoMonthsAgoDate: format(twoMonthsAgoDate, "yyyy-MM-dd"), + twoMonthsAgoPartialDate: format(twoMonthsAgoPartialDate, "yyyy-MM-dd"), + threeMonthsAgoDate: format(threeMonthsAgoDate, "yyyy-MM-dd"), + threeMonthsAgoPartialDate: format(threeMonthsAgoPartialDate, "yyyy-MM-dd"), }, }); @@ -61,18 +85,112 @@ export default function RevenueForecastPage() { const processServiceData = (type: 'regular' | 'preContracted' | 'consultingFee' | 'consultingPreFee' | 'handsOnFee' | 'squadFee') => { const clients = new Map(); - data.previous_month.summaries.byClient.forEach((client: any) => { + data.three_months_ago.summaries.byClient.forEach((client: any) => { if (client[type]) { clients.set(client.slug, { name: client.name, slug: client.slug, - previousFull: client[type], + threeMonthsAgoFull: client[type], + threeMonthsAgoPartial: 0, + twoMonthsAgoFull: 0, + twoMonthsAgoPartial: 0, + previousFull: 0, previousPartial: 0, - current: 0 + current: 0, + projected: 0, + expected: 0 }); } }); + data.three_months_ago_partial.summaries.byClient.forEach((client: any) => { + if (client[type]) { + if (clients.has(client.slug)) { + clients.get(client.slug).threeMonthsAgoPartial = client[type]; + } else { + clients.set(client.slug, { + name: client.name, + slug: client.slug, + threeMonthsAgoFull: 0, + threeMonthsAgoPartial: client[type], + twoMonthsAgoFull: 0, + twoMonthsAgoPartial: 0, + previousFull: 0, + previousPartial: 0, + current: 0, + projected: 0, + expected: 0 + }); + } + } + }); + + data.two_months_ago.summaries.byClient.forEach((client: any) => { + if (client[type]) { + if (clients.has(client.slug)) { + clients.get(client.slug).twoMonthsAgoFull = client[type]; + } else { + clients.set(client.slug, { + name: client.name, + slug: client.slug, + threeMonthsAgoFull: 0, + threeMonthsAgoPartial: 0, + twoMonthsAgoFull: client[type], + twoMonthsAgoPartial: 0, + previousFull: 0, + previousPartial: 0, + current: 0, + projected: 0, + expected: 0 + }); + } + } + }); + + data.two_months_ago_partial.summaries.byClient.forEach((client: any) => { + if (client[type]) { + if (clients.has(client.slug)) { + clients.get(client.slug).twoMonthsAgoPartial = client[type]; + } else { + clients.set(client.slug, { + name: client.name, + slug: client.slug, + threeMonthsAgoFull: 0, + threeMonthsAgoPartial: 0, + twoMonthsAgoFull: 0, + twoMonthsAgoPartial: client[type], + previousFull: 0, + previousPartial: 0, + current: 0, + projected: 0, + expected: 0 + }); + } + } + }); + + data.previous_month.summaries.byClient.forEach((client: any) => { + if (client[type]) { + if (clients.has(client.slug)) { + clients.get(client.slug).previousFull = client[type]; + } else { + clients.set(client.slug, { + name: client.name, + slug: client.slug, + threeMonthsAgoFull: 0, + threeMonthsAgoPartial: 0, + twoMonthsAgoFull: 0, + twoMonthsAgoPartial: 0, + previousFull: client[type], + previousPartial: 0, + current: 0, + projected: 0, + expected: 0 + }); + } + } + }); + data.previous_month_partial.summaries.byClient.forEach((client: any) => { if (client[type]) { if (clients.has(client.slug)) { @@ -81,9 +199,15 @@ export default function RevenueForecastPage() { clients.set(client.slug, { name: client.name, slug: client.slug, + threeMonthsAgoFull: 0, + threeMonthsAgoPartial: 0, + twoMonthsAgoFull: 0, + twoMonthsAgoPartial: 0, previousFull: 0, previousPartial: client[type], - current: 0 + current: 0, + projected: 0, + expected: 0 }); } } @@ -92,14 +216,46 @@ export default function RevenueForecastPage() { data.in_analysis.summaries.byClient.forEach((client: any) => { if (client[type]) { if (clients.has(client.slug)) { - clients.get(client.slug).current = client[type]; + const currentValue = client[type]; + const clientData = clients.get(client.slug); + clientData.current = currentValue; + + // Calculate projected value based on current day of month + const currentDay = getDate(date); + const daysInMonth = getDaysInMonth(date); + const projectedValue = (currentValue / currentDay) * daysInMonth; + clientData.projected = projectedValue; + + // Calculate expected value (60% previous month + 25% two months ago + 15% three months ago) + const previousValue = clientData.previousFull; + const twoMonthsAgoValue = clientData.twoMonthsAgoFull; + const threeMonthsAgoValue = clientData.threeMonthsAgoFull; + + if (threeMonthsAgoValue === 0 && twoMonthsAgoValue === 0) { + clientData.expected = previousValue; + } else if (threeMonthsAgoValue === 0) { + clientData.expected = (previousValue * 0.8) + (twoMonthsAgoValue * 0.2); + } else { + clientData.expected = (previousValue * 0.6) + (twoMonthsAgoValue * 0.25) + (threeMonthsAgoValue * 0.15); + } } else { + const currentValue = client[type]; + const currentDay = getDate(date); + const daysInMonth = getDaysInMonth(date); + const projectedValue = (currentValue / currentDay) * daysInMonth; + clients.set(client.slug, { name: client.name, slug: client.slug, + threeMonthsAgoFull: 0, + threeMonthsAgoPartial: 0, + twoMonthsAgoFull: 0, + twoMonthsAgoPartial: 0, previousFull: 0, previousPartial: 0, - current: client[type] + current: currentValue, + projected: projectedValue, + expected: 0 }); } } @@ -156,10 +312,16 @@ export default function RevenueForecastPage() { const sortedClients = getSortedClients(clients, tableId); const sortConfig = sortConfigs[tableId]; const total = sortedClients.reduce((acc, client) => ({ + threeMonthsAgoFull: acc.threeMonthsAgoFull + client.threeMonthsAgoFull, + threeMonthsAgoPartial: acc.threeMonthsAgoPartial + client.threeMonthsAgoPartial, + twoMonthsAgoFull: acc.twoMonthsAgoFull + client.twoMonthsAgoFull, + twoMonthsAgoPartial: acc.twoMonthsAgoPartial + client.twoMonthsAgoPartial, previousFull: acc.previousFull + client.previousFull, previousPartial: acc.previousPartial + client.previousPartial, - current: acc.current + client.current - }), { previousFull: 0, previousPartial: 0, current: 0 }); + current: acc.current + client.current, + projected: acc.projected + client.projected, + expected: acc.expected + client.expected + }), { threeMonthsAgoFull: 0, threeMonthsAgoPartial: 0, twoMonthsAgoFull: 0, twoMonthsAgoPartial: 0, previousFull: 0, previousPartial: 0, current: 0, projected: 0, expected: 0 }); return (
@@ -169,82 +331,91 @@ export default function RevenueForecastPage() { # Client + + {format(threeMonthsAgoDate, 'MMM yyyy')} + + + {format(twoMonthsAgoDate, 'MMM yyyy')} + {format(previousMonthDate, 'MMM yyyy')} - requestSort('current', tableId)} className="text-right cursor-pointer hover:bg-gray-100 w-[120px]"> - {format(date, "MMM yyyy")} {sortConfig.key === 'current' && (sortConfig.direction === 'asc' ? '↑' : '↓')} + + {format(date, "MMM yyyy")} - Difference + Until {format(threeMonthsAgoPartialDate, "dd")} + Full Month + Until {format(twoMonthsAgoPartialDate, "dd")} + Full Month Until {format(previousMonthPartialDate, "dd")} Full Month - - + requestSort('current', tableId)} className="text-right cursor-pointer hover:bg-gray-100 w-[120px] border-x"> + Realized {sortConfig.key === 'current' && (sortConfig.direction === 'asc' ? '↑' : '↓')} + + requestSort('projected', tableId)} className="text-right cursor-pointer hover:bg-gray-100 w-[120px] border-x"> + Projected {sortConfig.key === 'projected' && (sortConfig.direction === 'asc' ? '↑' : '↓')} + + Expected - {sortedClients.map((client: any, index: number) => { - const diff = client.current - client.previousFull; - const percentChange = client.previousFull !== 0 ? diff / client.previousFull : 0; - - return ( - - {index + 1} - - {client.slug ? ( - - {client.name} - - ) : ( - {client.name} - )} - - - {formatCurrency(client.previousPartial)} - - - {formatCurrency(client.previousFull)} - - - {formatCurrency(client.current)} - - - {formatCurrency(diff)} - {diff !== 0 && ( -
0 ? 'text-green-600' : 'text-red-600'}`}> - {new Intl.NumberFormat('en-US', { - style: 'percent', - minimumFractionDigits: 1, - maximumFractionDigits: 1, - }).format(percentChange)} -
- )} -
-
- ); - })} + {sortedClients.map((client: any, index: number) => ( + + {index + 1} + + {client.slug ? ( + + {client.name} + + ) : ( + {client.name} + )} + + + {formatCurrency(client.threeMonthsAgoPartial)} + + + {formatCurrency(client.threeMonthsAgoFull)} + + + {formatCurrency(client.twoMonthsAgoPartial)} + + + {formatCurrency(client.twoMonthsAgoFull)} + + + {formatCurrency(client.previousPartial)} + + + {formatCurrency(client.previousFull)} + + + {formatCurrency(client.current)} + + + {formatCurrency(client.projected)} + + + {formatCurrency(client.expected)} + + + ))} Total + {formatCurrency(total.threeMonthsAgoPartial)} + {formatCurrency(total.threeMonthsAgoFull)} + {formatCurrency(total.twoMonthsAgoPartial)} + {formatCurrency(total.twoMonthsAgoFull)} {formatCurrency(total.previousPartial)} {formatCurrency(total.previousFull)} - {formatCurrency(total.current)} - - {formatCurrency(total.current - total.previousFull)} - {total.previousFull !== 0 && ( -
total.previousFull ? 'text-green-600' : 'text-red-600'}`}> - {new Intl.NumberFormat('en-US', { - style: 'percent', - minimumFractionDigits: 1, - maximumFractionDigits: 1, - }).format((total.current - total.previousFull) / total.previousFull)} -
- )} -
+ {formatCurrency(total.current)} + {formatCurrency(total.projected)} + {formatCurrency(total.expected)}
diff --git a/frontend/src/app/analytics/revenue-forecast/query.ts b/frontend/src/app/analytics/revenue-forecast/query.ts index caffa98d..e47466a7 100644 --- a/frontend/src/app/analytics/revenue-forecast/query.ts +++ b/frontend/src/app/analytics/revenue-forecast/query.ts @@ -1,7 +1,7 @@ import { gql } from "@apollo/client"; export const REVENUE_FORECAST_QUERY = gql` - query RevenueForecast($inAnalysisDate: Date!, $previousMonthDate: Date!, $previousMonthPartialDate: Date!) { + query RevenueForecast($inAnalysisDate: Date!, $previousMonthDate: Date!, $previousMonthPartialDate: Date!, $twoMonthsAgoDate: Date!, $twoMonthsAgoPartialDate: Date!, $threeMonthsAgoDate: Date!, $threeMonthsAgoPartialDate: Date!) { in_analysis: revenueTracking(dateOfInterest: $inAnalysisDate) { summaries { byClient { @@ -51,5 +51,69 @@ export const REVENUE_FORECAST_QUERY = gql` } } } + + two_months_ago: revenueTracking(dateOfInterest: $twoMonthsAgoDate) { + summaries { + byClient { + name + slug + regular + preContracted + consultingFee + consultingPreFee + handsOnFee + squadFee + total + } + } + } + + two_months_ago_partial: revenueTracking(dateOfInterest: $twoMonthsAgoPartialDate) { + summaries { + byClient { + name + slug + regular + preContracted + consultingFee + consultingPreFee + handsOnFee + squadFee + total + } + } + } + + three_months_ago: revenueTracking(dateOfInterest: $threeMonthsAgoDate) { + summaries { + byClient { + name + slug + regular + preContracted + consultingFee + consultingPreFee + handsOnFee + squadFee + total + } + } + } + + three_months_ago_partial: revenueTracking(dateOfInterest: $threeMonthsAgoPartialDate) { + summaries { + byClient { + name + slug + regular + preContracted + consultingFee + consultingPreFee + handsOnFee + squadFee + total + } + } + } } `; From 8414e466b2819b30c19847779a5845342858cd1a Mon Sep 17 00:00:00 2001 From: Elemar Rodrigues Severo Junior Date: Mon, 25 Nov 2024 23:01:40 +0100 Subject: [PATCH 5/6] Refactor table rendering functions and adjust table styling Renamed the consulting table rendering function to `renderConsultingTable` to distinguish it from other tables. Added new `renderOtherTable` function for non-consulting tables. Adjusted the width and text styles for better visual consistency across all tables. --- .../app/analytics/revenue-forecast/page.tsx | 119 ++++++++++++++---- 1 file changed, 96 insertions(+), 23 deletions(-) diff --git a/frontend/src/app/analytics/revenue-forecast/page.tsx b/frontend/src/app/analytics/revenue-forecast/page.tsx index 93e486a3..439ba174 100644 --- a/frontend/src/app/analytics/revenue-forecast/page.tsx +++ b/frontend/src/app/analytics/revenue-forecast/page.tsx @@ -308,7 +308,7 @@ export default function RevenueForecastPage() { }); }; - const renderTable = (title: string, clients: Map, tableId: string) => { + const renderConsultingTable = (title: string, clients: Map, tableId: string) => { const sortedClients = getSortedClients(clients, tableId); const sortConfig = sortConfigs[tableId]; const total = sortedClients.reduce((acc, client) => ({ @@ -347,12 +347,12 @@ export default function RevenueForecastPage() { - Until {format(threeMonthsAgoPartialDate, "dd")} - Full Month - Until {format(twoMonthsAgoPartialDate, "dd")} - Full Month - Until {format(previousMonthPartialDate, "dd")} - Full Month + Until {format(threeMonthsAgoPartialDate, "dd")} + Full Month + Until {format(twoMonthsAgoPartialDate, "dd")} + Full Month + Until {format(previousMonthPartialDate, "dd")} + Full Month requestSort('current', tableId)} className="text-right cursor-pointer hover:bg-gray-100 w-[120px] border-x"> Realized {sortConfig.key === 'current' && (sortConfig.direction === 'asc' ? '↑' : '↓')} @@ -375,22 +375,22 @@ export default function RevenueForecastPage() { {client.name} )} - + {formatCurrency(client.threeMonthsAgoPartial)} - + {formatCurrency(client.threeMonthsAgoFull)} - + {formatCurrency(client.twoMonthsAgoPartial)} - + {formatCurrency(client.twoMonthsAgoFull)} - + {formatCurrency(client.previousPartial)} - + {formatCurrency(client.previousFull)} @@ -407,12 +407,12 @@ export default function RevenueForecastPage() { Total - {formatCurrency(total.threeMonthsAgoPartial)} - {formatCurrency(total.threeMonthsAgoFull)} - {formatCurrency(total.twoMonthsAgoPartial)} - {formatCurrency(total.twoMonthsAgoFull)} - {formatCurrency(total.previousPartial)} - {formatCurrency(total.previousFull)} + {formatCurrency(total.threeMonthsAgoPartial)} + {formatCurrency(total.threeMonthsAgoFull)} + {formatCurrency(total.twoMonthsAgoPartial)} + {formatCurrency(total.twoMonthsAgoFull)} + {formatCurrency(total.previousPartial)} + {formatCurrency(total.previousFull)} {formatCurrency(total.current)} {formatCurrency(total.projected)} {formatCurrency(total.expected)} @@ -423,6 +423,79 @@ export default function RevenueForecastPage() { ); }; + const renderOtherTable = (title: string, clients: Map, tableId: string) => { + const sortedClients = getSortedClients(clients, tableId); + const sortConfig = sortConfigs[tableId]; + const total = sortedClients.reduce((acc, client) => ({ + threeMonthsAgoFull: acc.threeMonthsAgoFull + client.threeMonthsAgoFull, + twoMonthsAgoFull: acc.twoMonthsAgoFull + client.twoMonthsAgoFull, + previousFull: acc.previousFull + client.previousFull, + current: acc.current + client.current + }), { threeMonthsAgoFull: 0, twoMonthsAgoFull: 0, previousFull: 0, current: 0 }); + + return ( +
+

{title}

+ + + + # + Client + + {format(threeMonthsAgoDate, 'MMM yyyy')} + + + {format(twoMonthsAgoDate, 'MMM yyyy')} + + + {format(previousMonthDate, 'MMM yyyy')} + + + {format(date, "MMM yyyy")} + + + + + {sortedClients.map((client: any, index: number) => ( + + {index + 1} + + {client.slug ? ( + + {client.name} + + ) : ( + {client.name} + )} + + + {formatCurrency(client.threeMonthsAgoFull)} + + + {formatCurrency(client.twoMonthsAgoFull)} + + + {formatCurrency(client.previousFull)} + + + {formatCurrency(client.current)} + + + ))} + + + Total + {formatCurrency(total.threeMonthsAgoFull)} + {formatCurrency(total.twoMonthsAgoFull)} + {formatCurrency(total.previousFull)} + {formatCurrency(total.current)} + + +
+
+ ); + }; + return (
@@ -430,10 +503,10 @@ export default function RevenueForecastPage() {
- {renderTable('Consulting', consultingClients, 'consulting')} - {renderTable('Consulting Pre', consultingPreClients, 'consultingPre')} - {renderTable('Hands On', handsOnClients, 'handsOn')} - {renderTable('Squad', squadClients, 'squad')} + {renderConsultingTable('Consulting', consultingClients, 'consulting')} + {renderOtherTable('Consulting Pre', consultingPreClients, 'consultingPre')} + {renderOtherTable('Hands On', handsOnClients, 'handsOn')} + {renderOtherTable('Squad', squadClients, 'squad')}
); From 0eaaf0481895b42baa9bde19f53c12e5c0580d10 Mon Sep 17 00:00:00 2001 From: Elemar Rodrigues Severo Junior Date: Mon, 25 Nov 2024 23:46:29 +0100 Subject: [PATCH 6/6] Format code and improve revenue forecast table. Refactored the code for better readability and consistency by reformatting imports and function definitions. Enhanced the revenue forecast tables with better sorting functionality and added percentage displays for individual client contributions. --- .../app/analytics/revenue-forecast/page.tsx | 664 +++++++++++++----- 1 file changed, 498 insertions(+), 166 deletions(-) diff --git a/frontend/src/app/analytics/revenue-forecast/page.tsx b/frontend/src/app/analytics/revenue-forecast/page.tsx index 439ba174..a24bacff 100644 --- a/frontend/src/app/analytics/revenue-forecast/page.tsx +++ b/frontend/src/app/analytics/revenue-forecast/page.tsx @@ -1,7 +1,14 @@ "use client"; import { useQuery } from "@apollo/client"; -import { format, endOfMonth, subMonths, isSameDay, getDaysInMonth, getDate } from "date-fns"; +import { + format, + endOfMonth, + subMonths, + isSameDay, + getDaysInMonth, + getDate, +} from "date-fns"; import { useState, useEffect } from "react"; import { DatePicker } from "@/components/DatePicker"; import { REVENUE_FORECAST_QUERY } from "./query"; @@ -15,18 +22,25 @@ import { } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import Link from "next/link"; +import SectionHeader from "@/components/SectionHeader"; export default function RevenueForecastPage() { const [date, setDate] = useState(new Date()); - const [showPartialPreviousMonth, setShowPartialPreviousMonth] = useState(false); - const [sortConfigs, setSortConfigs] = useState>({ - consulting: { key: 'current', direction: 'desc' }, - consultingPre: { key: 'current', direction: 'desc' }, - handsOn: { key: 'current', direction: 'desc' }, - squad: { key: 'current', direction: 'desc' } + const [showPartialPreviousMonth, setShowPartialPreviousMonth] = + useState(false); + const [sortConfigs, setSortConfigs] = useState< + Record< + string, + { + key: string; + direction: "asc" | "desc"; + } + > + >({ + consulting: { key: "current", direction: "desc" }, + consultingPre: { key: "current", direction: "desc" }, + handsOn: { key: "current", direction: "desc" }, + squad: { key: "current", direction: "desc" }, }); useEffect(() => { @@ -37,13 +51,17 @@ export default function RevenueForecastPage() { const previousMonthDate = endOfMonth(subMonths(date, 1)); const twoMonthsAgoDate = endOfMonth(subMonths(date, 2)); const threeMonthsAgoDate = endOfMonth(subMonths(date, 3)); - + const getPreviousMonthPartialDate = () => { const previousMonth = subMonths(date, 1); const currentDay = date.getDate(); const daysInPreviousMonth = getDaysInMonth(previousMonth); const targetDay = Math.min(currentDay, daysInPreviousMonth); - return new Date(previousMonth.getFullYear(), previousMonth.getMonth(), targetDay); + return new Date( + previousMonth.getFullYear(), + previousMonth.getMonth(), + targetDay + ); }; const getTwoMonthsAgoPartialDate = () => { @@ -51,7 +69,11 @@ export default function RevenueForecastPage() { const currentDay = date.getDate(); const daysInTwoMonthsAgo = getDaysInMonth(twoMonthsAgo); const targetDay = Math.min(currentDay, daysInTwoMonthsAgo); - return new Date(twoMonthsAgo.getFullYear(), twoMonthsAgo.getMonth(), targetDay); + return new Date( + twoMonthsAgo.getFullYear(), + twoMonthsAgo.getMonth(), + targetDay + ); }; const getThreeMonthsAgoPartialDate = () => { @@ -59,7 +81,11 @@ export default function RevenueForecastPage() { const currentDay = date.getDate(); const daysInThreeMonthsAgo = getDaysInMonth(threeMonthsAgo); const targetDay = Math.min(currentDay, daysInThreeMonthsAgo); - return new Date(threeMonthsAgo.getFullYear(), threeMonthsAgo.getMonth(), targetDay); + return new Date( + threeMonthsAgo.getFullYear(), + threeMonthsAgo.getMonth(), + targetDay + ); }; const previousMonthPartialDate = getPreviousMonthPartialDate(); @@ -74,7 +100,10 @@ export default function RevenueForecastPage() { twoMonthsAgoDate: format(twoMonthsAgoDate, "yyyy-MM-dd"), twoMonthsAgoPartialDate: format(twoMonthsAgoPartialDate, "yyyy-MM-dd"), threeMonthsAgoDate: format(threeMonthsAgoDate, "yyyy-MM-dd"), - threeMonthsAgoPartialDate: format(threeMonthsAgoPartialDate, "yyyy-MM-dd"), + threeMonthsAgoPartialDate: format( + threeMonthsAgoPartialDate, + "yyyy-MM-dd" + ), }, }); @@ -82,7 +111,15 @@ export default function RevenueForecastPage() { if (error) return
Error: {error.message}
; // Process data for each service type - const processServiceData = (type: 'regular' | 'preContracted' | 'consultingFee' | 'consultingPreFee' | 'handsOnFee' | 'squadFee') => { + const processServiceData = ( + type: + | "regular" + | "preContracted" + | "consultingFee" + | "consultingPreFee" + | "handsOnFee" + | "squadFee" + ) => { const clients = new Map(); data.three_months_ago.summaries.byClient.forEach((client: any) => { @@ -98,7 +135,7 @@ export default function RevenueForecastPage() { previousPartial: 0, current: 0, projected: 0, - expected: 0 + expected: 0, }); } }); @@ -119,7 +156,7 @@ export default function RevenueForecastPage() { previousPartial: 0, current: 0, projected: 0, - expected: 0 + expected: 0, }); } } @@ -141,7 +178,7 @@ export default function RevenueForecastPage() { previousPartial: 0, current: 0, projected: 0, - expected: 0 + expected: 0, }); } } @@ -163,7 +200,7 @@ export default function RevenueForecastPage() { previousPartial: 0, current: 0, projected: 0, - expected: 0 + expected: 0, }); } } @@ -185,7 +222,7 @@ export default function RevenueForecastPage() { previousPartial: 0, current: 0, projected: 0, - expected: 0 + expected: 0, }); } } @@ -207,7 +244,7 @@ export default function RevenueForecastPage() { previousPartial: client[type], current: 0, projected: 0, - expected: 0 + expected: 0, }); } } @@ -219,7 +256,7 @@ export default function RevenueForecastPage() { const currentValue = client[type]; const clientData = clients.get(client.slug); clientData.current = currentValue; - + // Calculate projected value based on current day of month const currentDay = getDate(date); const daysInMonth = getDaysInMonth(date); @@ -227,23 +264,35 @@ export default function RevenueForecastPage() { clientData.projected = projectedValue; // Calculate expected value (60% previous month + 25% two months ago + 15% three months ago) - const previousValue = clientData.previousFull; - const twoMonthsAgoValue = clientData.twoMonthsAgoFull; - const threeMonthsAgoValue = clientData.threeMonthsAgoFull; - - if (threeMonthsAgoValue === 0 && twoMonthsAgoValue === 0) { + const previousValue = clientData.previousFull || 0; + const twoMonthsAgoValue = clientData.twoMonthsAgoFull || 0; + const threeMonthsAgoValue = clientData.threeMonthsAgoFull || 0; + + // If there's no history, use projected value as expected + if (previousValue === 0 && twoMonthsAgoValue === 0 && threeMonthsAgoValue === 0) { + clientData.expected = projectedValue; + } + // If only has previous month + else if (twoMonthsAgoValue === 0 && threeMonthsAgoValue === 0) { clientData.expected = previousValue; - } else if (threeMonthsAgoValue === 0) { - clientData.expected = (previousValue * 0.8) + (twoMonthsAgoValue * 0.2); - } else { - clientData.expected = (previousValue * 0.6) + (twoMonthsAgoValue * 0.25) + (threeMonthsAgoValue * 0.15); + } + // If has previous and two months ago + else if (threeMonthsAgoValue === 0) { + clientData.expected = previousValue * 0.8 + twoMonthsAgoValue * 0.2; + } + // If has all three months + else { + clientData.expected = + previousValue * 0.6 + + twoMonthsAgoValue * 0.25 + + threeMonthsAgoValue * 0.15; } } else { const currentValue = client[type]; const currentDay = getDate(date); const daysInMonth = getDaysInMonth(date); const projectedValue = (currentValue / currentDay) * daysInMonth; - + clients.set(client.slug, { name: client.name, slug: client.slug, @@ -255,7 +304,7 @@ export default function RevenueForecastPage() { previousPartial: 0, current: currentValue, projected: projectedValue, - expected: 0 + expected: projectedValue, // Use projected value as expected when no history }); } } @@ -264,10 +313,10 @@ export default function RevenueForecastPage() { return clients; }; - const consultingClients = processServiceData('consultingFee'); - const consultingPreClients = processServiceData('consultingPreFee'); - const handsOnClients = processServiceData('handsOnFee'); - const squadClients = processServiceData('squadFee'); + const consultingClients = processServiceData("consultingFee"); + const consultingPreClients = processServiceData("consultingPreFee"); + const handsOnClients = processServiceData("handsOnFee"); + const squadClients = processServiceData("squadFee"); const formatCurrency = (value: number) => { return new Intl.NumberFormat("en-US", { @@ -277,12 +326,20 @@ export default function RevenueForecastPage() { }).format(value); }; + const formatPercentage = (value: number, total: number) => { + if (total === 0 || value === 0) return ""; + return `${((value / total) * 100).toFixed(1)}%`; + }; + const requestSort = (key: string, tableId: string) => { - setSortConfigs(prevConfigs => { + setSortConfigs((prevConfigs) => { const newConfigs = { ...prevConfigs }; - let direction: 'asc' | 'desc' = 'desc'; - if (newConfigs[tableId].key === key && newConfigs[tableId].direction === 'desc') { - direction = 'asc'; + let direction: "asc" | "desc" = "desc"; + if ( + newConfigs[tableId].key === key && + newConfigs[tableId].direction === "desc" + ) { + direction = "asc"; } newConfigs[tableId] = { key, direction }; return newConfigs; @@ -299,123 +356,310 @@ export default function RevenueForecastPage() { const bValue = b[sortConfig.key]; if (aValue < bValue) { - return sortConfig.direction === 'asc' ? -1 : 1; + return sortConfig.direction === "asc" ? -1 : 1; } if (aValue > bValue) { - return sortConfig.direction === 'asc' ? 1 : -1; + return sortConfig.direction === "asc" ? 1 : -1; } return 0; }); }; - const renderConsultingTable = (title: string, clients: Map, tableId: string) => { + const renderConsultingTable = ( + title: string, + clients: Map, + tableId: string + ) => { const sortedClients = getSortedClients(clients, tableId); const sortConfig = sortConfigs[tableId]; - const total = sortedClients.reduce((acc, client) => ({ - threeMonthsAgoFull: acc.threeMonthsAgoFull + client.threeMonthsAgoFull, - threeMonthsAgoPartial: acc.threeMonthsAgoPartial + client.threeMonthsAgoPartial, - twoMonthsAgoFull: acc.twoMonthsAgoFull + client.twoMonthsAgoFull, - twoMonthsAgoPartial: acc.twoMonthsAgoPartial + client.twoMonthsAgoPartial, - previousFull: acc.previousFull + client.previousFull, - previousPartial: acc.previousPartial + client.previousPartial, - current: acc.current + client.current, - projected: acc.projected + client.projected, - expected: acc.expected + client.expected - }), { threeMonthsAgoFull: 0, threeMonthsAgoPartial: 0, twoMonthsAgoFull: 0, twoMonthsAgoPartial: 0, previousFull: 0, previousPartial: 0, current: 0, projected: 0, expected: 0 }); + const total = sortedClients.reduce( + (acc, client) => ({ + threeMonthsAgoFull: acc.threeMonthsAgoFull + client.threeMonthsAgoFull, + threeMonthsAgoPartial: + acc.threeMonthsAgoPartial + client.threeMonthsAgoPartial, + twoMonthsAgoFull: acc.twoMonthsAgoFull + client.twoMonthsAgoFull, + twoMonthsAgoPartial: + acc.twoMonthsAgoPartial + client.twoMonthsAgoPartial, + previousFull: acc.previousFull + client.previousFull, + previousPartial: acc.previousPartial + client.previousPartial, + current: acc.current + client.current, + projected: acc.projected + client.projected, + expected: acc.expected + client.expected, + }), + { + threeMonthsAgoFull: 0, + threeMonthsAgoPartial: 0, + twoMonthsAgoFull: 0, + twoMonthsAgoPartial: 0, + previousFull: 0, + previousPartial: 0, + current: 0, + projected: 0, + expected: 0, + } + ); return (
-

{title}

+ - # - Client - - {format(threeMonthsAgoDate, 'MMM yyyy')} + # + Client + + {format(threeMonthsAgoDate, "MMM yyyy")} - - {format(twoMonthsAgoDate, 'MMM yyyy')} + + {format(twoMonthsAgoDate, "MMM yyyy")} - - {format(previousMonthDate, 'MMM yyyy')} + + {format(previousMonthDate, "MMM yyyy")} - + {format(date, "MMM yyyy")} - - - Until {format(threeMonthsAgoPartialDate, "dd")} - Full Month - Until {format(twoMonthsAgoPartialDate, "dd")} - Full Month - Until {format(previousMonthPartialDate, "dd")} - Full Month - requestSort('current', tableId)} className="text-right cursor-pointer hover:bg-gray-100 w-[120px] border-x"> - Realized {sortConfig.key === 'current' && (sortConfig.direction === 'asc' ? '↑' : '↓')} + requestSort("threeMonthsAgoPartial", tableId)} + className="text-right w-[95px] border-x border-gray-200 cursor-pointer hover:bg-gray-100" + > + Until {format(threeMonthsAgoPartialDate, "dd")}{" "} + {sortConfig.key === "threeMonthsAgoPartial" && + (sortConfig.direction === "asc" ? "↑" : "↓")} + + requestSort("threeMonthsAgoFull", tableId)} + className="text-right w-[95px] border-r border-gray-400 cursor-pointer hover:bg-gray-100" + > + Full Month{" "} + {sortConfig.key === "threeMonthsAgoFull" && + (sortConfig.direction === "asc" ? "↑" : "↓")} + + requestSort("twoMonthsAgoPartial", tableId)} + className="text-right w-[95px] border-x border-gray-200 cursor-pointer hover:bg-gray-100" + > + Until {format(twoMonthsAgoPartialDate, "dd")}{" "} + {sortConfig.key === "twoMonthsAgoPartial" && + (sortConfig.direction === "asc" ? "↑" : "↓")} + + requestSort("twoMonthsAgoFull", tableId)} + className="text-right w-[95px] border-r border-gray-400 cursor-pointer hover:bg-gray-100" + > + Full Month{" "} + {sortConfig.key === "twoMonthsAgoFull" && + (sortConfig.direction === "asc" ? "↑" : "↓")} + + requestSort("previousPartial", tableId)} + className="text-right w-[95px] border-x border-gray-200 cursor-pointer hover:bg-gray-100" + > + Until {format(previousMonthPartialDate, "dd")}{" "} + {sortConfig.key === "previousPartial" && + (sortConfig.direction === "asc" ? "↑" : "↓")} + + requestSort("previousFull", tableId)} + className="text-right w-[95px] border-r border-gray-400 cursor-pointer hover:bg-gray-100" + > + Full Month{" "} + {sortConfig.key === "previousFull" && + (sortConfig.direction === "asc" ? "↑" : "↓")} - requestSort('projected', tableId)} className="text-right cursor-pointer hover:bg-gray-100 w-[120px] border-x"> - Projected {sortConfig.key === 'projected' && (sortConfig.direction === 'asc' ? '↑' : '↓')} + requestSort("current", tableId)} + className="text-right cursor-pointer hover:bg-gray-100 w-[120px] border-x border-gray-200" + > + Realized{" "} + {sortConfig.key === "current" && + (sortConfig.direction === "asc" ? "↑" : "↓")} + + requestSort("projected", tableId)} + className="text-right cursor-pointer hover:bg-gray-100 w-[120px] border-x border-gray-200" + > + Projected{" "} + {sortConfig.key === "projected" && + (sortConfig.direction === "asc" ? "↑" : "↓")} + + requestSort("expected", tableId)} + className="text-right cursor-pointer hover:bg-gray-100 w-[120px] border-r border-gray-400" + > + Expected{" "} + {sortConfig.key === "expected" && + (sortConfig.direction === "asc" ? "↑" : "↓")} - Expected {sortedClients.map((client: any, index: number) => ( - - {index + 1} - + + + {index + 1} + + {client.slug ? ( - + {client.name} ) : ( {client.name} )} - + {formatCurrency(client.threeMonthsAgoPartial)} + + {formatPercentage( + client.threeMonthsAgoPartial, + total.threeMonthsAgoPartial + )} + - + {formatCurrency(client.threeMonthsAgoFull)} + + {formatPercentage( + client.threeMonthsAgoFull, + total.threeMonthsAgoFull + )} + - + {formatCurrency(client.twoMonthsAgoPartial)} + + {formatPercentage( + client.twoMonthsAgoPartial, + total.twoMonthsAgoPartial + )} + - + {formatCurrency(client.twoMonthsAgoFull)} + + {formatPercentage( + client.twoMonthsAgoFull, + total.twoMonthsAgoFull + )} + - + {formatCurrency(client.previousPartial)} + + {formatPercentage( + client.previousPartial, + total.previousPartial + )} + - + {formatCurrency(client.previousFull)} + + {formatPercentage(client.previousFull, total.previousFull)} + - + {formatCurrency(client.current)} + + {formatPercentage(client.current, total.current)} + - + {formatCurrency(client.projected)} + + {formatPercentage(client.projected, total.projected)} + - + {formatCurrency(client.expected)} + + {formatPercentage(client.expected, total.expected)} + ))} - + - Total - {formatCurrency(total.threeMonthsAgoPartial)} - {formatCurrency(total.threeMonthsAgoFull)} - {formatCurrency(total.twoMonthsAgoPartial)} - {formatCurrency(total.twoMonthsAgoFull)} - {formatCurrency(total.previousPartial)} - {formatCurrency(total.previousFull)} - {formatCurrency(total.current)} - {formatCurrency(total.projected)} - {formatCurrency(total.expected)} + Total + + {formatCurrency(total.threeMonthsAgoPartial)} + + + {formatCurrency(total.threeMonthsAgoFull)} + + + {formatCurrency(total.twoMonthsAgoPartial)} + + + {formatCurrency(total.twoMonthsAgoFull)} + + + {formatCurrency(total.previousPartial)} + + + {formatCurrency(total.previousFull)} + + + {formatCurrency(total.current)} + + + {formatCurrency(total.projected)} + + + {formatCurrency(total.expected)} +
@@ -423,75 +667,159 @@ export default function RevenueForecastPage() { ); }; - const renderOtherTable = (title: string, clients: Map, tableId: string) => { + const renderOtherTable = ( + title: string, + clients: Map, + tableId: string + ) => { const sortedClients = getSortedClients(clients, tableId); const sortConfig = sortConfigs[tableId]; - const total = sortedClients.reduce((acc, client) => ({ - threeMonthsAgoFull: acc.threeMonthsAgoFull + client.threeMonthsAgoFull, - twoMonthsAgoFull: acc.twoMonthsAgoFull + client.twoMonthsAgoFull, - previousFull: acc.previousFull + client.previousFull, - current: acc.current + client.current - }), { threeMonthsAgoFull: 0, twoMonthsAgoFull: 0, previousFull: 0, current: 0 }); + const total = sortedClients.reduce( + (acc, client) => ({ + threeMonthsAgoFull: acc.threeMonthsAgoFull + client.threeMonthsAgoFull, + twoMonthsAgoFull: acc.twoMonthsAgoFull + client.twoMonthsAgoFull, + previousFull: acc.previousFull + client.previousFull, + current: acc.current + client.current, + }), + { + threeMonthsAgoFull: 0, + twoMonthsAgoFull: 0, + previousFull: 0, + current: 0, + } + ); return (
-

{title}

- - - - # - Client - - {format(threeMonthsAgoDate, 'MMM yyyy')} - - - {format(twoMonthsAgoDate, 'MMM yyyy')} - - - {format(previousMonthDate, 'MMM yyyy')} - - - {format(date, "MMM yyyy")} - - - - - {sortedClients.map((client: any, index: number) => ( - - {index + 1} - - {client.slug ? ( - - {client.name} - - ) : ( - {client.name} - )} + +
+
+ + + # + Client + requestSort("threeMonthsAgoFull", tableId)} + className="text-center border-x w-[95px] cursor-pointer hover:bg-gray-100" + > + {format(threeMonthsAgoDate, "MMM yyyy")}{" "} + {sortConfig.key === "threeMonthsAgoFull" && + (sortConfig.direction === "asc" ? "↑" : "↓")} + + requestSort("twoMonthsAgoFull", tableId)} + className="text-center border-x w-[95px] cursor-pointer hover:bg-gray-100" + > + {format(twoMonthsAgoDate, "MMM yyyy")}{" "} + {sortConfig.key === "twoMonthsAgoFull" && + (sortConfig.direction === "asc" ? "↑" : "↓")} + + requestSort("previousFull", tableId)} + className="text-center border-x w-[95px] cursor-pointer hover:bg-gray-100" + > + {format(previousMonthDate, "MMM yyyy")}{" "} + {sortConfig.key === "previousFull" && + (sortConfig.direction === "asc" ? "↑" : "↓")} + + requestSort("current", tableId)} + className="text-center border-x w-[120px] cursor-pointer hover:bg-gray-100" + > + {format(date, "MMM yyyy")}{" "} + {sortConfig.key === "current" && + (sortConfig.direction === "asc" ? "↑" : "↓")} + + + + + {sortedClients.map((client: any, index: number) => ( + + + {index + 1} + + + {client.slug ? ( + + {client.name} + + ) : ( + {client.name} + )} + + + {formatCurrency(client.threeMonthsAgoFull)} + + {formatPercentage( + client.threeMonthsAgoFull, + total.threeMonthsAgoFull + )} + + + + {formatCurrency(client.twoMonthsAgoFull)} + + {formatPercentage( + client.twoMonthsAgoFull, + total.twoMonthsAgoFull + )} + + + + {formatCurrency(client.previousFull)} + + {formatPercentage( + client.previousFull, + total.previousFull + )} + + + + {formatCurrency(client.current)} + + {formatPercentage(client.current, total.current)} + + + + ))} + + + Total + + {formatCurrency(total.threeMonthsAgoFull)} - - {formatCurrency(client.threeMonthsAgoFull)} + + {formatCurrency(total.twoMonthsAgoFull)} - - {formatCurrency(client.twoMonthsAgoFull)} + + {formatCurrency(total.previousFull)} - - {formatCurrency(client.previousFull)} - - - {formatCurrency(client.current)} + + {formatCurrency(total.current)} - ))} - - - Total - {formatCurrency(total.threeMonthsAgoFull)} - {formatCurrency(total.twoMonthsAgoFull)} - {formatCurrency(total.previousFull)} - {formatCurrency(total.current)} - - -
+ + +
); }; @@ -503,10 +831,14 @@ export default function RevenueForecastPage() {
- {renderConsultingTable('Consulting', consultingClients, 'consulting')} - {renderOtherTable('Consulting Pre', consultingPreClients, 'consultingPre')} - {renderOtherTable('Hands On', handsOnClients, 'handsOn')} - {renderOtherTable('Squad', squadClients, 'squad')} + {renderConsultingTable("Consulting", consultingClients, "consulting")} + {renderOtherTable( + "Consulting Pre", + consultingPreClients, + "consultingPre" + )} + {renderOtherTable("Hands On", handsOnClients, "handsOn")} + {renderOtherTable("Squad", squadClients, "squad")}
);