feat(dashboard): revenue "year" tile toggles 365 days ↔ calendar YTD
Per request, keep the dashboard to four tiles rather than adding a fifth: the "Revenue · last 365 days" tile is now clickable and toggles in place between the trailing-365-day window and calendar year-to-date (since Jan 1). - adminDashboard: new calendar-year cutoff + revenue.calendarYearMinor (same cash-basis paid_at window logic as the existing trio). - StatCard gains an optional onClick (renders as a button); the year tile uses it, with a "Tap to switch window" hint for discoverability. - bills.service CrmOverviewStats.revenue gains calendarYearMinor.
This commit is contained in:
@@ -439,6 +439,10 @@ router.get('/crm-stats', adminAuth, async (req, res) => {
|
|||||||
const monthCutoff = new Date(now - 30 * DAY);
|
const monthCutoff = new Date(now - 30 * DAY);
|
||||||
const quarterCutoff = new Date(now - 90 * DAY);
|
const quarterCutoff = new Date(now - 90 * DAY);
|
||||||
const yearCutoff = new Date(now - 365 * DAY);
|
const yearCutoff = new Date(now - 365 * DAY);
|
||||||
|
// Calendar year-to-date (Jan 1 of the current year, local time) —
|
||||||
|
// the dashboard's revenue "year" tile can toggle between this and
|
||||||
|
// the trailing-365-day window.
|
||||||
|
const calendarYearCutoff = new Date(new Date(now).getFullYear(), 0, 1);
|
||||||
|
|
||||||
// ---- quotes: counts by status ---------------------------------
|
// ---- quotes: counts by status ---------------------------------
|
||||||
let quoteCounts = { draft: 0, sent: 0, accepted: 0, declined: 0, expired: 0, converted: 0 };
|
let quoteCounts = { draft: 0, sent: 0, accepted: 0, declined: 0, expired: 0, converted: 0 };
|
||||||
@@ -459,6 +463,7 @@ router.get('/crm-stats', adminAuth, async (req, res) => {
|
|||||||
let revenueMonthMinor = 0;
|
let revenueMonthMinor = 0;
|
||||||
let revenueQuarterMinor = 0;
|
let revenueQuarterMinor = 0;
|
||||||
let revenueYearMinor = 0;
|
let revenueYearMinor = 0;
|
||||||
|
let revenueCalendarYearMinor = 0;
|
||||||
let outstandingTotalMinor = 0;
|
let outstandingTotalMinor = 0;
|
||||||
let outstandingCount = 0;
|
let outstandingCount = 0;
|
||||||
|
|
||||||
@@ -507,6 +512,7 @@ router.get('/crm-stats', adminAuth, async (req, res) => {
|
|||||||
revenueMonthMinor = await winSum(monthCutoff);
|
revenueMonthMinor = await winSum(monthCutoff);
|
||||||
revenueQuarterMinor = await winSum(quarterCutoff);
|
revenueQuarterMinor = await winSum(quarterCutoff);
|
||||||
revenueYearMinor = await winSum(yearCutoff);
|
revenueYearMinor = await winSum(yearCutoff);
|
||||||
|
revenueCalendarYearMinor = await winSum(calendarYearCutoff);
|
||||||
|
|
||||||
// Outstanding: every invoice that's been sent but not fully
|
// Outstanding: every invoice that's been sent but not fully
|
||||||
// paid (sent + overdue). Outstanding = total - paid. We sum
|
// paid (sent + overdue). Outstanding = total - paid. We sum
|
||||||
@@ -571,6 +577,7 @@ router.get('/crm-stats', adminAuth, async (req, res) => {
|
|||||||
monthMinor: revenueMonthMinor,
|
monthMinor: revenueMonthMinor,
|
||||||
quarterMinor: revenueQuarterMinor,
|
quarterMinor: revenueQuarterMinor,
|
||||||
yearMinor: revenueYearMinor,
|
yearMinor: revenueYearMinor,
|
||||||
|
calendarYearMinor: revenueCalendarYearMinor,
|
||||||
},
|
},
|
||||||
outstanding: {
|
outstanding: {
|
||||||
totalMinor: outstandingTotalMinor,
|
totalMinor: outstandingTotalMinor,
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ export const CrmOverviewSection: React.FC = () => {
|
|||||||
// publicSettings finishes loading — shows everything, then settles.
|
// publicSettings finishes loading — shows everything, then settles.
|
||||||
const showRevenue = publicSettings?.crm_overview_show_revenue !== false;
|
const showRevenue = publicSettings?.crm_overview_show_revenue !== false;
|
||||||
const showOutstanding = publicSettings?.crm_overview_show_outstanding !== false;
|
const showOutstanding = publicSettings?.crm_overview_show_outstanding !== false;
|
||||||
|
// Revenue "year" tile toggles in place between the trailing-365-day
|
||||||
|
// window and calendar year-to-date — keeps the dashboard to four
|
||||||
|
// tiles instead of adding a fifth.
|
||||||
|
const [revYearMode, setRevYearMode] = React.useState<'rolling' | 'calendar'>('rolling');
|
||||||
const showQuotes = publicSettings?.crm_overview_show_quotes !== false;
|
const showQuotes = publicSettings?.crm_overview_show_quotes !== false;
|
||||||
const showInvoices = publicSettings?.crm_overview_show_invoices !== false;
|
const showInvoices = publicSettings?.crm_overview_show_invoices !== false;
|
||||||
// Compute which sub-sections actually render so we can skip the
|
// Compute which sub-sections actually render so we can skip the
|
||||||
@@ -121,8 +125,15 @@ export const CrmOverviewSection: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={<TrendingUp className="w-5 h-5" />}
|
icon={<TrendingUp className="w-5 h-5" />}
|
||||||
label={t('crmOverview.revenue.year', 'Revenue · last 365 days')}
|
label={revYearMode === 'calendar'
|
||||||
value={formatMoney(d.revenue.yearMinor, cur)}
|
? t('crmOverview.revenue.yearCalendar', 'Revenue · this year')
|
||||||
|
: t('crmOverview.revenue.year', 'Revenue · last 365 days')}
|
||||||
|
value={formatMoney(
|
||||||
|
revYearMode === 'calendar' ? d.revenue.calendarYearMinor : d.revenue.yearMinor,
|
||||||
|
cur,
|
||||||
|
)}
|
||||||
|
sub={t('crmOverview.revenue.toggleHint', 'Tap to switch window')}
|
||||||
|
onClick={() => setRevYearMode((m) => (m === 'rolling' ? 'calendar' : 'rolling'))}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -249,8 +260,11 @@ interface StatCardProps {
|
|||||||
value: string | number;
|
value: string | number;
|
||||||
sub?: string;
|
sub?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
|
/** Makes the whole tile a button (mutually exclusive with `to`).
|
||||||
|
* Used by the revenue tile to toggle its window in place. */
|
||||||
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
const StatCard: React.FC<StatCardProps> = ({ icon, label, value, sub, to }) => {
|
const StatCard: React.FC<StatCardProps> = ({ icon, label, value, sub, to, onClick }) => {
|
||||||
const inner = (
|
const inner = (
|
||||||
<Card padding="md" className="h-full">
|
<Card padding="md" className="h-full">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
@@ -266,6 +280,13 @@ const StatCard: React.FC<StatCardProps> = ({ icon, label, value, sub, to }) => {
|
|||||||
if (to) {
|
if (to) {
|
||||||
return <Link to={to} className="block hover:opacity-90 transition-opacity">{inner}</Link>;
|
return <Link to={to} className="block hover:opacity-90 transition-opacity">{inner}</Link>;
|
||||||
}
|
}
|
||||||
|
if (onClick) {
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onClick} className="block w-full text-left hover:opacity-90 transition-opacity">
|
||||||
|
{inner}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
return inner;
|
return inner;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -386,6 +386,9 @@ export interface CrmOverviewStats {
|
|||||||
monthMinor: number;
|
monthMinor: number;
|
||||||
quarterMinor: number;
|
quarterMinor: number;
|
||||||
yearMinor: number;
|
yearMinor: number;
|
||||||
|
/** Revenue since Jan 1 of the current year (calendar YTD). The
|
||||||
|
* dashboard's "year" tile toggles between this and yearMinor. */
|
||||||
|
calendarYearMinor: number;
|
||||||
};
|
};
|
||||||
outstanding: {
|
outstanding: {
|
||||||
totalMinor: number;
|
totalMinor: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user