Files
cmdb-insight/frontend/src/components/EffortDisplay.tsx
Bert Hausmans a7f8301196 Add database adapter system, production deployment configs, and new dashboard components
- Add PostgreSQL and SQLite database adapters with factory pattern
- Add migration script for SQLite to PostgreSQL
- Add production Dockerfiles and docker-compose configs
- Add deployment documentation and scripts
- Add BIA sync dashboard and matching service
- Add data completeness configuration and components
- Add new dashboard components (BusinessImportanceComparison, ComplexityDynamics, etc.)
- Update various services and routes
- Remove deprecated management-parameters.json and taxonomy files
2026-01-14 00:38:40 +01:00

269 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* EffortDisplay - Shared component for displaying FTE effort calculations
*
* Used in:
* - ApplicationInfo.tsx (detail page)
* - GovernanceModelHelper.tsx (governance helper page)
*/
import type { EffortCalculationBreakdown } from '../types';
export interface EffortDisplayProps {
/** The effective FTE value (after override if applicable) */
effectiveFte: number | null;
/** The calculated FTE value (before override) */
calculatedFte?: number | null;
/** Override FTE value if set */
overrideFte?: number | null;
/** Full breakdown from effort calculation */
breakdown?: EffortCalculationBreakdown | null;
/** Whether this is a preview (unsaved changes) */
isPreview?: boolean;
/** Show full details (expanded view) */
showDetails?: boolean;
/** Show override input field */
showOverrideInput?: boolean;
/** Callback when override value changes */
onOverrideChange?: (value: number | null) => void;
}
// Display-only constants (for showing the formula, NOT for calculation)
// The actual calculation happens in backend/src/services/effortCalculation.ts
const HOURS_PER_WEEK_DISPLAY = 36;
const WORK_WEEKS_PER_YEAR_DISPLAY = 46;
const DECLARABLE_PERCENTAGE_DISPLAY = 0.75;
export function EffortDisplay({
effectiveFte,
calculatedFte,
overrideFte,
breakdown,
isPreview = false,
showDetails = true,
showOverrideInput = false,
onOverrideChange,
}: EffortDisplayProps) {
const hasOverride = overrideFte !== null && overrideFte !== undefined;
const hasBreakdown = breakdown !== null && breakdown !== undefined;
// Extract breakdown values
const baseEffort = breakdown?.baseEffort ?? null;
const baseEffortMin = breakdown?.baseEffortMin ?? null;
const baseEffortMax = breakdown?.baseEffortMax ?? null;
const numberOfUsersFactor = breakdown?.numberOfUsersFactor ?? { value: 1.0, name: null };
const dynamicsFactor = breakdown?.dynamicsFactor ?? { value: 1.0, name: null };
const complexityFactor = breakdown?.complexityFactor ?? { value: 1.0, name: null };
// Calculate final min/max FTE by applying factors to base min/max
const factorMultiplier = numberOfUsersFactor.value * dynamicsFactor.value * complexityFactor.value;
const finalMinFTE = baseEffortMin !== null ? baseEffortMin * factorMultiplier : null;
const finalMaxFTE = baseEffortMax !== null ? baseEffortMax * factorMultiplier : null;
const governanceModelName = breakdown?.governanceModelName ?? breakdown?.governanceModel ?? null;
const applicationTypeName = breakdown?.applicationType ?? null;
const businessImpactAnalyse = breakdown?.businessImpactAnalyse ?? null;
const applicationManagementHosting = breakdown?.applicationManagementHosting ?? null;
const warnings = breakdown?.warnings ?? [];
const errors = breakdown?.errors ?? [];
const usedDefaults = breakdown?.usedDefaults ?? [];
const requiresManualAssessment = breakdown?.requiresManualAssessment ?? false;
const isFixedFte = breakdown?.isFixedFte ?? false;
// Use hours from backend breakdown (calculated in effortCalculation.ts)
// Only fall back to local calculation if breakdown is not available
const declarableHoursPerYear = breakdown?.hoursPerYear ?? (effectiveFte !== null
? HOURS_PER_WEEK_DISPLAY * WORK_WEEKS_PER_YEAR_DISPLAY * effectiveFte * DECLARABLE_PERCENTAGE_DISPLAY
: 0);
const hoursPerMonth = breakdown?.hoursPerMonth ?? declarableHoursPerYear / 12;
const hoursPerWeekCalculated = breakdown?.hoursPerWeek ?? declarableHoursPerYear / WORK_WEEKS_PER_YEAR_DISPLAY;
const minutesPerWeek = hoursPerWeekCalculated * 60;
// For display of netto hours (before declarable percentage)
const netHoursPerYear = effectiveFte !== null
? HOURS_PER_WEEK_DISPLAY * WORK_WEEKS_PER_YEAR_DISPLAY * effectiveFte
: 0;
// No effort calculated
if (effectiveFte === null || effectiveFte === undefined) {
if (errors.length > 0) {
return (
<div className="space-y-2">
<div className="bg-red-50 border border-red-200 rounded-lg p-2">
{errors.map((error, i) => (
<div key={i} className="text-sm text-red-700 flex items-start gap-1">
<span></span>
<span>{error}</span>
</div>
))}
</div>
<span className="text-sm text-gray-400">Niet berekend - configuratie onvolledig</span>
</div>
);
}
return <span className="text-sm text-gray-400">Niet berekend</span>;
}
return (
<div className="space-y-2">
{/* Errors */}
{errors.length > 0 && (
<div className="bg-red-50 border border-red-200 rounded-lg p-2 mb-2">
{errors.map((error, i) => (
<div key={i} className="text-sm text-red-700 flex items-start gap-1">
<span></span>
<span>{error}</span>
</div>
))}
</div>
)}
{/* Warnings */}
{warnings.length > 0 && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-2 mb-2">
{warnings.map((warning, i) => (
<div key={i} className="text-sm text-yellow-700 flex items-start gap-1">
<span>{warning.startsWith('⚠️') || warning.startsWith('') ? '' : ''}</span>
<span>{warning}</span>
</div>
))}
</div>
)}
{/* Main FTE display */}
<div className="text-lg font-semibold text-gray-900">
{effectiveFte.toFixed(2)} FTE
{finalMinFTE !== null && finalMaxFTE !== null && finalMinFTE !== finalMaxFTE && (
<span className="text-xs text-gray-500 ml-1 font-normal">
(bandbreedte: {finalMinFTE.toFixed(2)} - {finalMaxFTE.toFixed(2)})
</span>
)}
{hasOverride && (
<span className="ml-2 text-sm font-normal text-orange-600">(Override)</span>
)}
{isPreview && !hasOverride && (
<span className="ml-2 text-sm font-normal text-blue-600">(voorvertoning)</span>
)}
{isFixedFte && (
<span className="ml-2 text-sm font-normal text-purple-600">(vast)</span>
)}
{requiresManualAssessment && (
<span className="ml-2 text-sm font-normal text-orange-600">(handmatige beoordeling)</span>
)}
</div>
{/* Show calculated value if override is active */}
{hasOverride && calculatedFte !== null && calculatedFte !== undefined && (
<div className="text-sm text-gray-600">
Berekende waarde: <span className="font-medium">{calculatedFte.toFixed(2)} FTE</span>
</div>
)}
{/* Override input */}
{showOverrideInput && onOverrideChange && (
<div className="flex items-center gap-2 mt-2">
<label className="text-sm text-gray-600">Override FTE:</label>
<input
type="number"
step="0.01"
min="0"
value={overrideFte !== null ? overrideFte : ''}
onChange={(e) => {
const value = e.target.value;
if (value === '') {
onOverrideChange(null);
} else {
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
onOverrideChange(numValue);
}
}
}}
className="w-24 px-2 py-1 border border-gray-300 rounded text-sm"
placeholder="Leeg"
/>
</div>
)}
{showDetails && baseEffort !== null && (
<div className="pt-2 border-t border-gray-200 space-y-1 text-sm text-gray-600">
{/* Base FTE with range */}
<div className="font-medium text-gray-700 mb-2">
Basis FTE: {baseEffort.toFixed(2)} FTE
{baseEffortMin !== null && baseEffortMax !== null && baseEffortMin !== baseEffortMax && (
<span className="text-xs text-gray-500 ml-1">
(range: {baseEffortMin.toFixed(2)} - {baseEffortMax.toFixed(2)})
</span>
)}
</div>
{/* Lookup path */}
<div className="pl-2 space-y-1 text-xs text-gray-500 border-l-2 border-gray-300">
<div className="flex items-center gap-1">
<span>ICT Governance Model:</span>
<span className="font-medium text-gray-700">{governanceModelName || 'Niet ingesteld'}</span>
{usedDefaults.includes('regiemodel') && <span className="text-orange-500">(default)</span>}
</div>
<div className="flex items-center gap-1">
<span>Application Type:</span>
<span className="font-medium text-gray-700">{applicationTypeName || 'Niet ingesteld'}</span>
{usedDefaults.includes('applicationType') && <span className="text-orange-500">(default)</span>}
</div>
<div className="flex items-center gap-1">
<span>Business Impact Analyse:</span>
<span className="font-medium text-gray-700">{businessImpactAnalyse || 'Niet ingesteld'}</span>
{usedDefaults.includes('businessImpact') && <span className="text-orange-500">(default)</span>}
</div>
<div className="flex items-center gap-1">
<span>Hosting:</span>
<span className="font-medium text-gray-700">{applicationManagementHosting || 'Niet ingesteld'}</span>
{usedDefaults.includes('hosting') && <span className="text-orange-500">(default)</span>}
</div>
</div>
{/* Factors */}
<div className="font-medium text-gray-700 mt-2 mb-1">Factoren:</div>
<div>
Number of Users: × {numberOfUsersFactor.value.toFixed(2)}
{numberOfUsersFactor.name && ` (${numberOfUsersFactor.name})`}
</div>
<div>
Dynamics Factor: × {dynamicsFactor.value.toFixed(2)}
{dynamicsFactor.name && ` (${dynamicsFactor.name})`}
</div>
<div>
Complexity Factor: × {complexityFactor.value.toFixed(2)}
{complexityFactor.name && ` (${complexityFactor.name})`}
</div>
{/* Hours breakdown */}
<div className="font-medium text-gray-700 mt-3 mb-1 pt-2 border-t border-gray-200">
Uren per jaar (écht inzetbaar):
</div>
<div className="pl-2 space-y-1 text-xs text-gray-600 bg-blue-50 rounded p-2 border-l-2 border-blue-300">
<div className="font-medium text-gray-700">
{declarableHoursPerYear.toFixed(1)} uur per jaar
</div>
<div className="text-gray-500 mt-1">
{hoursPerMonth.toFixed(1)} uur per maand
</div>
<div className="text-gray-500">
{hoursPerWeekCalculated.toFixed(2)} uur per week
</div>
<div className="text-gray-500">
{minutesPerWeek.toFixed(0)} minuten per week
</div>
<div className="text-xs text-gray-400 mt-2 pt-2 border-t border-gray-200">
<div>Berekening: {HOURS_PER_WEEK_DISPLAY} uur/week × {WORK_WEEKS_PER_YEAR_DISPLAY} weken × {effectiveFte.toFixed(2)} FTE × {DECLARABLE_PERCENTAGE_DISPLAY * 100}% = {declarableHoursPerYear.toFixed(1)} uur/jaar</div>
<div className="mt-1">(Netto: {netHoursPerYear.toFixed(1)} uur/jaar, waarvan {DECLARABLE_PERCENTAGE_DISPLAY * 100}% declarabel)</div>
</div>
</div>
</div>
)}
</div>
);
}
export default EffortDisplay;