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:
2026-01-10 02:16:55 +01:00
parent ea1c84262c
commit ca21b9538d
54 changed files with 13444 additions and 1789 deletions

View 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;