Een periode kiezen (sprint, verhuur, verlof, campagne) met daaronder een samenvatting: begin, einde, duur in dagen en een extra vak dat het project invult (sprintnummer, prijs, nachten). Receptuur: date-range-calendar + samenvatting.
Plan de sprint
september 2026
oktober 2026
- Begin
- —
- Einde
- —
- Duur
- —
blocks/planning/period-planner.tsx
/**
* PeriodPlanner — een periode kiezen (sprint, verhuur, verlof, campagne) met
* daaronder een samenvatting: begin, einde, duur in dagen, en een extra vak
* dat het project invult (sprintnummer, prijs, aantal nachten).
*
* Receptuur: de periodekiezer is date-range-calendar (FC-0004) — twee maanden,
* onbeschikbare dagen, ISO-strings. De bron (react-day-picker + date-fns +
* badge) rekende een sprintnummer uit vanaf 1 januari; dat is domein en zit
* hier in het `extra`-slot, met een `sprintNumber`-hulpfunctie ernaast voor
* wie precies dat wil.
*/
"use client";
import { useState } from "react";
import type * as React from "react";
import { DateRangeCalendar, type DateRange, type UnavailablePeriod } from "../../primitives/ui/date-range-calendar";
export interface PeriodPlannerLabels {
start: string;
end: string;
duration: string;
/** {n} = aantal dagen */
days: string;
none: string;
}
export interface PeriodPlannerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "title"> {
value?: DateRange;
onChange?: (range: { start: string; end: string }) => void;
unavailable?: UnavailablePeriod[];
onInvalidRange?: () => void;
months?: 1 | 2;
locale?: string;
title?: string;
/** Het vierde vak: label + waarde, afgeleid van de gekozen periode (sprintnummer, prijs, …). */
extra?: (range: { start: string; end: string; days: number }) => { label: string; value: React.ReactNode } | null;
labels?: Partial<PeriodPlannerLabels>;
}
const STANDAARD: PeriodPlannerLabels = { start: "Begin", end: "Einde", duration: "Duur", days: "{n} dagen", none: "—" };
const dagen = (a: string, b: string) => Math.round((Date.parse(b) - Date.parse(a)) / 86_400_000) + 1;
/** Sprintnummer op basis van sprints van `length` dagen vanaf 1 januari van dat jaar. */
export function sprintNumber(startIso: string, length = 14): number {
const start = new Date(startIso);
const jaarBegin = new Date(start.getFullYear(), 0, 1);
return Math.ceil((Math.round((start.getTime() - jaarBegin.getTime()) / 86_400_000) + 1) / length);
}
export function PeriodPlanner({
value: valueProp,
onChange,
unavailable,
onInvalidRange,
months = 2,
locale = "nl-NL",
title,
extra,
labels: eigen,
className,
...rest
}: PeriodPlannerProps) {
const L = { ...STANDAARD, ...eigen };
const [intern, setIntern] = useState<DateRange | undefined>(undefined);
const value = valueProp ?? intern;
const kort = new Intl.DateTimeFormat(locale, { day: "numeric", month: "short" });
const toon = (iso?: string | null) => (iso ? kort.format(new Date(iso)) : L.none);
const compleet = value?.start && value?.end ? { start: value.start, end: value.end, days: dagen(value.start, value.end) } : null;
const extraVak = compleet && extra ? extra(compleet) : null;
return (
<div {...rest} className={["fp-planner", className].filter(Boolean).join(" ")}>
<DateRangeCalendar
value={value}
months={months}
locale={locale}
title={title}
unavailable={unavailable}
showLegend={false}
onChange={(r) => {
if (valueProp === undefined) setIntern(r);
onChange?.(r);
}}
onInvalidRange={onInvalidRange}
/>
<dl className="fp-planner__samenvatting">
<div className="fp-planner__vak">
<dt>{L.start}</dt>
<dd>{toon(value?.start)}</dd>
</div>
<div className="fp-planner__vak">
<dt>{L.end}</dt>
<dd>{toon(value?.end)}</dd>
</div>
<div className="fp-planner__vak">
<dt>{L.duration}</dt>
<dd>{compleet ? L.days.replace("{n}", String(compleet.days)) : L.none}</dd>
</div>
{extraVak && (
<div className="fp-planner__vak fp-planner__vak--extra">
<dt>{extraVak.label}</dt>
<dd>{extraVak.value}</dd>
</div>
)}
</dl>
</div>
);
}
blocks/planning/planning.css
/**
* Planning-blocks: period-planner.
* Importeren in de app-root (de registry-site doet dat in app/globals.css).
*/
.fp-planner { display: flex; flex-direction: column; gap: 0.75rem; width: 100%; color: var(--foreground); }
.fp-planner__samenvatting {
display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.5rem; margin: 0; padding: 0.75rem 1rem;
border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); font-size: 0.875rem;
}
.fp-planner__samenvatting:has(.fp-planner__vak--extra) { grid-template-columns: repeat(4, minmax(0, 1fr)); }
@media (max-width: 30rem) { .fp-planner__samenvatting, .fp-planner__samenvatting:has(.fp-planner__vak--extra) { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.fp-planner__vak { display: flex; flex-direction: column; gap: 0.125rem; min-width: 0; }
.fp-planner__vak dt { font-size: 0.75rem; color: var(--muted-foreground); }
.fp-planner__vak dd { margin: 0; font-weight: 500; font-variant-numeric: tabular-nums; }