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.
170 lines
6.0 KiB
Svelte
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>
|