diff --git a/frontend/src/app/analytics/revenue-forecast/page.tsx b/frontend/src/app/analytics/revenue-forecast/page.tsx index 69d089c9..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 } 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,14 +22,26 @@ 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 [sortConfig, setSortConfig] = useState<{ - key: string; - direction: 'asc' | 'desc'; - }>({ key: 'current.total', 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(() => { const today = new Date(); @@ -30,74 +49,274 @@ export default function RevenueForecastPage() { }, []); const previousMonthDate = endOfMonth(subMonths(date, 1)); - - // Calculate the partial previous month date + 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 = () => { + 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" + ), }, }); if (loading) return
Loading...
; if (error) return
Error: {error.message}
; - // Merge client data from both queries - const clients = new Map(); - - const previousData = showPartialPreviousMonth ? data.previous_month_partial : data.previous_month; - - 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, - }, + // Process data for each service type + const processServiceData = ( + type: + | "regular" + | "preContracted" + | "consultingFee" + | "consultingPreFee" + | "handsOnFee" + | "squadFee" + ) => { + const clients = new Map(); + + data.three_months_ago.summaries.byClient.forEach((client: any) => { + if (client[type]) { + clients.set(client.slug, { + name: client.name, + slug: client.slug, + threeMonthsAgoFull: client[type], + threeMonthsAgoPartial: 0, + twoMonthsAgoFull: 0, + twoMonthsAgoPartial: 0, + previousFull: 0, + previousPartial: 0, + current: 0, + projected: 0, + expected: 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.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)) { + clients.get(client.slug).previousPartial = client[type]; + } else { + 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, + projected: 0, + expected: 0, + }); + } + } + }); + + data.in_analysis.summaries.byClient.forEach((client: any) => { + if (client[type]) { + if (clients.has(client.slug)) { + 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 || 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; + } + // 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, + threeMonthsAgoFull: 0, + threeMonthsAgoPartial: 0, + twoMonthsAgoFull: 0, + twoMonthsAgoPartial: 0, + previousFull: 0, + previousPartial: 0, + current: currentValue, + projected: projectedValue, + expected: projectedValue, // Use projected value as expected when no history + }); + } + } + }); + + 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,216 +326,520 @@ 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 formatPercentage = (value: number, total: number) => { + if (total === 0 || value === 0) return ""; + return `${((value / total) * 100).toFixed(1)}%`; + }; - 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; + return sortConfig.direction === "asc" ? -1 : 1; } if (aValue > bValue) { - return sortConfig.direction === 'asc' ? 1 : -1; + return sortConfig.direction === "asc" ? 1 : -1; } return 0; }); }; - const sortedClients = getSortedClients(); - - return ( -
-
- -
-
+ 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, + } + ); -
+ return ( +
+ # - Client - -
- {format(previousMonthDate, 'MMMM yyyy')} -
- setShowPartialPreviousMonth(checked as boolean)} - /> - -
-
+ Client + + {format(threeMonthsAgoDate, "MMM yyyy")} + + + {format(twoMonthsAgoDate, "MMM yyyy")} + + + {format(previousMonthDate, "MMM yyyy")} + + + {format(date, "MMM yyyy")} - {format(date, "MMMM yyyy 'until' EEEE, dd")} - 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' ? '↑' : '↓')} + 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("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" ? "↑" : "↓")} +
{sortedClients.map((client: any, index: number) => ( - - {index + 1} - + + + {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)} + + {formatCurrency(client.threeMonthsAgoPartial)} + + {formatPercentage( + client.threeMonthsAgoPartial, + total.threeMonthsAgoPartial + )} + - 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)} + + {formatCurrency(client.threeMonthsAgoFull)} + + {formatPercentage( + client.threeMonthsAgoFull, + total.threeMonthsAgoFull + )} + - 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.twoMonthsAgoPartial)} + + {formatPercentage( + client.twoMonthsAgoPartial, + total.twoMonthsAgoPartial + )} + - - {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.twoMonthsAgoFull)} + + {formatPercentage( + client.twoMonthsAgoFull, + total.twoMonthsAgoFull + )} + - - {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)} -
- )} + + {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 - 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)} -
- )} + Total + + {formatCurrency(total.threeMonthsAgoPartial)} + + + {formatCurrency(total.threeMonthsAgoFull)} + + + {formatCurrency(total.twoMonthsAgoPartial)} + + + {formatCurrency(total.twoMonthsAgoFull)} + + + {formatCurrency(total.previousPartial)} + + + {formatCurrency(total.previousFull)} + + + {formatCurrency(total.current)} - - {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(total.projected)} - - {formatCurrency(totals.current.total - totals.previous.total)} - {totals.current.total - totals.previous.total !== 0 && ( -
totals.previous.total ? '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)} -
- )} + + {formatCurrency(total.expected)}
+ ); + }; + + 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 ( +
+ +
+ + + + # + 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(total.twoMonthsAgoFull)} + + + {formatCurrency(total.previousFull)} + + + {formatCurrency(total.current)} + + + +
+
+
+ ); + }; + + return ( +
+
+ +
+ +
+ {renderConsultingTable("Consulting", consultingClients, "consulting")} + {renderOtherTable( + "Consulting Pre", + consultingPreClients, + "consultingPre" + )} + {renderOtherTable("Hands On", handsOnClients, "handsOn")} + {renderOtherTable("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..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 { @@ -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,74 @@ export const REVENUE_FORECAST_QUERY = gql` slug regular preContracted + consultingFee + consultingPreFee + handsOnFee + squadFee + total + } + } + } + + 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 } } 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) => ( financialEmail.startsWith(username + '@'))) { + return true; + } return FINANCIAL_USERS.includes(email as any); case 'admin': return email.endsWith('@eximia.co') || email.endsWith('@elemarjr.com');