- 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
269 lines
11 KiB
TypeScript
269 lines
11 KiB
TypeScript
/**
|
||
* 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;
|
||
|