Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 0 additions & 24 deletions src/components/projects/ProjectHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,30 +210,6 @@ export function ProjectHeader() {

{/* Action buttons */}
<div className="flex items-center gap-2">
{/* Cost visibility toggle - hidden in print */}
<button
onClick={() => setShowCosts(!showCosts)}
className={cn(
'flex items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors print:hidden',
showCosts
? 'border-amber-500/50 bg-amber-900/20 text-amber-400 hover:bg-amber-900/30'
: 'border-dark-600 bg-dark-700 text-gray-400 hover:border-gray-500 hover:text-gray-300'
)}
title={showCosts ? 'Hide internal costs' : 'Show internal costs'}
>
{showCosts ? (
<>
<EyeIcon className="h-4 w-4" />
<span className="hidden sm:inline">Costs visible</span>
</>
) : (
<>
<EyeSlashIcon className="h-4 w-4" />
<span className="hidden sm:inline">Costs hidden</span>
</>
)}
</button>

{/* PDF Export dropdown - hidden in print */}
<div className="relative print:hidden" ref={exportMenuRef}>
<button
Expand Down
245 changes: 88 additions & 157 deletions src/components/projects/ProjectKPICards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,59 +10,32 @@ import {
CalendarDaysIcon,
BanknotesIcon,
CurrencyPoundIcon,
InformationCircleIcon,
EyeIcon,
EyeSlashIcon,
} from '@heroicons/react/24/outline';

// Info tooltip component with styled popup (keyboard accessible)
function InfoTooltip({
title,
description,
source,
formula,
// Per-widget visibility toggle: an Eye / Eye-slash button that masks just this
// widget's amount. Hidden from print (the PDF export controls its own masking).
function VisibilityToggle({
hidden,
onToggle,
label,
}: {
title: string;
description: string;
source: string;
formula?: string;
hidden: boolean;
onToggle: () => void;
label: string;
}) {
const [isOpen, setIsOpen] = useState(false);

return (
<div className="relative print:hidden">
<button
type="button"
className="focus:ring-thyme-500 focus:ring-offset-dark-800 cursor-help rounded text-gray-600 hover:text-gray-400 focus:ring-1 focus:ring-offset-1 focus:outline-none"
onMouseEnter={() => setIsOpen(true)}
onMouseLeave={() => setIsOpen(false)}
onFocus={() => setIsOpen(true)}
onBlur={() => setIsOpen(false)}
aria-label={`Info: ${title}`}
aria-expanded={isOpen}
>
<InformationCircleIcon className="h-4 w-4" />
</button>
{isOpen && (
<div
role="tooltip"
className="bg-dark-700 absolute top-6 right-0 z-20 w-64 rounded px-3 py-2 text-xs shadow-lg"
>
<div className="font-medium text-white">{title}</div>
<div className="border-dark-500 mt-1 border-t pt-1">
<div className="text-gray-300">{description}</div>
</div>
{formula && (
<div className="border-dark-500 mt-1 border-t pt-1">
<div className="text-gray-500">Formula:</div>
<div className="text-thyme-400 font-mono">{formula}</div>
</div>
)}
<div className="border-dark-500 mt-1 border-t pt-1">
<div className="text-gray-500">Source:</div>
<div className="text-blue-400">{source}</div>
</div>
</div>
)}
</div>
<button
type="button"
onClick={onToggle}
className="focus:ring-thyme-500 focus:ring-offset-dark-800 rounded text-gray-600 transition-colors hover:text-gray-400 focus:ring-1 focus:ring-offset-1 focus:outline-none print:hidden"
aria-label={hidden ? `Show ${label} amount` : `Hide ${label} amount`}
aria-pressed={hidden}
title={hidden ? 'Show amount' : 'Hide amount'}
>
{hidden ? <EyeSlashIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
</button>
);
}

Expand Down Expand Up @@ -94,12 +67,26 @@ function formatCurrency(amount: number, currencyCode: string): string {
}

export function ProjectKPICards() {
const { analytics, isLoadingAnalytics, showCosts, showPrices, currencyCode, project } =
const { analytics, isLoadingAnalytics, showPrices, currencyCode, project } =
useProjectDetailsStore();
const selectedCompany = useCompanyStore((state) => state.selectedCompany);
const companyName = selectedCompany?.name;
const projectCode = project?.code;

// Per-widget amount visibility. Keyed by KPI label; resets on reload (not persisted).
const [hiddenCards, setHiddenCards] = useState<Set<string>>(new Set());
const toggleCardHidden = (label: string) =>
setHiddenCards((prev) => {
const next = new Set(prev);
if (next.has(label)) {
next.delete(label);
} else {
next.add(label);
}
return next;
});
const maskedValue = '•••••';

if (isLoadingAnalytics) {
return (
<div className="space-y-4">
Expand Down Expand Up @@ -143,11 +130,6 @@ export function ProjectKPICards() {
: 'No budget set in BC',
icon: CalendarDaysIcon,
color: hoursRemaining < 0 ? 'text-red-400' : 'text-blue-400',
tooltip: {
title: 'Time Budgeted',
description: `Budgeted hours from Job Planning Lines. Only includes Resource lines where lineType is "Budget" or "Both Budget and Billable". Days = hours ÷ ${hoursPerDay}.`,
source: 'BC API: /jobPlanningLines → quantity',
},
},
{
label: 'Time Spent',
Expand All @@ -160,36 +142,20 @@ export function ProjectKPICards() {
progress: hasPlannedHours ? Math.min(percentUsed, 100) : undefined,
progressColor:
percentUsed > 100 ? 'bg-red-500' : percentUsed > 80 ? 'bg-amber-500' : 'bg-thyme-500',
tooltip: {
title: 'Time Spent',
description: `Total hours logged in timesheets for this project. Includes all timesheet statuses: Open, Submitted, and Approved. Days = hours ÷ ${hoursPerDay}.`,
source: 'BC API: /timeSheetLines → totalQuantity',
},
},
{
label: 'Time Unposted',
value: formatHoursWithDays(hoursUnposted, hoursPerDay),
subLabel: hoursUnposted > 0 ? 'In timesheets, not posted' : 'All time posted',
icon: ClockIcon,
color: hoursUnposted > 0 ? 'text-amber-400' : 'text-gray-500',
tooltip: {
title: 'Time Unposted',
description: `Hours in timesheets that have not yet been posted to the Job Ledger Entry. These hours are approved but awaiting the "Post Time Sheets" action in BC. Days = hours ÷ ${hoursPerDay}.`,
formula: 'Time Spent − Time Posted',
source: 'Calculated',
},
},
{
label: 'Time Posted',
value: formatHoursWithDays(hoursPosted, hoursPerDay),
subLabel: 'In Job Ledger Entry',
icon: ClockIcon,
color: 'text-green-400',
tooltip: {
title: 'Time Posted',
description: `Hours that have been posted to the Job Ledger Entry. Posting creates cost and price entries based on the Resource's Unit Cost and Unit Price. Days = hours ÷ ${hoursPerDay}.`,
source: 'BC API: /timeEntries → quantity',
},
},
];

Expand Down Expand Up @@ -259,8 +225,9 @@ export function ProjectKPICards() {
);

// Financial KPIs - 4 cards matching BC structure
// Budget Cost and Actual Cost are internal (hideable)
// Billable Price and Invoiced Price are customer-facing
// Budget Cost and Actual Cost are internal; Billable Price and Invoiced Price
// are customer-facing. Each card's amount can be hidden individually via its
// Eye toggle (see hiddenCards); the value/breakdown masking happens at render.
const financialKpis: {
label: string;
value: string;
Expand All @@ -269,51 +236,24 @@ export function ProjectKPICards() {
icon: typeof BanknotesIcon;
color: string;
isInternal?: boolean;
isHidden?: boolean;
tooltip: {
title: string;
description: string;
formula?: string;
source: string;
};
}[] = [
{
label: 'Budget Cost',
value: showCosts ? formatCurrency(budgetCost, currencyCode) : '•••••',
subLabel: showCosts ? jobPlanningLinesLink : 'Hidden',
breakdown: showCosts ? budgetBreakdown : null,
value: formatCurrency(budgetCost, currencyCode),
subLabel: jobPlanningLinesLink,
breakdown: budgetBreakdown,
icon: BanknotesIcon,
color: 'text-amber-400',
isInternal: true,
isHidden: !showCosts,
tooltip: {
title: 'Budget Cost (Internal)',
description:
'Internal cost budget from Job Planning Lines. This is what the project is expected to cost the company. Broken down by Resource (labour), Item (materials), and G/L Account (overhead).',
formula: 'quantity × unitCost',
source: 'BC API: /jobPlanningLines → totalCost',
},
},
{
label: 'Actual Cost',
value: showCosts ? formatCurrency(actualCost, currencyCode) : '•••••',
subLabel: showCosts ? jobLedgerEntryLink : 'Hidden',
breakdown: showCosts ? actualBreakdown : null,
value: formatCurrency(actualCost, currencyCode),
subLabel: jobLedgerEntryLink,
breakdown: actualBreakdown,
icon: BanknotesIcon,
color: showCosts
? actualCost > budgetCost && budgetCost > 0
? 'text-red-400'
: 'text-amber-400'
: 'text-gray-500',
color: actualCost > budgetCost && budgetCost > 0 ? 'text-red-400' : 'text-amber-400',
isInternal: true,
isHidden: !showCosts,
tooltip: {
title: 'Actual Cost (Internal)',
description:
"Internal cost incurred from posted Job Ledger Entries. Calculated when timesheets are posted using each Resource's Unit Cost. Shows £0 if timesheets are approved but not yet posted.",
formula: 'posted hours × Resource Unit Cost',
source: 'BC API: /timeEntries → totalCost',
},
},
{
label: 'Billable Price',
Expand All @@ -322,13 +262,6 @@ export function ProjectKPICards() {
breakdown: billableBreakdown,
icon: CurrencyPoundIcon,
color: 'text-blue-400',
tooltip: {
title: 'Billable Price (Customer)',
description:
'Customer quote/expected revenue from Job Planning Lines. This is what the customer is expected to pay. Only includes lines where lineType is "Billable" or "Both Budget and Billable".',
formula: 'quantity × unitPrice',
source: 'BC API: /jobPlanningLines → totalPrice',
},
},
{
label: 'Invoiced Price',
Expand All @@ -337,50 +270,49 @@ export function ProjectKPICards() {
breakdown: invoicedBreakdown,
icon: CurrencyPoundIcon,
color: 'text-green-400',
tooltip: {
title: 'Invoiced Price (Customer)',
description:
"Amount actually invoiced to the customer from Job Ledger Entry. Calculated when timesheets are posted using each Resource's Unit Price.",
formula: 'posted hours × Resource Unit Price',
source: 'BC API: /timeEntries → totalPrice',
},
},
];

return (
<div className="space-y-4">
{/* Row 1: Hours (4 cards) */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 print:grid-cols-4">
{hoursKpis.map((kpi) => (
<Card key={kpi.label} variant="bordered" className="relative p-4">
<div className="absolute top-3 right-3">
<InfoTooltip
title={kpi.tooltip.title}
description={kpi.tooltip.description}
source={kpi.tooltip.source}
formula={'formula' in kpi.tooltip ? kpi.tooltip.formula : undefined}
/>
</div>
<div className="flex items-start gap-3">
<div className={`bg-dark-600 rounded-lg p-2 ${kpi.color}`}>
<kpi.icon className="h-5 w-5" />
{hoursKpis.map((kpi) => {
const isHidden = hiddenCards.has(kpi.label);
return (
<Card key={kpi.label} variant="bordered" className="relative p-4">
<div className="absolute top-3 right-3">
<VisibilityToggle
hidden={isHidden}
onToggle={() => toggleCardHidden(kpi.label)}
label={kpi.label}
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm text-gray-400">{kpi.label}</p>
<p className="text-2xl font-bold text-white">{kpi.value}</p>
<p className="mt-1 text-xs text-gray-500">{kpi.subLabel}</p>
{kpi.progress !== undefined && (
<div className="bg-dark-600 mt-2 h-1.5 w-full overflow-hidden rounded-full">
<div
className={`h-full rounded-full transition-all ${kpi.progressColor}`}
style={{ width: `${kpi.progress}%` }}
/>
</div>
)}
<div className="flex items-start gap-3">
<div
className={`bg-dark-600 rounded-lg p-2 ${kpi.color} ${isHidden ? 'opacity-50' : ''}`}
>
<kpi.icon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm text-gray-400">{kpi.label}</p>
<p className={`text-2xl font-bold ${isHidden ? 'text-gray-600' : 'text-white'}`}>
{isHidden ? maskedValue : kpi.value}
</p>
<p className="mt-1 text-xs text-gray-500">{kpi.subLabel}</p>
{kpi.progress !== undefined && !isHidden && (
<div className="bg-dark-600 mt-2 h-1.5 w-full overflow-hidden rounded-full">
<div
className={`h-full rounded-full transition-all ${kpi.progressColor}`}
style={{ width: `${kpi.progress}%` }}
/>
</div>
)}
</div>
</div>
</div>
</Card>
))}
</Card>
);
})}
</div>

{/* Row 2: Financials (4 cards matching BC) - hidden in print only for "Without Financials" export */}
Expand All @@ -391,16 +323,15 @@ export function ProjectKPICards() {
)}
>
{financialKpis.map((kpi) => {
const isHidden = 'isHidden' in kpi && kpi.isHidden;
const breakdown = 'breakdown' in kpi ? kpi.breakdown : null;
const isHidden = hiddenCards.has(kpi.label);
const breakdown = kpi.breakdown;
return (
<Card key={kpi.label} variant="bordered" className="relative p-4">
<div className={`absolute top-3 right-3 ${isHidden ? 'opacity-50' : ''}`}>
<InfoTooltip
title={kpi.tooltip.title}
description={kpi.tooltip.description}
source={kpi.tooltip.source}
formula={'formula' in kpi.tooltip ? kpi.tooltip.formula : undefined}
<div className="absolute top-3 right-3">
<VisibilityToggle
hidden={isHidden}
onToggle={() => toggleCardHidden(kpi.label)}
label={kpi.label}
/>
</div>
<div className="flex items-start gap-3">
Expand All @@ -421,7 +352,7 @@ export function ProjectKPICards() {
)}
</div>
<p className={`text-2xl font-bold ${isHidden ? 'text-gray-600' : 'text-white'}`}>
{kpi.value}
{isHidden ? maskedValue : kpi.value}
</p>
{/* Breakdown by type - always show all 3 lines */}
{breakdown && !isHidden && (
Expand Down
Loading
Loading