feat(planning): ship holiday-aware planning and assistant upgrades
This commit is contained in:
@@ -36,8 +36,91 @@ type ChargeabilityRow = {
|
||||
chargeabilityTarget: number;
|
||||
actualChargeability: number;
|
||||
expectedChargeability: number;
|
||||
countryCode?: string | null;
|
||||
countryName?: string | null;
|
||||
federalState?: string | null;
|
||||
metroCityName?: string | null;
|
||||
derivation?: {
|
||||
weeklyAvailabilityHours: number;
|
||||
baseWorkingDays: number;
|
||||
effectiveWorkingDayEquivalent: number;
|
||||
baseAvailableHours: number;
|
||||
effectiveAvailableHours: number;
|
||||
publicHolidayCount: number;
|
||||
publicHolidayWorkdayCount: number;
|
||||
publicHolidayHoursDeduction: number;
|
||||
absenceDayEquivalent: number;
|
||||
absenceHoursDeduction: number;
|
||||
actualBookedHours: number;
|
||||
expectedBookedHours: number;
|
||||
targetBookedHours: number;
|
||||
unassignedHours: number;
|
||||
};
|
||||
};
|
||||
|
||||
function formatHours(value: number | undefined): string {
|
||||
if (value === undefined) return "—";
|
||||
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}h`;
|
||||
}
|
||||
|
||||
function formatDayEquivalent(value: number | undefined): string {
|
||||
if (value === undefined) return "—";
|
||||
return Number.isInteger(value) ? `${value}` : value.toFixed(1);
|
||||
}
|
||||
|
||||
function MetricPill({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2 py-0.5 text-[10px] font-medium text-gray-600 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300">
|
||||
<span className="text-gray-400 dark:text-gray-500">{label}</span>
|
||||
<span className="text-gray-700 dark:text-gray-200">{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatLocation(row: ChargeabilityRow): string {
|
||||
const parts = [row.countryCode ?? row.countryName ?? null, row.federalState ?? null, row.metroCityName ?? null]
|
||||
.filter((part): part is string => Boolean(part));
|
||||
return parts.length > 0 ? parts.join(" / ") : "No calendar context";
|
||||
}
|
||||
|
||||
function ChargeabilityContextLine({ row }: { row: ChargeabilityRow }) {
|
||||
const derivation = row.derivation;
|
||||
|
||||
if (!derivation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-1.5 space-y-1.5 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<MetricPill label="Loc" value={formatLocation(row)} />
|
||||
<MetricPill label="Week" value={formatHours(derivation.weeklyAvailabilityHours)} />
|
||||
<MetricPill label="Target" value={formatHours(derivation.targetBookedHours)} />
|
||||
</div>
|
||||
<div className="grid gap-x-3 gap-y-0.5 sm:grid-cols-2">
|
||||
<div>
|
||||
Days {formatDayEquivalent(derivation.baseWorkingDays)} {"->"} {formatDayEquivalent(derivation.effectiveWorkingDayEquivalent)}
|
||||
</div>
|
||||
<div>
|
||||
Holidays {derivation.publicHolidayWorkdayCount}/{derivation.publicHolidayCount} ({formatHours(derivation.publicHolidayHoursDeduction)})
|
||||
</div>
|
||||
<div>
|
||||
Base {formatHours(derivation.baseAvailableHours)} {"->"} Effective {formatHours(derivation.effectiveAvailableHours)}
|
||||
</div>
|
||||
<div>
|
||||
Absence {formatDayEquivalent(derivation.absenceDayEquivalent)} ({formatHours(derivation.absenceHoursDeduction)})
|
||||
</div>
|
||||
<div>
|
||||
Actual {formatHours(derivation.actualBookedHours)} · Expected {formatHours(derivation.expectedBookedHours)}
|
||||
</div>
|
||||
<div>
|
||||
Free {formatHours(derivation.unassignedHours)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterDropdown({ label, children }: { label: string; children: ReactNode }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -74,7 +157,13 @@ function FilterDropdown({ label, children }: { label: string; children: ReactNod
|
||||
}
|
||||
|
||||
export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetProps) {
|
||||
const config = _config as { topN?: number; watchlistThreshold?: number; chapter?: string; includeProposed?: boolean };
|
||||
const config = _config as {
|
||||
topN?: number;
|
||||
watchlistThreshold?: number;
|
||||
chapter?: string;
|
||||
includeProposed?: boolean;
|
||||
showDetails?: boolean;
|
||||
};
|
||||
const { chapters } = useWidgetFilterOptions();
|
||||
|
||||
const widgetFilters = useMemo<WidgetFilter[]>(
|
||||
@@ -86,6 +175,7 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
|
||||
);
|
||||
|
||||
const includeProposed = !!config.includeProposed;
|
||||
const showDetails = !!config.showDetails;
|
||||
const chapterFilter = (config.chapter as string) ?? "";
|
||||
const [showDeparted, setShowDeparted] = useState(false);
|
||||
const [selectedCountryIds, setSelectedCountryIds] = useState<string[]>([]);
|
||||
@@ -266,7 +356,7 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
|
||||
<p className="text-xs text-gray-400 flex items-center gap-1">
|
||||
Period: {month}
|
||||
<InfoTooltip
|
||||
content="Chargeability is calculated for the current calendar month. Available hours are based on each person's weekly schedule (WeekdayAvailability). Watchlist threshold: 15 percentage points below target."
|
||||
content="Chargeability is calculated for the current calendar month. Available hours are derived from each person's weekly schedule and reduced by regional public holidays plus approved absences. Watchlist threshold: 15 percentage points below target."
|
||||
width="w-72"
|
||||
/>
|
||||
</p>
|
||||
@@ -330,7 +420,7 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
|
||||
>
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white flex items-center">
|
||||
Top Chargeability
|
||||
<InfoTooltip content="Resources ranked by highest actual chargeability this month. Chargeability = chargeable booked hours / total available hours." />
|
||||
<InfoTooltip content="Resources ranked by highest actual chargeability this month. Chargeability = chargeable booked hours divided by holiday- and absence-adjusted available hours." />
|
||||
<span className="ml-1 font-normal normal-case text-gray-400">
|
||||
{visibleTop.length}/{top.length}
|
||||
</span>
|
||||
@@ -390,18 +480,33 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
|
||||
{visibleTop.map((r, i) => (
|
||||
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/40">
|
||||
<td className="px-2 py-1 text-gray-400">{i + 1}</td>
|
||||
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[120px]">
|
||||
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[240px] align-top">
|
||||
<div className="truncate">
|
||||
<span title={r.displayName}>{r.displayName}</span>
|
||||
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
|
||||
</div>
|
||||
{showDetails ? <ChargeabilityContextLine row={r} /> : null}
|
||||
<UtilizationBar percent={r.actualChargeability} />
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right font-semibold text-green-700 dark:text-green-400">
|
||||
<AnimatedNumber value={r.actualChargeability} suffix="%" />
|
||||
<td className="px-2 py-1 text-right font-semibold text-green-700 dark:text-green-400 align-top">
|
||||
<div>
|
||||
<AnimatedNumber value={r.actualChargeability} suffix="%" />
|
||||
</div>
|
||||
{showDetails ? (
|
||||
<div className="mt-1 text-[10px] font-normal leading-4 text-gray-500 dark:text-gray-400">
|
||||
{formatHours(r.derivation?.actualBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right text-gray-400">
|
||||
<AnimatedNumber value={r.expectedChargeability} suffix="%" />
|
||||
<td className="px-2 py-1 text-right text-gray-400 align-top">
|
||||
<div>
|
||||
<AnimatedNumber value={r.expectedChargeability} suffix="%" />
|
||||
</div>
|
||||
{showDetails ? (
|
||||
<div className="mt-1 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
|
||||
{formatHours(r.derivation?.expectedBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -473,18 +578,33 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{visibleWatchlist.map((r) => (
|
||||
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/40">
|
||||
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[140px]">
|
||||
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[240px] align-top">
|
||||
<div className="truncate">
|
||||
<span title={r.displayName}>{r.displayName}</span>
|
||||
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
|
||||
</div>
|
||||
{showDetails ? <ChargeabilityContextLine row={r} /> : null}
|
||||
<UtilizationBar percent={r.actualChargeability} />
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right font-semibold text-red-600 dark:text-red-400">
|
||||
<AnimatedNumber value={r.actualChargeability} suffix="%" />
|
||||
<td className="px-2 py-1 text-right font-semibold text-red-600 dark:text-red-400 align-top">
|
||||
<div>
|
||||
<AnimatedNumber value={r.actualChargeability} suffix="%" />
|
||||
</div>
|
||||
{showDetails ? (
|
||||
<div className="mt-1 text-[10px] font-normal leading-4 text-gray-500 dark:text-gray-400">
|
||||
{formatHours(r.derivation?.actualBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right text-gray-400">
|
||||
<AnimatedNumber value={r.chargeabilityTarget} suffix="%" />
|
||||
<td className="px-2 py-1 text-right text-gray-400 align-top">
|
||||
<div>
|
||||
<AnimatedNumber value={r.chargeabilityTarget} suffix="%" />
|
||||
</div>
|
||||
{showDetails ? (
|
||||
<div className="mt-1 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
|
||||
Target {formatHours(r.derivation?.targetBookedHours)} · Free {formatHours(r.derivation?.unassignedHours)}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user