Files
headroom/frontend/src/lib/components/capacity/CapacityCalendar.svelte
Santhosh Janardhanan b821713cc7 fix(capacity): stabilize PTO flows and calendar consistency
Make PTO creation immediately approved, add PTO deletion, and ensure cache invalidation updates individual/team/revenue capacity consistently.

Harden holiday duplicate handling (422), support PTO-day availability overrides without disabling edits, and align tests plus OpenSpec artifacts with the new behavior.
2026-02-19 22:47:39 -05:00

170 lines
6.0 KiB
Svelte

<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { Capacity, Holiday, PTO } from '$lib/types/capacity';
const weekdayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
export let month: string;
export let capacity: Capacity | null = null;
export let holidays: Holiday[] = [];
export let ptos: PTO[] = [];
const dispatch = createEventDispatcher();
let overrides: Record<string, number> = {};
let previousMonth: string | null = null;
$: if (month && month !== previousMonth) {
overrides = {};
previousMonth = month;
}
function toIso(date: Date) {
return date.toISOString().split('T')[0];
}
function buildPtoDates(records: PTO[]): Set<string> {
const set = new Set<string>();
records.forEach((pto) => {
const start = new Date(pto.start_date);
const end = new Date(pto.end_date);
const cursor = new Date(start);
while (cursor <= end) {
set.add(toIso(cursor));
cursor.setDate(cursor.getDate() + 1);
}
});
return set;
}
function availabilityLabel(value: number): string {
if (value >= 0.99) return 'Full day';
if (value >= 0.49) return 'Half day';
return 'Off';
}
$: parsedMonth = (() => {
if (!month) return null;
const [year, monthPart] = month.split('-').map(Number);
if (!year || !monthPart) return null;
return { year, index: monthPart - 1 };
})();
$: detailsMap = new Map((capacity?.details ?? []).map((detail) => [detail.date, detail]));
$: holidayMap = new Map(holidays.map((holiday) => [holiday.date, holiday.name]));
$: ptoDates = buildPtoDates(ptos);
$: calendarContext = parsedMonth
? (() => {
const { year, index } = parsedMonth;
const first = new Date(year, index, 1);
const totalDays = new Date(year, index + 1, 0).getDate();
const startWeekday = first.getDay();
const leading = Array.from({ length: startWeekday });
const trailing = Array.from({ length: ((7 - ((leading.length + totalDays) % 7)) % 7) });
const days = Array.from({ length: totalDays }, (_, i) => {
const current = new Date(year, index, i + 1);
const iso = toIso(current);
const dayOfWeek = current.getDay();
const detail = detailsMap.get(iso);
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
const isHoliday = holidayMap.has(iso);
const holidayName = holidayMap.get(iso);
const isPto = ptoDates.has(iso) || !!detail?.is_pto;
const isBlocked = isWeekend || isHoliday;
const fallbackAvailability = isWeekend ? 0 : isPto ? 0 : 1;
const sourceAvailability = overrides[iso] ?? detail?.availability ?? fallbackAvailability;
const availability = isPto ? sourceAvailability : (isBlocked ? 0 : sourceAvailability);
const effectiveHours = Math.round(availability * 8 * 10) / 10;
return {
iso,
day: i + 1,
dayName: weekdayLabels[dayOfWeek],
isWeekend,
isHoliday,
holidayName,
isPto,
isBlocked,
availability,
effectiveHours,
defaultAvailability: fallbackAvailability
};
});
return { leading, days, trailing };
})()
: { leading: [], days: [], trailing: [] };
function handleAvailabilityChange(date: string, value: number) {
overrides = { ...overrides, [date]: value };
dispatch('availabilitychange', { date, availability: value });
}
</script>
<section class="space-y-4" data-testid="capacity-calendar">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="text-lg font-semibold">Capacity Calendar</h2>
<p class="text-sm text-base-content/70">{month || 'Select a month to view calendar'}</p>
</div>
<span class="text-xs text-base-content/60">Working days: {capacity?.working_days ?? 0}</span>
</div>
<div class="grid grid-cols-7 gap-2 text-xs font-semibold uppercase text-center text-base-content/60">
{#each weekdayLabels as label}
<div>{label}</div>
{/each}
</div>
<div class="grid grid-cols-7 gap-2">
{#each calendarContext.leading as _, idx}
<div class="h-32 rounded-lg border border-base-300 bg-base-100" aria-hidden="true" />
{/each}
{#each calendarContext.days as day}
<div
class={`flex flex-col rounded-xl border p-3 text-sm shadow-sm transition ${
day.isWeekend ? 'border-dashed border-base-300 bg-base-200' : 'border-base-200 bg-base-100'
} ${day.isHoliday ? 'border-error bg-error/10' : ''}`}
data-date={day.iso}
>
<div class="flex items-center justify-between">
<span class="text-base font-semibold">{day.day}</span>
<span class="text-[10px] uppercase tracking-wide text-base-content/40">{day.dayName}</span>
</div>
<div class="mt-1 space-y-1 text-xs text-base-content/70">
<div>{availabilityLabel(day.availability)}</div>
<div>{day.effectiveHours} hrs</div>
{#if day.isHoliday}
<div class="badge badge-info badge-sm">{day.holidayName ?? 'Holiday'}</div>
{/if}
{#if day.isPto}
<div class="badge badge-warning badge-sm">PTO</div>
{/if}
</div>
<select
class="select select-sm mt-3"
aria-label={`Availability for ${day.iso}`}
disabled={day.isWeekend || day.isHoliday}
on:change={(event) => handleAvailabilityChange(day.iso, Number(event.currentTarget.value))}
>
<option value="1" selected={day.availability === 1}>Full day (1.0)</option>
<option value="0.5" selected={day.availability === 0.5}>Half day (0.5)</option>
<option value="0" selected={day.availability === 0}>Off (0.0)</option>
</select>
</div>
{/each}
{#each calendarContext.trailing as _, idx}
<div class="h-32 rounded-lg border border-base-300 bg-base-100" aria-hidden="true" />
{/each}
</div>
</section>