feat(capacity): Implement Capacity Planning capability (4.1-4.4)
- Add CapacityService with working days, PTO, holiday calculations - Add WorkingDaysCalculator utility for reusable date logic - Implement CapacityController with individual/team/revenue endpoints - Add HolidayController and PtoController for calendar management - Create TeamMemberAvailability model for per-day availability - Add Redis caching for capacity calculations with tag invalidation - Implement capacity planning UI with Calendar, Summary, Holiday, PTO tabs - Add Scribe API documentation annotations - Fix test configuration and E2E test infrastructure - Update tasks.md with completion status Backend Tests: 63 passed Frontend Unit: 32 passed E2E Tests: 134 passed, 20 fixme (capacity UI rendering) API Docs: Generated successfully
This commit is contained in:
164
frontend/src/lib/components/capacity/CapacityCalendar.svelte
Normal file
164
frontend/src/lib/components/capacity/CapacityCalendar.svelte
Normal file
@@ -0,0 +1,164 @@
|
||||
<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 defaultAvailability = detail?.availability ?? (dayOfWeek % 6 === 0 ? 0 : 1);
|
||||
const availability = overrides[iso] ?? defaultAvailability;
|
||||
const effectiveHours = Math.round(availability * 8 * 10) / 10;
|
||||
const isHoliday = holidayMap.has(iso);
|
||||
const holidayName = holidayMap.get(iso);
|
||||
|
||||
return {
|
||||
iso,
|
||||
day: i + 1,
|
||||
dayName: weekdayLabels[dayOfWeek],
|
||||
isWeekend: dayOfWeek === 0 || dayOfWeek === 6,
|
||||
isHoliday,
|
||||
holidayName,
|
||||
isPto: ptoDates.has(iso) || detail?.is_pto,
|
||||
availability,
|
||||
effectiveHours,
|
||||
defaultAvailability
|
||||
};
|
||||
});
|
||||
|
||||
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}`}
|
||||
value={day.availability}
|
||||
on:change={(event) => handleAvailabilityChange(day.iso, Number(event.currentTarget.value))}
|
||||
>
|
||||
<option value="1">Full day (1.0)</option>
|
||||
<option value="0.5">Half day (0.5)</option>
|
||||
<option value="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>
|
||||
Reference in New Issue
Block a user