Improve Team-indeling dashboard UI and cache invalidation
- Replace 'TEAM' label with Type attribute (Business/Enabling/Staf) in team blocks - Make Type labels larger (text-sm) and brighter colors - Make SUBTEAM label less bright (indigo-300) and smaller (text-[10px]) - Add 'FTE' suffix to bandbreedte values in header and application blocks - Add Platform and Connected Device labels to application blocks - Show Platform FTE and Workloads FTE separately in Platform blocks - Add spacing between Regiemodel letter and count value - Add cache invalidation for Team Dashboard when applications are updated - Enrich team references with Type attribute in getSubteamToTeamMapping
This commit is contained in:
258
frontend/src/components/EffortDisplay.tsx
Normal file
258
frontend/src/components/EffortDisplay.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* 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 };
|
||||
|
||||
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
|
||||
{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;
|
||||
|
||||
Reference in New Issue
Block a user