← alle entries

period-planner

FC-0077Kalender & boekingperiod-planningblockv1.0.0

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.

zelfde component — andere tokens

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; }