Ressourcenplanung UI
Planung für Personen und Räume. Buchungen ziehen und strecken mit Konflikten, Schichtplan mit offenen Schichten, Räume mit Jetzt-Linie, Auslastung und freie Termine.
Wer wann wo arbeitet
Eine Ressourcenplanung zeigt Menschen und Räume über die Zeit, eine Leitung sieht so auf einen Blick, wer frei ist, wer überbucht ist und welcher Raum noch offen ist. Jede Buchung hier bewegt sich per Zeiger und per Pfeiltasten.
Die „Teamwoche“ legt Personen über die Tage mit Buchungen als Balken, zieh eine auf einen anderen Tag oder zu einer anderen Person, streck sie an der Kante, überbuchte Tage sind markiert. Der „Schichtplan“ zeigt Früh- und Spätschichten je Person und Tag, Besetzung gegen den Bedarf, Stunden gegen den Vertrag, offene Schichten zum Vergeben und einen Plan zum Veröffentlichen. „Räume heute“ führt Räume nach Stunden mit Plätzen und Ausstattung, einer Linie für jetzt und jeder freien Lücke, die mit zwei Tipps gebucht ist. Die „Auslastung“ zeigt die Last je Person und Woche als Heatmap in Prozent oder Stunden, mit Teamschnitt und den Projekten hinter jeder Zelle. „Termin finden“ wählt, wer dabei sein muss und wie lange, und zeigt, wann alle frei sind.
Plane in der Einheit, in der dein Team denkt, Tage für Projekte, Stunden für Räume, Schichten für Personal. Markiere Überlast deutlich, lass Buchungen aber zu, ein Plan muss der Wirklichkeit folgen. Veröffentliche Schichtpläne jede Woche zur selben Zeit, dann wissen alle, wann sie nachsehen.
Variants
Details
Was Ressourcenplanung mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Aufgaben und Planung
- Bereich
- App
- Variants
- 5
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/scheduler.tsx
- Bringt mit
- utilskitavatarbuttondropdown-menuiconmotionsidebar
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 92 eigene Texte
- Studio
- Im Code anpassen
- In Sammlungen
- SaaS-App-Screens
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle SchedulerProps aus components/ui/scheduler.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalSchedulerVariantresource-weekshift-planroom-daycapacityavailability
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titlePflichtstringsubtitleoptionalstringresourcesoptionalSchedResource[]bookingsoptionalBooking[]shiftTypesoptionalShiftType[]shiftsoptionalShift[]loadoptionalCapacityWeek[]capacity: planned hours per person and week
busyoptionalBusyBlock[]availability: busy times per person
weekStartoptionalstringYYYY-MM-DD, Monday of the first week on screen
daysoptionalnumberDays on screen, 5 or 7
weeksoptionalnumbercapacity: weeks on screen
todayoptionalstringYYYY-MM-DD that counts as today
nowoptionalstringHH:MM that counts as now (room-day)
dayStartoptionalnumberFirst and last hour of the day grid
dayEndoptionalnumberonActionoptional(action: string, data: Record<string, unknown>) => void | Promise<void>Called on book, move, resize, assign, publish and pick. Without it the block simulates the request.
Eingebaute Texte
Diese Texte schreibt Ressourcenplanung selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 92 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| newBooking | Englisch New booking | Deutsch Neue Buchung |
| book | Englisch Add booking | Deutsch Buchung anlegen |
| booked | Englisch {title} booked for {who} on {day}. | Deutsch {title} für {who} am {day} gebucht. |
| bookCell | Englisch Book {who} on {day} | Deutsch {who} am {day} buchen |
| what | Englisch What | Deutsch Was |
| whatPlaceholder | Englisch Project or task | Deutsch Projekt oder Aufgabe |
| who | Englisch Person | Deutsch Person |
| day | Englisch Day | Deutsch Tag |
| length | Englisch Days | Deutsch Tage |
| perDay | Englisch Hours a day | Deutsch Stunden am Tag |
| cancel | Englisch Cancel | Deutsch Abbrechen |
| hint | Englisch Arrow keys move a booking by a day or to another person. Shift with left or right makes it shorter or longer. Delete removes it. | Deutsch Die Pfeiltasten verschieben eine Buchung um einen Tag oder zu einer anderen Person. Umschalt mit links oder rechts macht sie kürzer oder länger. Entfernen löscht sie. |
| roleBooking | Englisch booking | Deutsch Buchung |
| moved | Englisch {title}, {who}, {from} to {to}, {h} h a day. | Deutsch {title}, {who}, {from} bis {to}, {h} Std. am Tag. |
| conflict | Englisch Over capacity on {days}. | Deutsch Überbucht am {days}. |
| conflictShort | Englisch Over capacity | Deutsch Überbucht |
| load | Englisch {h} of {c} h | Deutsch {h} von {c} Std. |
| removed | Englisch {title} removed. | Deutsch {title} entfernt. |
| undo | Englisch Undo | Deutsch Rückgängig |
| resize | Englisch Change the length of {title} | Deutsch Länge von {title} ändern |
| moveTo | Englisch Move to | Deutsch Verschieben nach |
| assignTo | Englisch Hand over to | Deutsch Übergeben an |
| remove | Englisch Remove | Deutsch Entfernen |
| menu | Englisch Actions for {title} | Deutsch Aktionen für {title} |
| tentative | Englisch Tentative | Deutsch Vorläufig |
| days | Englisch {n} days | Deutsch {n} Tage |
| shifts | Englisch Shifts | Deutsch Schichten |
| open | Englisch Open shifts | Deutsch Offene Schichten |
| openN | Englisch {n} open | Deutsch {n} offen |
| staffed | Englisch {n} of {m} | Deutsch {n} von {m} |
| assign | Englisch Assign {shift} on {day} | Deutsch {shift} am {day} vergeben |
| assigned | Englisch {who} takes the {shift} shift on {day}. | Deutsch {who} übernimmt die Schicht {shift} am {day}. |
| addShift | Englisch Add a shift for {who} on {day} | Deutsch Schicht für {who} am {day} anlegen |
| addThis | Englisch Add {shift} | Deutsch {shift} anlegen |
| changeTo | Englisch Change to {shift} | Deutsch In {shift} ändern |
| toOpen | Englisch Back to open shifts | Deutsch Zurück zu den offenen Schichten |
| unassigned | Englisch {shift} on {day} is open again. | Deutsch {shift} am {day} ist wieder offen. |
| busyThatDay | Englisch already on shift | Deutsch hat schon Schicht |
| hoursWeek | Englisch {h} h this week | Deutsch {h} Std. diese Woche |
| contract | Englisch {h} of {c} h | Deutsch {h} von {c} Std. |
| publish | Englisch Publish plan | Deutsch Plan veröffentlichen |
| publishing | Englisch Publishing… | Deutsch Wird veröffentlicht… |
| published | Englisch Plan published. Everyone gets their shifts. | Deutsch Plan veröffentlicht. Alle bekommen ihre Schichten. |
| shiftMenu | Englisch {shift} shift of {who} on {day} | Deutsch Schicht {shift} von {who} am {day} |
| rooms | Englisch Rooms | Deutsch Räume |
| seats | Englisch {n} seats | Deutsch {n} Plätze |
| all | Englisch All | Deutsch Alle |
| seatsMin | Englisch {n}+ seats | Deutsch Ab {n} Plätzen |
| roomSize | Englisch Room size | Deutsch Raumgröße |
| nowLine | Englisch Now {t} | Deutsch Jetzt {t} |
| free | Englisch Free {from} to {to} | Deutsch Frei von {from} bis {to} |
| bookFree | Englisch Book {room} from {from} | Deutsch {room} ab {from} buchen |
| bookRoom | Englisch Book room | Deutsch Raum buchen |
| bookingFor | Englisch {room} from {from} | Deutsch {room} ab {from} |
| duration | Englisch Length | Deutsch Dauer |
| roomBooked | Englisch {room} booked from {from} to {to}. | Deutsch {room} von {from} bis {to} gebucht. |
| nothingFree | Englisch Nothing free for the rest of the day. | Deutsch Für den Rest des Tages ist nichts frei. |
| pickGap | Englisch Pick a free slot in the plan to book it. | Deutsch Wähle eine freie Zeit im Plan, um sie zu buchen. |
| capacity | Englisch Capacity | Deutsch Auslastung |
| unit | Englisch Show as | Deutsch Anzeigen als |
| percent | Englisch Percent | Deutsch Prozent |
| hours | Englisch Hours | Deutsch Stunden |
| team | Englisch Team | Deutsch Team |
| teamAvg | Englisch Team average | Deutsch Teamschnitt |
| weekOf | Englisch Week of {d} | Deutsch Woche vom {d} |
| utilization | Englisch {who}, week of {d}, {p} booked | Deutsch {who}, Woche vom {d}, {p} verplant |
| over | Englisch {h} h over | Deutsch {h} Std. zu viel |
| under | Englisch {h} h free | Deutsch {h} Std. frei |
| exact | Englisch Fully booked | Deutsch Voll verplant |
| pickCell | Englisch Pick a cell to see what fills that week. | Deutsch Wähle eine Zelle, dann steht hier, was die Woche füllt. |
| legendLow | Englisch Room to spare | Deutsch Luft |
| legendGood | Englisch Well booked | Deutsch Gut verplant |
| legendFull | Englisch Full | Deutsch Voll |
| legendOver | Englisch Over capacity | Deutsch Überbucht |
| people | Englisch People | Deutsch Personen |
| pickPeople | Englisch Who needs to be there | Deutsch Wer dabei sein muss |
| meetingLength | Englisch Meeting length | Deutsch Dauer des Termins |
| min | Englisch {n} min | Deutsch {n} Min. |
| allowOne | Englisch Allow one person to miss | Deutsch Eine Person darf fehlen |
| suggestions | Englisch Best times | Deutsch Beste Zeiten |
| allFree | Englisch All {n} free | Deutsch Alle {n} frei |
| oneMissing | Englisch {who} is busy | Deutsch {who} ist belegt |
| pick | Englisch Pick this time | Deutsch Diese Zeit wählen |
| picked | Englisch Invite sent for {when}. | Deutsch Einladung für {when} verschickt. |
| noSlot | Englisch No common time this week. Allow one person to miss or pick a shorter meeting. | Deutsch Diese Woche gibt es keine gemeinsame Zeit. Lass eine Person fehlen oder wähle einen kürzeren Termin. |
| busy | Englisch Busy | Deutsch Belegt |
| past | Englisch Past | Deutsch Vorbei |
| freeAll | Englisch Everyone free | Deutsch Alle frei |
| freeMost | Englisch All but one free | Deutsch Alle bis auf eine Person frei |
| needTwo | Englisch Pick at least two people. | Deutsch Wähle mindestens zwei Personen. |
| slotLabel | Englisch {day} {from} to {to} | Deutsch {day} {from} bis {to} |
| to | Englisch to | Deutsch bis |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Ressourcenplanung reagiert selbst auf die Tasten Escape, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Rücktaste, Entf und Leertaste.
- Setzt aria-checked, aria-describedby, aria-expanded, aria-label, aria-labelledby, aria-live, aria-pressed, aria-roledescription und aria-selected, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen button, grid, gridcell, radio, radiogroup, status und switch.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 1.874 Zeilen
'use client';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, formatPercent, formatTime, type Locale, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { DropdownMenu, type MenuItem } from '@/components/ui/dropdown-menu';
import { Icon, type IconName } from '@/components/ui/icon';
import { useScrollFade } from '@/components/ui/motion';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';
export type SchedulerVariant = 'resource-week' | 'shift-plan' | 'room-day' | 'capacity' | 'availability';
export interface SchedResource {
id: string;
name: string;
/** Role or kind, e.g. Designer or Meeting room */
role?: string;
/** Team for filters */
team?: string;
/** Hours a day (people) or a week (capacity). Default 8 and 40. */
capacity?: number;
/** Rooms: seats */
seats?: number;
/** Rooms: equipment as icon names */
features?: IconName[];
}
export interface Booking {
id: string;
/** Resource id */
resource: string;
title: string;
/** YYYY-MM-DD, first day */
date: string;
/** Days it spans, default 1 */
days?: number;
/** Hours a day it takes, default 8 */
hours?: number;
/** room-day: HH:MM */
start?: string;
end?: string;
/** Colour slot 0 to 3 */
tone?: number;
tentative?: boolean;
/** Who booked it */
by?: string;
}
export interface ShiftType {
id: string;
label: string;
/** HH:MM */
start: string;
end: string;
/** People needed on each day */
needed?: number;
}
export interface Shift {
id: string;
/** Person id, null for an open shift */
resource: string | null;
date: string;
/** ShiftType id */
type: string;
}
export interface CapacityWeek {
/** Resource id */
resource: string;
/** YYYY-MM-DD, Monday */
week: string;
projects: { name: string; hours: number }[];
}
export interface BusyBlock {
resource: string;
date: string;
start: string;
end: string;
}
export interface SchedulerProps {
variant?: SchedulerVariant;
/** The app frame. Leave it out (or null) for a standalone screen. */
sidebar?: Omit<SidebarProps, 'children'> | null;
title: string;
subtitle?: string;
resources?: SchedResource[];
bookings?: Booking[];
shiftTypes?: ShiftType[];
shifts?: Shift[];
/** capacity: planned hours per person and week */
load?: CapacityWeek[];
/** availability: busy times per person */
busy?: BusyBlock[];
/** YYYY-MM-DD, Monday of the first week on screen */
weekStart?: string;
/** Days on screen, 5 or 7 */
days?: number;
/** capacity: weeks on screen */
weeks?: number;
/** YYYY-MM-DD that counts as today */
today?: string;
/** HH:MM that counts as now (room-day) */
now?: string;
/** First and last hour of the day grid */
dayStart?: number;
dayEnd?: number;
/** Called on book, move, resize, assign, publish and pick. Without it the block simulates the request. */
onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
newBooking: 'New booking',
book: 'Add booking',
booked: '{title} booked for {who} on {day}.',
bookCell: 'Book {who} on {day}',
what: 'What',
whatPlaceholder: 'Project or task',
who: 'Person',
day: 'Day',
length: 'Days',
perDay: 'Hours a day',
cancel: 'Cancel',
hint: 'Arrow keys move a booking by a day or to another person. Shift with left or right makes it shorter or longer. Delete removes it.',
roleBooking: 'booking',
moved: '{title}, {who}, {from} to {to}, {h} h a day.',
conflict: 'Over capacity on {days}.',
conflictShort: 'Over capacity',
load: '{h} of {c} h',
removed: '{title} removed.',
undo: 'Undo',
resize: 'Change the length of {title}',
moveTo: 'Move to',
assignTo: 'Hand over to',
remove: 'Remove',
menu: 'Actions for {title}',
tentative: 'Tentative',
days: '{n} days',
shifts: 'Shifts',
open: 'Open shifts',
openN: '{n} open',
staffed: '{n} of {m}',
assign: 'Assign {shift} on {day}',
assigned: '{who} takes the {shift} shift on {day}.',
addShift: 'Add a shift for {who} on {day}',
addThis: 'Add {shift}',
changeTo: 'Change to {shift}',
toOpen: 'Back to open shifts',
unassigned: '{shift} on {day} is open again.',
busyThatDay: 'already on shift',
hoursWeek: '{h} h this week',
contract: '{h} of {c} h',
publish: 'Publish plan',
publishing: 'Publishing…',
published: 'Plan published. Everyone gets their shifts.',
shiftMenu: '{shift} shift of {who} on {day}',
rooms: 'Rooms',
seats: '{n} seats',
all: 'All',
seatsMin: '{n}+ seats',
roomSize: 'Room size',
nowLine: 'Now {t}',
free: 'Free {from} to {to}',
bookFree: 'Book {room} from {from}',
bookRoom: 'Book room',
bookingFor: '{room} from {from}',
duration: 'Length',
roomBooked: '{room} booked from {from} to {to}.',
nothingFree: 'Nothing free for the rest of the day.',
pickGap: 'Pick a free slot in the plan to book it.',
capacity: 'Capacity',
unit: 'Show as',
percent: 'Percent',
hours: 'Hours',
team: 'Team',
teamAvg: 'Team average',
weekOf: 'Week of {d}',
utilization: '{who}, week of {d}, {p} booked',
over: '{h} h over',
under: '{h} h free',
exact: 'Fully booked',
pickCell: 'Pick a cell to see what fills that week.',
legendLow: 'Room to spare',
legendGood: 'Well booked',
legendFull: 'Full',
legendOver: 'Over capacity',
people: 'People',
pickPeople: 'Who needs to be there',
meetingLength: 'Meeting length',
min: '{n} min',
allowOne: 'Allow one person to miss',
suggestions: 'Best times',
allFree: 'All {n} free',
oneMissing: '{who} is busy',
pick: 'Pick this time',
picked: 'Invite sent for {when}.',
noSlot: 'No common time this week. Allow one person to miss or pick a shorter meeting.',
busy: 'Busy',
past: 'Past',
freeAll: 'Everyone free',
freeMost: 'All but one free',
needTwo: 'Pick at least two people.',
slotLabel: '{day} {from} to {to}',
to: 'to',
},
de: {
newBooking: 'Neue Buchung',
book: 'Buchung anlegen',
booked: '{title} für {who} am {day} gebucht.',
bookCell: '{who} am {day} buchen',
what: 'Was',
whatPlaceholder: 'Projekt oder Aufgabe',
who: 'Person',
day: 'Tag',
length: 'Tage',
perDay: 'Stunden am Tag',
cancel: 'Abbrechen',
hint: 'Die Pfeiltasten verschieben eine Buchung um einen Tag oder zu einer anderen Person. Umschalt mit links oder rechts macht sie kürzer oder länger. Entfernen löscht sie.',
roleBooking: 'Buchung',
moved: '{title}, {who}, {from} bis {to}, {h} Std. am Tag.',
conflict: 'Überbucht am {days}.',
conflictShort: 'Überbucht',
load: '{h} von {c} Std.',
removed: '{title} entfernt.',
undo: 'Rückgängig',
resize: 'Länge von {title} ändern',
moveTo: 'Verschieben nach',
assignTo: 'Übergeben an',
remove: 'Entfernen',
menu: 'Aktionen für {title}',
tentative: 'Vorläufig',
days: '{n} Tage',
shifts: 'Schichten',
open: 'Offene Schichten',
openN: '{n} offen',
staffed: '{n} von {m}',
assign: '{shift} am {day} vergeben',
assigned: '{who} übernimmt die Schicht {shift} am {day}.',
addShift: 'Schicht für {who} am {day} anlegen',
addThis: '{shift} anlegen',
changeTo: 'In {shift} ändern',
toOpen: 'Zurück zu den offenen Schichten',
unassigned: '{shift} am {day} ist wieder offen.',
busyThatDay: 'hat schon Schicht',
hoursWeek: '{h} Std. diese Woche',
contract: '{h} von {c} Std.',
publish: 'Plan veröffentlichen',
publishing: 'Wird veröffentlicht…',
published: 'Plan veröffentlicht. Alle bekommen ihre Schichten.',
shiftMenu: 'Schicht {shift} von {who} am {day}',
rooms: 'Räume',
seats: '{n} Plätze',
all: 'Alle',
seatsMin: 'Ab {n} Plätzen',
roomSize: 'Raumgröße',
nowLine: 'Jetzt {t}',
free: 'Frei von {from} bis {to}',
bookFree: '{room} ab {from} buchen',
bookRoom: 'Raum buchen',
bookingFor: '{room} ab {from}',
duration: 'Dauer',
roomBooked: '{room} von {from} bis {to} gebucht.',
nothingFree: 'Für den Rest des Tages ist nichts frei.',
pickGap: 'Wähle eine freie Zeit im Plan, um sie zu buchen.',
capacity: 'Auslastung',
unit: 'Anzeigen als',
percent: 'Prozent',
hours: 'Stunden',
team: 'Team',
teamAvg: 'Teamschnitt',
weekOf: 'Woche vom {d}',
utilization: '{who}, Woche vom {d}, {p} verplant',
over: '{h} Std. zu viel',
under: '{h} Std. frei',
exact: 'Voll verplant',
pickCell: 'Wähle eine Zelle, dann steht hier, was die Woche füllt.',
legendLow: 'Luft',
legendGood: 'Gut verplant',
legendFull: 'Voll',
legendOver: 'Überbucht',
people: 'Personen',
pickPeople: 'Wer dabei sein muss',
meetingLength: 'Dauer des Termins',
min: '{n} Min.',
allowOne: 'Eine Person darf fehlen',
suggestions: 'Beste Zeiten',
allFree: 'Alle {n} frei',
oneMissing: '{who} ist belegt',
pick: 'Diese Zeit wählen',
picked: 'Einladung für {when} verschickt.',
noSlot: 'Diese Woche gibt es keine gemeinsame Zeit. Lass eine Person fehlen oder wähle einen kürzeren Termin.',
busy: 'Belegt',
past: 'Vorbei',
freeAll: 'Alle frei',
freeMost: 'Alle bis auf eine Person frei',
needTwo: 'Wähle mindestens zwei Personen.',
slotLabel: '{day} {from} bis {to}',
to: 'bis',
},
};
type Texts = (typeof TEXTS)['en'];
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
/* ------------------------------------------------------------------ */
/* Motion */
/* ------------------------------------------------------------------ */
const CSS =
'@keyframes sch-up{from{opacity:0;transform:translateY(10px)}}@keyframes sch-wipe{from{clip-path:inset(0 100% 0 0)}}@keyframes sch-flash{from{box-shadow:0 0 0 3px color-mix(in oklab,var(--primary) 45%,transparent)}}@keyframes sch-pop{from{opacity:0;transform:scale(.85)}}@keyframes sch-pulse{0%{transform:scale(1);opacity:.6}80%,100%{transform:scale(2.4);opacity:0}}' +
'.sch-stripes{background-image:repeating-linear-gradient(135deg,color-mix(in oklab,var(--destructive) 14%,transparent) 0 6px,transparent 6px 12px)}' +
'@media (prefers-reduced-motion:no-preference){[data-sch=play] .sch-up{animation:sch-up .55s var(--ease-kit) var(--sch-d,0ms) both}[data-sch=wait] .sch-up{opacity:0}[data-sch=play] .sch-wipe{animation:sch-wipe .9s var(--ease-kit) var(--sch-d,0ms) both}[data-sch=wait] .sch-wipe{clip-path:inset(0 100% 0 0)}.sch-flash{animation:sch-flash 1.4s ease-out}.sch-in{animation:sch-up .4s var(--ease-kit) both}.sch-pop{animation:sch-pop .35s var(--ease-kit) both}.sch-pulse{animation:sch-pulse 2s var(--ease-kit) infinite}}';
function Style() {
return (
<style href="kitb5-scheduler" precedence="default">
{CSS}
</style>
);
}
function useEnter<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
useEffect(() => {
const el = ref.current;
if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
setState('wait');
const io = new IntersectionObserver(([e]) => {
if (!e?.isIntersecting) return;
setState('play');
io.disconnect();
});
io.observe(el);
return () => io.disconnect();
}, []);
return [ref, { 'data-sch': state }] as const;
}
/* ------------------------------------------------------------------ */
/* Parts */
/* ------------------------------------------------------------------ */
const dayMs = 86400000;
const dayNum = (iso: string) => Math.round(Date.parse(iso.slice(0, 10) + 'T00:00:00Z') / dayMs);
const isoOf = (n: number) => new Date(n * dayMs).toISOString().slice(0, 10);
const tagOf = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const fmtDay = (iso: string, l: Locale, o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(tagOf(l), { ...o, timeZone: 'UTC' }).format(new Date(dayNum(iso) * dayMs));
const toMin = (hhmm: string) => {
const [h, m] = hhmm.split(':').map(Number);
return (h || 0) * 60 + (m || 0);
};
const hhmm = (min: number) => `${String(Math.floor(min / 60)).padStart(2, '0')}:${String(Math.round(min % 60)).padStart(2, '0')}`;
const TONES = [
'border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-foreground',
'border-[color-mix(in_oklab,var(--foreground)_22%,var(--border))] bg-[color-mix(in_oklab,var(--foreground)_8%,var(--card))] text-foreground',
'border-[color-mix(in_oklab,var(--warning)_55%,var(--border))] bg-[color-mix(in_oklab,var(--warning)_18%,var(--card))] text-foreground',
'border-[color-mix(in_oklab,var(--success)_50%,var(--border))] bg-[color-mix(in_oklab,var(--success)_15%,var(--card))] text-foreground',
];
const TONE_BAR = ['bg-primary', 'bg-[color-mix(in_oklab,var(--foreground)_45%,var(--card))]', 'bg-warning', 'bg-success'];
const field =
'h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:h-9 sm:text-[13px]';
const ring = 'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';
const iconBtn = cn('grid size-11 shrink-0 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-30 sm:size-8', ring);
function Header({ title, subtitle, children }: { title: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
{subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
<h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>
</div>
{children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
</div>
);
}
function Main({ children, className }: { children: React.ReactNode; className?: string }) {
return <main className={cn('mx-auto w-full min-w-0 max-w-[96rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}
function Segmented<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: React.ReactNode }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
const ref = useRef<HTMLDivElement>(null);
const onKey = (e: React.KeyboardEvent, i: number) => {
const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!dir) return;
e.preventDefault();
const next = (i + dir + options.length) % options.length;
onChange(options[next].value);
ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[next]?.focus();
};
return (
<div ref={ref} role="radiogroup" aria-label={label} className={cn('relative inline-flex shrink-0 rounded-lg bg-[color-mix(in_oklab,var(--foreground)_6%,var(--background))] p-1', className)}>
{options.map((o, i) => (
<button
key={o.value}
type="button"
role="radio"
aria-checked={value === o.value}
tabIndex={value === o.value ? 0 : -1}
onClick={() => onChange(o.value)}
onKeyDown={(e) => onKey(e, i)}
className={cn('relative inline-flex h-11 flex-1 max-sm:min-w-11 shrink-0 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium transition-[color,background-color,box-shadow] duration-200 sm:h-8', ring, value === o.value ? 'bg-background text-foreground shadow-[var(--shadow-sm)] ring-1 ring-border' : 'text-muted-foreground hover:text-foreground')}
>
{o.label}
</button>
))}
</div>
);
}
function Pick({ label, value, onChange, children, className }: { label: string; value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string }) {
const id = useId();
return (
<span className={cn('grid min-w-0 gap-1.5', className)}>
<label htmlFor={id} className="text-[12px] font-medium text-muted-foreground">
{label}
</label>
<span className="relative block">
<select id={id} value={value} onChange={(e) => onChange(e.target.value)} className={cn(field, 'cursor-pointer appearance-none truncate pe-8')}>
{children}
</select>
<Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
</span>
</span>
);
}
function Live({ text }: { text: string }) {
return (
<p role="status" aria-live="polite" className="sr-only">
{text}
</p>
);
}
function Toast({ text, onUndo, t }: { text: string; onUndo?: () => void; t: Texts }) {
if (!text) return null;
return (
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
<div key={text} className="sch-in pointer-events-auto flex max-w-md items-center gap-3 rounded-xl border border-border bg-popover py-2 ps-4 pe-2 text-[13.5px] text-popover-foreground shadow-[var(--shadow-lg)]">
<Icon name="check-circle" size={16} className="shrink-0 text-success-foreground" />
<span className="min-w-0 flex-1 text-pretty">{text}</span>
{onUndo && (
<button type="button" onClick={onUndo} className={cn('h-11 shrink-0 cursor-pointer rounded-md px-3 text-[13px] font-semibold text-foreground hover:bg-accent sm:h-8', ring)}>
{t.undo}
</button>
)}
</div>
</div>
);
}
function useToast() {
const [toast, setToast] = useState<{ text: string; undo?: () => void } | null>(null);
const timer = useRef(0);
const show = (text: string, undo?: () => void) => {
setToast({ text, undo });
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setToast(null), 5000);
};
return [toast, show, () => setToast(null)] as const;
}
/** Day tabs for phones: a row of days as a radiogroup */
function DayTabs({ days, value, onChange, label, locale, today, extra }: { days: string[]; value: number; onChange: (i: number) => void; label: string; locale: Locale; today: string; extra?: (i: number) => React.ReactNode }) {
return (
<div role="radiogroup" aria-label={label} className="grid gap-1" style={{ gridTemplateColumns: `repeat(${days.length}, minmax(0, 1fr))` }}>
{days.map((d, i) => (
<button
key={d}
type="button"
role="radio"
aria-checked={value === i}
tabIndex={value === i ? 0 : -1}
aria-label={fmtDay(d, locale, { weekday: 'long', day: 'numeric', month: 'long' })}
onClick={() => onChange(i)}
onKeyDown={(e) => {
const k = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
if (!k) return;
e.preventDefault();
const n = (i + k + days.length) % days.length;
onChange(n);
(e.currentTarget.parentElement?.children[n] as HTMLElement | undefined)?.focus();
}}
className={cn('relative flex h-14 cursor-pointer flex-col items-center justify-center rounded-lg border transition-colors', ring, value === i ? 'border-transparent bg-foreground text-background' : 'border-border bg-card text-foreground')}
>
<span className={cn('text-[11.5px] font-medium', value === i ? 'opacity-80' : 'text-muted-foreground')}>{fmtDay(d, locale, { weekday: 'short' }).replace('.', '')}</span>
<span className="text-[14px] font-semibold tabular-nums">{fmtDay(d, locale, { day: 'numeric' })}</span>
{d === today && <span aria-hidden className={cn('absolute bottom-1 size-1 rounded-full', value === i ? 'bg-background' : 'bg-primary')} />}
{extra?.(i)}
</button>
))}
</div>
);
}
type Ctx = { t: Texts; locale: Locale; res: (id: string | null) => SchedResource | undefined };
type VP = Omit<SchedulerProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { ctx: Ctx; resources: SchedResource[]; today: string; weekStart: string; days: number };
/* ------------------------------------------------------------------ */
/* resource-week */
/* ------------------------------------------------------------------ */
function ResourceWeek({ ctx, title, subtitle, resources, bookings: initial = [], weekStart, days: n, today, onAction }: VP) {
const { t, locale } = ctx;
const id = useId();
const w0 = dayNum(weekStart);
const days = Array.from({ length: n }, (_, i) => isoOf(w0 + i));
const [items, setItems] = useState<Booking[]>(initial);
const [say, setSay] = useState('');
const [fresh, setFresh] = useState<string | null>(null);
const [dragging, setDragging] = useState<string | null>(null);
const [form, setForm] = useState<{ who: string; day: number; title: string; span: number; hours: number } | null>(null);
const [mday, setMday] = useState(Math.max(0, Math.min(n - 1, dayNum(today) - w0)));
const [toast, show, hide] = useToast();
const [scope, enter] = useEnter<HTMLDivElement>();
const formRef = useRef<HTMLFormElement>(null);
const idx = (b: Booking) => dayNum(b.date) - w0;
const span = (b: Booking) => b.days ?? 1;
const cap = (r: SchedResource) => r.capacity ?? 8;
const dayName = (i: number, o: Intl.DateTimeFormatOptions = { weekday: 'long' }) => fmtDay(days[Math.max(0, Math.min(n - 1, i))], locale, o);
const loadOf = (rid: string, d: number, list = items) => list.filter((b) => b.resource === rid && idx(b) <= d && idx(b) + span(b) > d).reduce((a, b) => a + (b.hours ?? 8), 0);
const overDays = (b: Booking, list = items) => {
const r = ctx.res(b.resource);
if (!r) return [];
return Array.from({ length: span(b) }, (_, k) => idx(b) + k).filter((d) => d >= 0 && d < n && loadOf(b.resource, d, list) > cap(r));
};
const describe = (b: Booking, list = items) => {
const od = overDays(b, list);
return fill(t.moved, { title: b.title, who: ctx.res(b.resource)?.name ?? '', from: dayName(idx(b)), to: dayName(idx(b) + span(b) - 1), h: b.hours ?? 8 }) + (od.length ? ` ${fill(t.conflict, { days: od.map((d) => dayName(d)).join(', ') })}` : '');
};
// Stack bookings of a person in lanes so overlapping ones sit on top of each other
const lanesOf = (rid: string) => {
const mine = items.filter((b) => b.resource === rid).sort((a, b) => idx(a) - idx(b) || span(b) - span(a));
const ends: number[] = [];
const lane: Record<string, number> = {};
for (const b of mine) {
let l = ends.findIndex((e) => e <= idx(b));
if (l < 0) l = ends.length;
ends[l] = idx(b) + span(b);
lane[b.id] = l;
}
return { mine, lane, count: Math.max(1, ends.length) };
};
const change = (b: Booking, patch: Partial<Booking>, announce = true) => {
const next = items.map((x) => (x.id === b.id ? { ...x, ...patch } : x));
setItems(next);
if (announce) setSay(describe({ ...b, ...patch }, next));
return next;
};
const moveBy = (b: Booking, dd: number, dr: number, ds: number) => {
const s = Math.max(1, Math.min(n, span(b) + ds));
const d = Math.max(0, Math.min(n - s, idx(b) + dd));
const ri = resources.findIndex((r) => r.id === b.resource) + dr;
if (ri < 0 || ri >= resources.length) return;
change(b, { date: days[d], days: s, resource: resources[ri].id });
void onAction?.('move', { id: b.id, date: days[d], days: s, resource: resources[ri].id });
};
const removeB = (b: Booking) => {
const before = items;
setItems((x) => x.filter((y) => y.id !== b.id));
const text = fill(t.removed, { title: b.title });
setSay(text);
show(text, () => (setItems(before), hide()));
void onAction?.('delete', { id: b.id });
};
const onKey = (e: React.KeyboardEvent, b: Booking) => {
if (e.key === 'Delete' || e.key === 'Backspace') return (e.preventDefault(), removeB(b));
const m: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
const v = m[e.key];
if (!v) return;
e.preventDefault();
if (e.shiftKey && v[0]) moveBy(b, 0, 0, v[0]);
else moveBy(b, v[0], v[1], 0);
requestAnimationFrame(() => document.getElementById(`${id}-b-${b.id}`)?.focus());
};
const onPointer = (e: React.PointerEvent<HTMLElement>, b: Booking, mode: 'move' | 'size') => {
if (e.button !== 0 || (mode === 'move' && (e.target as HTMLElement).closest('[data-handle],button'))) return;
const track = (e.currentTarget.closest('[data-track]') as HTMLElement | null)?.getBoundingClientRect();
if (!track) return;
e.preventDefault();
const colW = track.width / n;
const sx = e.clientX;
const orig = { ...b };
const before = items;
let began = false;
let cur = b;
const move = (ev: PointerEvent) => {
if (!began && Math.abs(ev.clientX - sx) < 4 && Math.abs(ev.clientY - e.clientY) < 4) return;
if (!began) {
began = true;
setDragging(b.id);
document.body.style.userSelect = 'none';
}
const dx = Math.round((ev.clientX - sx) / colW);
let patch: Partial<Booking>;
if (mode === 'size') {
patch = { days: Math.max(1, Math.min(n - idx(orig), span(orig) + dx)) };
} else {
const d = Math.max(0, Math.min(n - span(orig), idx(orig) + dx));
const row = (document.elementFromPoint(ev.clientX, ev.clientY) as HTMLElement | null)?.closest<HTMLElement>('[data-res]');
patch = { date: days[d], resource: row?.dataset.res ?? cur.resource };
}
const next = { ...orig, ...patch };
if (next.date !== cur.date || next.resource !== cur.resource || next.days !== cur.days) {
cur = next;
setItems((x) => x.map((y) => (y.id === b.id ? next : y)));
}
};
const end = (ok: boolean) => {
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', up);
document.removeEventListener('pointercancel', cancel);
document.removeEventListener('keydown', key, true);
document.body.style.userSelect = '';
setDragging(null);
if (!began) return;
if (!ok) {
setItems(before);
return;
}
setFresh(b.id);
window.setTimeout(() => setFresh((f) => (f === b.id ? null : f)), 1500);
setItems((x) => {
setSay(describe(cur, x));
return x;
});
show(describe(cur), () => (setItems(before), hide()));
void onAction?.(mode === 'size' ? 'resize' : 'move', { id: b.id, date: cur.date, days: cur.days, resource: cur.resource });
};
const up = () => end(true);
const cancel = () => end(false);
const key = (ev: KeyboardEvent) => ev.key === 'Escape' && (ev.preventDefault(), end(false));
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up);
document.addEventListener('pointercancel', cancel);
document.addEventListener('keydown', key, true);
};
const openForm = (who: string, day: number) => {
setForm({ who, day, title: '', span: 1, hours: 4 });
requestAnimationFrame(() => {
formRef.current?.scrollIntoView({ block: 'nearest', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
formRef.current?.querySelector<HTMLInputElement>('input')?.focus({ preventScroll: true });
});
};
const menuFor = (b: Booking): MenuItem[] => [
{ type: 'label', label: t.moveTo },
...days.map((d, i) => ({ type: 'radio' as const, group: 'day', value: d, checked: idx(b) === i, label: dayName(i, { weekday: 'long', day: 'numeric', month: 'short' }), onSelect: () => (change(b, { date: days[Math.min(i, n - span(b))] }), void onAction?.('move', { id: b.id, date: d })) })),
{ type: 'separator' },
{ type: 'label', label: t.assignTo },
...resources.filter((r) => r.id !== b.resource).map((r) => ({ label: r.name, icon: 'user' as const, onSelect: () => (change(b, { resource: r.id }), void onAction?.('move', { id: b.id, resource: r.id })) })),
{ type: 'separator' },
{ label: t.remove, icon: 'trash', destructive: true, onSelect: () => removeB(b) },
];
const totalOf = (r: SchedResource) => days.reduce((a, _, d) => a + loadOf(r.id, d), 0);
const weekCap = (r: SchedResource) => cap(r) * Math.min(n, 5);
return (
<Main>
<Header title={title} subtitle={subtitle}>
<Button size="sm" iconLeft="plus" magnetic className="max-sm:w-full" onClick={() => openForm(resources[0]?.id ?? '', mday)}>
{t.newBooking}
</Button>
</Header>
<p id={`${id}-hint`} className="sr-only">
{t.hint}
</p>
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-4">
{/* Tablet and desktop: people by days */}
<div className="sch-up overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] max-md:hidden">
<div className="grid grid-cols-[12rem_minmax(0,1fr)] border-b border-border bg-surface lg:grid-cols-[14rem_minmax(0,1fr)]">
<span className="px-4 py-2.5 text-[12px] font-medium text-muted-foreground">{t.who}</span>
<div className="grid" style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }}>
{days.map((d) => (
<span key={d} className={cn('flex items-baseline justify-center gap-1.5 border-s border-border py-2.5 text-[12.5px]', d === today ? 'font-semibold text-foreground' : 'text-muted-foreground')}>
{fmtDay(d, locale, { weekday: 'short' }).replace('.', '')}
<span className={cn('tabular-nums', d === today ? 'grid size-6 place-items-center rounded-full bg-primary text-primary-foreground' : 'text-subtle-foreground')}>{fmtDay(d, locale, { day: 'numeric' })}</span>
</span>
))}
</div>
</div>
{resources.map((r, ri) => {
const { mine, lane, count } = lanesOf(r.id);
const total = totalOf(r);
const wc = weekCap(r);
return (
<div key={r.id} data-res={r.id} className="grid grid-cols-[12rem_minmax(0,1fr)] border-b border-border last:border-b-0 lg:grid-cols-[14rem_minmax(0,1fr)]">
<div className="flex min-w-0 items-center gap-2.5 px-4 py-3">
<Avatar name={r.name} size="sm" />
<div className="min-w-0 flex-1">
<p className="truncate text-[13.5px] font-medium text-foreground">{r.name}</p>
<p className="flex items-center gap-2 text-[12px] text-muted-foreground">
<span className="truncate">{r.role}</span>
</p>
<span className="mt-1 flex items-center gap-2">
<span aria-hidden className="relative h-1 flex-1 overflow-hidden rounded-full bg-track">
<span className={cn('sch-wipe absolute inset-y-0 start-0 rounded-full transition-[width] duration-500', total > wc ? 'bg-destructive' : total === wc ? 'bg-success' : 'bg-primary')} style={{ width: `${round(Math.min(100, (total / wc) * 100), 1)}%`, '--sch-d': `${ri * 60}ms` } as React.CSSProperties} />
</span>
<span className={cn('shrink-0 text-[11.5px] tabular-nums', total > wc ? 'font-semibold text-destructive' : 'text-muted-foreground')}>{fill(t.load, { h: total, c: wc })}</span>
</span>
</div>
</div>
<div data-track className="relative min-w-0">
{/* Day cells: background, load per day, and a way to book an empty day */}
<div className="absolute inset-0 grid" style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }}>
{days.map((d, di) => {
const l = loadOf(r.id, di);
const over = l > cap(r);
return (
<button key={d} type="button" tabIndex={-1} aria-label={fill(t.bookCell, { who: r.name, day: dayName(di) })} onClick={() => openForm(r.id, di)} className={cn('group/cell relative cursor-pointer border-s border-border transition-colors hover:bg-[color-mix(in_oklab,var(--primary)_4%,transparent)]', d === today && 'bg-[color-mix(in_oklab,var(--primary)_4%,transparent)]', over && 'sch-stripes', di >= 5 && 'bg-[color-mix(in_oklab,var(--foreground)_2.5%,transparent)]')}>
<span aria-hidden className="pointer-events-none absolute bottom-1.5 end-2 text-[11px] tabular-nums text-subtle-foreground opacity-0 transition-opacity group-hover/cell:opacity-100">
{l}/{cap(r)}
</span>
{!l && <Icon name="plus" size={14} className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-subtle-foreground opacity-0 transition-opacity group-hover/cell:opacity-100" />}
</button>
);
})}
</div>
<div className="pointer-events-none relative grid gap-y-1.5 px-1 py-2" style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))`, gridTemplateRows: `repeat(${count}, 2.75rem)` }}>
{mine.map((b) => {
const od = overDays(b);
const tone = b.tone ?? 0;
return (
<div
key={b.id}
id={`${id}-b-${b.id}`}
role="button"
tabIndex={0}
aria-roledescription={t.roleBooking}
aria-describedby={`${id}-hint`}
aria-label={describe(b)}
onPointerDown={(e) => onPointer(e, b, 'move')}
onKeyDown={(e) => onKey(e, b)}
className={cn(
'pointer-events-auto group/bar relative mx-0.5 flex min-w-0 cursor-grab items-center gap-2 overflow-hidden rounded-lg border ps-2.5 pe-1 shadow-[var(--shadow-sm)] transition-[box-shadow,transform,opacity] duration-200 hover:-translate-y-px hover:shadow-[var(--shadow-md)] active:cursor-grabbing',
ring,
TONES[tone % 4],
b.tentative && 'border-dashed bg-[color-mix(in_oklab,var(--foreground)_3%,var(--card))]',
od.length > 0 && 'border-[color-mix(in_oklab,var(--destructive)_60%,var(--border))]',
dragging === b.id && 'z-10 scale-[1.02] shadow-[var(--shadow-lg)]',
fresh === b.id && 'sch-flash',
)}
style={{ gridColumn: `${idx(b) + 1} / span ${span(b)}`, gridRow: lane[b.id] + 1 }}
>
<span aria-hidden className={cn('absolute inset-y-1.5 start-1 w-[3px] rounded-full', TONE_BAR[tone % 4])} />
<span className="min-w-0 flex-1 ps-1">
<span className="block truncate text-[12.5px] font-semibold leading-tight">{b.title}</span>
<span className="flex min-w-0 items-center gap-1 overflow-hidden whitespace-nowrap text-[11.5px] leading-tight text-muted-foreground">
{od.length > 0 && <Icon name="alert-circle" size={11} className="shrink-0 text-destructive" />}
<span className="shrink-0 tabular-nums">{b.hours ?? 8} h</span>
{(od.length > 0 || b.tentative) && <span aria-hidden>·</span>}
{od.length > 0 ? <span className="truncate font-medium text-destructive">{t.conflictShort}</span> : b.tentative ? <span className="truncate">{t.tentative}</span> : null}
</span>
</span>
<DropdownMenu
align="end"
items={menuFor(b)}
trigger={
<button type="button" tabIndex={-1} aria-label={fill(t.menu, { title: b.title })} className={cn('grid size-7 shrink-0 cursor-pointer place-items-center rounded-md text-muted-foreground opacity-70 transition-opacity hover:bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)] hover:opacity-100', span(b) === 1 && 'max-xl:absolute max-xl:end-0.5 max-xl:top-1/2 max-xl:-translate-y-1/2 max-xl:bg-card max-xl:opacity-0 max-xl:group-hover/bar:opacity-100 max-xl:group-focus-within/bar:opacity-100')}>
<Icon name="more-vertical" size={14} />
</button>
}
/>
<span data-handle aria-hidden title={fill(t.resize, { title: b.title })} onPointerDown={(e) => (e.stopPropagation(), onPointer(e, b, 'size'))} className="absolute inset-y-0 end-0 w-2 cursor-ew-resize rounded-e-lg transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)]" />
</div>
);
})}
</div>
</div>
</div>
);
})}
</div>
{/* Phone: one day at a time */}
<div className="grid gap-3 md:hidden">
<DayTabs days={days} value={mday} onChange={setMday} label={t.day} locale={locale} today={today} extra={(i) => (resources.some((r) => loadOf(r.id, i) > cap(r)) ? <span aria-hidden className="absolute end-1.5 top-1.5 size-1.5 rounded-full bg-destructive" /> : null)} />
<ul key={mday} className="sch-in grid gap-2">
{resources.map((r) => {
const list = items.filter((b) => b.resource === r.id && idx(b) <= mday && idx(b) + span(b) > mday);
const l = loadOf(r.id, mday);
const over = l > cap(r);
return (
<li key={r.id} className={cn('rounded-xl border bg-card p-3 shadow-[var(--highlight),var(--shadow-sm)]', over ? 'border-[color-mix(in_oklab,var(--destructive)_45%,var(--border))]' : 'border-border')}>
<div className="flex items-center gap-2.5">
<Avatar name={r.name} size="sm" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[14px] font-medium text-foreground">{r.name}</span>
<span className={cn('block text-[12px] tabular-nums', over ? 'font-semibold text-destructive' : 'text-muted-foreground')}>{over ? `${t.conflictShort} · ` : ''}{fill(t.load, { h: l, c: cap(r) })}</span>
</span>
<button type="button" aria-label={fill(t.bookCell, { who: r.name, day: dayName(mday) })} onClick={() => openForm(r.id, mday)} className={iconBtn}>
<Icon name="plus" size={16} />
</button>
</div>
{list.length > 0 && (
<ul className="mt-2 grid gap-1.5">
{list.map((b) => (
<li key={b.id} className={cn('flex items-center gap-2 rounded-lg border ps-3 pe-1', TONES[(b.tone ?? 0) % 4], b.tentative && 'border-dashed', fresh === b.id && 'sch-flash')}>
<span className="min-w-0 flex-1 py-2">
<span className="block truncate text-[13px] font-semibold">{b.title}</span>
<span className="block text-[12px] text-muted-foreground">
{span(b) > 1 ? `${dayName(idx(b), { weekday: 'short' })}–${dayName(idx(b) + span(b) - 1, { weekday: 'short' })} · ` : ''}
{b.hours ?? 8} h
</span>
</span>
<DropdownMenu
align="end"
items={menuFor(b)}
trigger={
<button type="button" aria-label={fill(t.menu, { title: b.title })} className={iconBtn}>
<Icon name="more-vertical" size={15} />
</button>
}
/>
</li>
))}
</ul>
)}
</li>
);
})}
</ul>
</div>
{form && (
<form
ref={formRef}
className="sch-in grid grid-cols-2 gap-3 rounded-xl border border-border-strong bg-card p-4 shadow-[var(--shadow-md)] sm:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_minmax(0,1fr)_6rem_6rem_auto] sm:items-end sm:p-5"
onSubmit={(e) => {
e.preventDefault();
if (!form.title.trim()) return;
const b: Booking = { id: `b${Date.now()}`, resource: form.who, title: form.title.trim(), date: days[Math.min(form.day, n - form.span)], days: form.span, hours: form.hours, tone: items.length % 4 };
const next = [...items, b];
setItems(next);
setFresh(b.id);
setForm(null);
const text = fill(t.booked, { title: b.title, who: ctx.res(b.resource)?.name ?? '', day: dayName(idx(b)) }) + (overDays(b, next).length ? ` ${fill(t.conflict, { days: overDays(b, next).map((d) => dayName(d)).join(', ') })}` : '');
setSay(text);
show(text);
void onAction?.('book', { booking: b });
}}
>
<label className="col-span-2 grid gap-1.5 sm:col-span-1">
<span className="text-[12px] font-medium text-muted-foreground">{t.what}</span>
<input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} placeholder={t.whatPlaceholder} className={field} />
</label>
<Pick label={t.who} value={form.who} onChange={(v) => setForm({ ...form, who: v })}>
{resources.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</Pick>
<Pick label={t.day} value={String(form.day)} onChange={(v) => setForm({ ...form, day: Number(v) })}>
{days.map((d, i) => (
<option key={d} value={i}>
{dayName(i, { weekday: 'long', day: 'numeric', month: 'short' })}
</option>
))}
</Pick>
<label className="grid gap-1.5">
<span className="text-[12px] font-medium text-muted-foreground">{t.length}</span>
<input type="number" min={1} max={n} value={form.span} onChange={(e) => setForm({ ...form, span: Math.max(1, Math.min(n, Number(e.target.value) || 1)) })} className={cn(field, 'tabular-nums')} />
</label>
<label className="grid gap-1.5">
<span className="text-[12px] font-medium text-muted-foreground">{t.perDay}</span>
<input type="number" min={1} max={12} value={form.hours} onChange={(e) => setForm({ ...form, hours: Math.max(1, Math.min(12, Number(e.target.value) || 1)) })} className={cn(field, 'tabular-nums')} />
</label>
<div className="col-span-2 flex justify-end gap-2 sm:col-span-1">
<Button type="button" size="sm" variant="ghost" onClick={() => setForm(null)} className="max-sm:h-11">
{t.cancel}
</Button>
<Button type="submit" size="sm" iconLeft="check" disabled={!form.title.trim()} className="max-sm:h-11">
{t.book}
</Button>
</div>
</form>
)}
</div>
<Live text={say} />
<Toast t={t} text={toast?.text ?? ''} onUndo={toast?.undo} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* shift-plan */
/* ------------------------------------------------------------------ */
const SHIFT_TONE = [
'bg-[color-mix(in_oklab,var(--warning)_20%,var(--card))] text-foreground border-[color-mix(in_oklab,var(--warning)_50%,var(--border))]',
'bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-foreground border-[color-mix(in_oklab,var(--primary)_40%,var(--border))]',
'bg-[color-mix(in_oklab,var(--foreground)_80%,var(--card))] text-background border-transparent',
];
function ShiftPlan({ ctx, title, subtitle, resources, shiftTypes = [], shifts: initial = [], weekStart, days: n, today, onAction }: VP) {
const { t, locale } = ctx;
const id = useId();
const w0 = dayNum(weekStart);
const days = Array.from({ length: n }, (_, i) => isoOf(w0 + i));
const [shifts, setShifts] = useState<Shift[]>(initial);
const [say, setSay] = useState('');
const [fresh, setFresh] = useState<string | null>(null);
const [pub, setPub] = useState<'idle' | 'busy' | 'done'>('idle');
const [mday, setMday] = useState(Math.max(0, Math.min(n - 1, dayNum(today) - w0)));
const [scope, enter] = useEnter<HTMLDivElement>();
const type = (tid: string) => shiftTypes.find((x) => x.id === tid);
const ti = (tid: string) => Math.max(0, shiftTypes.findIndex((x) => x.id === tid));
const len = (s: Shift) => {
const ty = type(s.type);
if (!ty) return 0;
let m = toMin(ty.end) - toMin(ty.start);
if (m <= 0) m += 1440;
return m / 60;
};
const hoursOf = (rid: string) => shifts.filter((s) => s.resource === rid).reduce((a, s) => a + len(s), 0);
const dayName = (d: string) => fmtDay(d, locale, { weekday: 'long', day: 'numeric', month: 'short' });
const range = (ty?: ShiftType) => (ty ? `${formatTime(ty.start, locale)}–${formatTime(ty.end, locale)}` : '');
const flash = (sid: string) => {
setFresh(sid);
window.setTimeout(() => setFresh((f) => (f === sid ? null : f)), 1500);
};
const assign = (s: Shift, rid: string) => {
setShifts((x) => x.map((y) => (y.id === s.id ? { ...y, resource: rid } : y)));
flash(s.id);
setSay(fill(t.assigned, { who: ctx.res(rid)?.name ?? '', shift: type(s.type)?.label ?? '', day: dayName(s.date) }));
setPub('idle');
void onAction?.('assign', { id: s.id, resource: rid });
};
const unassign = (s: Shift) => {
setShifts((x) => x.map((y) => (y.id === s.id ? { ...y, resource: null } : y)));
flash(s.id);
setSay(fill(t.unassigned, { shift: type(s.type)?.label ?? '', day: dayName(s.date) }));
setPub('idle');
void onAction?.('unassign', { id: s.id });
};
const peopleFor = (s: Shift): MenuItem[] => [
{ type: 'label', label: fill(t.assign, { shift: type(s.type)?.label ?? '', day: fmtDay(s.date, locale, { weekday: 'short' }) }) },
...resources
.map((r) => ({ r, h: hoursOf(r.id), taken: shifts.some((x) => x.resource === r.id && x.date === s.date) }))
.sort((a, b) => Number(a.taken) - Number(b.taken) || a.h - b.h)
.map(({ r, h, taken }) => ({ label: r.name, description: taken ? t.busyThatDay : fill(t.contract, { h, c: r.capacity ?? 40 }), icon: 'user' as const, disabled: taken, onSelect: () => assign(s, r.id) })),
];
const shiftMenu = (s: Shift): MenuItem[] => [
...shiftTypes.filter((x) => x.id !== s.type).map((x) => ({ label: fill(t.changeTo, { shift: x.label }), description: range(x), icon: 'repeat' as const, onSelect: () => (setShifts((y) => y.map((z) => (z.id === s.id ? { ...z, type: x.id } : z))), flash(s.id), setPub('idle')) })),
{ type: 'separator' },
{ label: t.toOpen, icon: 'user-minus', onSelect: () => unassign(s) },
{ label: t.remove, icon: 'trash', destructive: true, onSelect: () => (setShifts((y) => y.filter((z) => z.id !== s.id)), setSay(fill(t.removed, { title: type(s.type)?.label ?? '' })), setPub('idle')) },
];
const addMenu = (rid: string, d: string): MenuItem[] => shiftTypes.map((x) => ({ label: fill(t.addThis, { shift: x.label }), description: range(x), icon: 'plus' as const, onSelect: () => {
const s: Shift = { id: `s${Date.now()}`, resource: rid, date: d, type: x.id };
setShifts((y) => [...y, s]);
flash(s.id);
setSay(fill(t.assigned, { who: ctx.res(rid)?.name ?? '', shift: x.label, day: dayName(d) }));
setPub('idle');
} }));
const chip = (s: Shift, who?: string, open = false) => {
const ty = type(s.type);
return (
<DropdownMenu
items={open ? peopleFor(s) : shiftMenu(s)}
trigger={
<button type="button" aria-label={open ? fill(t.assign, { shift: ty?.label ?? '', day: dayName(s.date) }) : fill(t.shiftMenu, { shift: ty?.label ?? '', who: who ?? '', day: dayName(s.date) })} className={cn('sch-pop flex min-h-11 w-full cursor-pointer flex-col items-start justify-center rounded-md border px-2 py-1 text-left transition-[transform,box-shadow] duration-200 hover:-translate-y-px hover:shadow-[var(--shadow-sm)] sm:min-h-10', ring, open ? 'border-dashed border-[color-mix(in_oklab,var(--destructive)_45%,var(--border))] bg-[color-mix(in_oklab,var(--destructive)_6%,var(--card))] text-foreground' : SHIFT_TONE[ti(s.type) % 3], fresh === s.id && 'sch-flash')}>
<span className="flex w-full items-center gap-1 text-[12px] font-semibold leading-tight">
{open && <Icon name="user-plus" size={11} className="shrink-0 text-destructive" />}
<span className="truncate">{ty?.label}</span>
</span>
<span className="text-[11px] tabular-nums leading-tight opacity-75">{range(ty)}</span>
</button>
}
/>
);
};
const open = shifts.filter((s) => !s.resource);
const staffed = (d: string, tid: string) => shifts.filter((s) => s.date === d && s.type === tid && s.resource).length;
const short = (d: string) => shiftTypes.reduce((a, ty) => a + Math.max(0, (ty.needed ?? 0) - staffed(d, ty.id)), 0);
return (
<Main>
<Header title={title} subtitle={subtitle}>
<span className={cn('inline-flex h-9 items-center gap-1.5 rounded-lg px-3 text-[13px] font-medium', open.length ? 'bg-[color-mix(in_oklab,var(--destructive)_10%,var(--card))] text-destructive' : 'bg-[color-mix(in_oklab,var(--success)_13%,var(--card))] text-success-foreground')}>
<Icon name={open.length ? 'alert-circle' : 'check-circle'} size={14} />
{fill(t.openN, { n: open.length })}
</span>
<Button
size="sm"
iconLeft={pub === 'busy' ? 'loader' : pub === 'done' ? 'check' : 'send'}
disabled={pub !== 'idle'}
onClick={async () => {
setPub('busy');
if (onAction) await onAction('publish', { shifts });
else await new Promise((r) => setTimeout(r, prefersReducedMotion() ? 0 : 800));
setPub('done');
setSay(t.published);
}}
>
{pub === 'busy' ? t.publishing : t.publish}
</Button>
</Header>
{pub === 'done' && (
<p role="status" className="sch-in mt-4 flex items-start gap-2 rounded-lg bg-[color-mix(in_oklab,var(--success)_10%,var(--card))] p-3 text-[13px] text-foreground">
<Icon name="check-circle" size={15} className="mt-px shrink-0 text-success-foreground" />
{t.published}
</p>
)}
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-4">
<div className="sch-up flex flex-wrap gap-x-4 gap-y-2 text-[12.5px] text-muted-foreground">
{shiftTypes.map((x, i) => (
<span key={x.id} className="inline-flex items-center gap-1.5">
<span aria-hidden className={cn('size-3 rounded-[4px] border', SHIFT_TONE[i % 3])} />
<span className="font-medium text-foreground">{x.label}</span>
<span className="tabular-nums">{range(x)}</span>
</span>
))}
</div>
{/* Tablet and desktop: people by days */}
<div className="sch-up overflow-x-auto rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] [--sch-d:60ms] max-md:hidden">
<table className="w-full min-w-[44rem] table-fixed border-collapse">
<caption className="sr-only">{title}</caption>
<thead>
<tr className="border-b border-border bg-surface">
<th scope="col" className="w-48 px-4 py-2.5 text-left text-[12px] font-medium text-muted-foreground lg:w-56">
{t.who}
</th>
{days.map((d) => {
const sh = short(d);
return (
<th key={d} scope="col" className={cn('border-s border-border px-1.5 py-2 text-center font-normal', d === today && 'bg-[color-mix(in_oklab,var(--primary)_5%,transparent)]')}>
<span className={cn('block text-[12.5px]', d === today ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{fmtDay(d, locale, { weekday: 'short' }).replace('.', '')} {fmtDay(d, locale, { day: 'numeric' })}</span>
<span className={cn('mt-0.5 inline-flex items-center gap-1 text-[11px] font-medium tabular-nums', sh ? 'text-destructive' : 'text-success-foreground')}>
<Icon name={sh ? 'alert-circle' : 'check'} size={10} />
{fill(t.staffed, { n: shiftTypes.reduce((a, ty) => a + staffed(d, ty.id), 0), m: shiftTypes.reduce((a, ty) => a + (ty.needed ?? 0), 0) })}
</span>
</th>
);
})}
</tr>
</thead>
<tbody>
{resources.map((r) => {
const h = hoursOf(r.id);
const c = r.capacity ?? 40;
return (
<tr key={r.id} className="border-b border-border">
<th scope="row" className="px-4 py-2 text-left font-normal">
<span className="flex min-w-0 items-center gap-2.5">
<Avatar name={r.name} size="sm" />
<span className="min-w-0">
<span className="block truncate text-[13.5px] font-medium text-foreground">{r.name}</span>
<span className={cn('block text-[12px] tabular-nums', h > c ? 'font-semibold text-destructive' : h === c ? 'text-success-foreground' : 'text-muted-foreground')}>{fill(t.contract, { h, c })}</span>
</span>
</span>
</th>
{days.map((d) => {
const mine = shifts.filter((s) => s.resource === r.id && s.date === d);
return (
<td key={d} className={cn('border-s border-border p-1 align-middle', d === today && 'bg-[color-mix(in_oklab,var(--primary)_3%,transparent)]')}>
{mine.length ? (
<div className="grid gap-1">{mine.map((s) => <div key={s.id} className="[&>span]:w-full">{chip(s, r.name)}</div>)}</div>
) : (
<div className="[&>span]:w-full">
<DropdownMenu
items={addMenu(r.id, d)}
trigger={
<button type="button" aria-label={fill(t.addShift, { who: r.name, day: dayName(d) })} className={cn('group/add grid min-h-10 w-full cursor-pointer place-items-center rounded-md text-subtle-foreground transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_5%,transparent)] hover:text-foreground', ring)}>
<Icon name="plus" size={14} className="opacity-40 transition-opacity group-hover/add:opacity-100 group-focus-visible/add:opacity-100" />
</button>
}
/>
</div>
)}
</td>
);
})}
</tr>
);
})}
</tbody>
<tfoot>
<tr className="bg-[color-mix(in_oklab,var(--destructive)_3%,var(--surface))]">
<th scope="row" className="px-4 py-2 text-left">
<span className="flex items-center gap-2 text-[13px] font-semibold text-foreground">
<span className="grid size-8 place-items-center rounded-full border border-dashed border-[color-mix(in_oklab,var(--destructive)_50%,var(--border))] text-destructive">
<Icon name="user-plus" size={14} />
</span>
{t.open}
</span>
</th>
{days.map((d) => {
const mine = open.filter((s) => s.date === d);
return (
<td key={d} className="border-s border-border p-1 align-top">
<div className="grid gap-1">{mine.map((s) => <div key={s.id} className="[&>span]:w-full">{chip(s, undefined, true)}</div>)}</div>
</td>
);
})}
</tr>
</tfoot>
</table>
</div>
{/* Phone: one day, grouped by shift */}
<div className="grid gap-3 md:hidden">
<DayTabs days={days} value={mday} onChange={setMday} label={t.day} locale={locale} today={today} extra={(i) => (short(days[i]) ? <span aria-hidden className="absolute end-1.5 top-1.5 size-1.5 rounded-full bg-destructive" /> : null)} />
<div key={mday} className="sch-in grid gap-3">
{shiftTypes.map((ty, i) => {
const d = days[mday];
const list = shifts.filter((s) => s.date === d && s.type === ty.id);
const st = staffed(d, ty.id);
return (
<section key={ty.id} aria-labelledby={`${id}-${ty.id}`} className="rounded-xl border border-border bg-card p-3 shadow-[var(--highlight),var(--shadow-sm)]">
<div className="flex items-center gap-2">
<span aria-hidden className={cn('size-3 rounded-[4px] border', SHIFT_TONE[i % 3])} />
<h2 id={`${id}-${ty.id}`} className="text-[14px] font-semibold text-foreground">
{ty.label}
</h2>
<span className="text-[12.5px] tabular-nums text-muted-foreground">{range(ty)}</span>
<span className={cn('ms-auto text-[12px] font-semibold tabular-nums', st < (ty.needed ?? 0) ? 'text-destructive' : 'text-success-foreground')}>{fill(t.staffed, { n: st, m: ty.needed ?? st })}</span>
</div>
<ul className="mt-2 grid gap-1.5">
{list.map((s) => {
const r = ctx.res(s.resource);
return (
<li key={s.id} className={cn('flex items-center gap-2 rounded-lg', fresh === s.id && 'sch-flash')}>
{r ? (
<>
<Avatar name={r.name} size="sm" />
<span className="min-w-0 flex-1 truncate text-[13.5px] text-foreground">{r.name}</span>
<DropdownMenu
align="end"
items={shiftMenu(s)}
trigger={
<button type="button" aria-label={fill(t.shiftMenu, { shift: ty.label, who: r.name, day: dayName(d) })} className={iconBtn}>
<Icon name="more-vertical" size={15} />
</button>
}
/>
</>
) : (
<div className="w-full [&>span]:w-full">{chip(s, undefined, true)}</div>
)}
</li>
);
})}
</ul>
</section>
);
})}
</div>
</div>
</div>
<Live text={say} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* room-day */
/* ------------------------------------------------------------------ */
function RoomDay({ ctx, title, subtitle, resources, bookings: initial = [], today, now = '10:40', dayStart = 8, dayEnd = 19, onAction }: VP) {
const { t, locale } = ctx;
const id = useId();
const [items, setItems] = useState<Booking[]>(initial.filter((b) => b.date === today));
const [minSeats, setMinSeats] = useState('0');
const [sel, setSel] = useState<{ room: string; from: number; to: number } | null>(null);
const [len, setLen] = useState(60);
const [what, setWhat] = useState('');
const [say, setSay] = useState('');
const [fresh, setFresh] = useState<string | null>(null);
const [mroom, setMroom] = useState(resources[0]?.id ?? '');
const [scope, enter] = useEnter<HTMLDivElement>();
const [rowRef, fade] = useScrollFade<HTMLDivElement>();
const formRef = useRef<HTMLFormElement>(null);
const S = dayStart * 60;
const E = dayEnd * 60;
const nowM = toMin(now);
const pct = (m: number) => round(((Math.max(S, Math.min(E, m)) - S) / (E - S)) * 100, 3);
const rooms = resources.filter((r) => (r.seats ?? 0) >= Number(minSeats));
const hours = Array.from({ length: dayEnd - dayStart }, (_, i) => dayStart + i);
const tm = (m: number) => formatTime(hhmm(m), locale);
// Free gaps of at least 30 minutes from now on, between the bookings of a room
const gapsOf = (rid: string) => {
const list = items.filter((b) => b.resource === rid).map((b) => [toMin(b.start ?? '00:00'), toMin(b.end ?? '00:00')] as const).sort((a, b) => a[0] - b[0]);
const out: [number, number][] = [];
let cur = Math.max(S, Math.ceil(nowM / 15) * 15);
for (const [a, b] of list) {
if (a - cur >= 30) out.push([cur, a]);
cur = Math.max(cur, b);
}
if (E - cur >= 30) out.push([cur, E]);
return out;
};
const choose = (room: string, from: number, to: number) => {
setSel({ room, from, to });
setLen(Math.min(60, to - from));
setWhat('');
requestAnimationFrame(() => {
formRef.current?.scrollIntoView({ block: 'nearest', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
formRef.current?.querySelector('input')?.focus({ preventScroll: true });
});
};
const roomName = (rid: string) => ctx.res(rid)?.name ?? rid;
const lens = [30, 60, 90, 120];
return (
<Main>
<Header title={title} subtitle={subtitle ?? fmtDay(today, locale, { weekday: 'long', day: 'numeric', month: 'long' })}>
<Segmented
label={t.roomSize}
value={minSeats}
onChange={setMinSeats}
options={[
{ value: '0', label: t.all },
{ value: '4', label: fill(t.seatsMin, { n: 4 }) },
{ value: '8', label: fill(t.seatsMin, { n: 8 }) },
]}
/>
</Header>
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-4">
{/* Tablet and desktop: rooms by hours with a line for now */}
<div className="sch-up overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] max-xl:hidden">
<div className="grid grid-cols-[11rem_minmax(0,1fr)] border-b border-border bg-surface lg:grid-cols-[13rem_minmax(0,1fr)]">
<span className="px-4 py-2.5 text-[12px] font-medium text-muted-foreground">{t.rooms}</span>
<div className="relative h-full">
{hours.map((h) => (
<span key={h} className="absolute top-1/2 -translate-y-1/2 ps-1.5 text-[11.5px] tabular-nums text-muted-foreground" style={{ left: `${pct(h * 60)}%` }}>
{formatTime(`${h}:00`, locale).replace(':00', '')}
</span>
))}
{nowM > S && nowM < E && (
<span aria-hidden className="absolute bottom-0 h-2 w-px bg-destructive" style={{ left: `${pct(nowM)}%` }}>
<span className="absolute -top-1 left-1/2 size-2 -translate-x-1/2 rounded-full bg-destructive" />
</span>
)}
</div>
</div>
<div className="relative">
{rooms.map((r, ri) => {
const list = items.filter((b) => b.resource === r.id);
const gaps = gapsOf(r.id);
return (
<div key={r.id} className="grid grid-cols-[11rem_minmax(0,1fr)] border-b border-border last:border-b-0 lg:grid-cols-[13rem_minmax(0,1fr)]">
<div className="min-w-0 px-4 py-3">
<p className="truncate text-[13.5px] font-medium text-foreground">{r.name}</p>
<p className="mt-0.5 flex items-center gap-2 text-[12px] text-muted-foreground">
<span className="inline-flex items-center gap-1 tabular-nums">
<Icon name="users" size={12} />
{r.seats}
</span>
{(r.features ?? []).map((f) => (
<Icon key={f} name={f} size={12} />
))}
</p>
</div>
<div className="relative h-[4.25rem]">
{hours.map((h) => (
<span key={h} aria-hidden className="absolute inset-y-0 border-s border-border" style={{ left: `${pct(h * 60)}%` }} />
))}
{/* The past is shaded */}
<span aria-hidden className="absolute inset-y-0 start-0 bg-[repeating-linear-gradient(135deg,color-mix(in_oklab,var(--foreground)_4%,transparent)_0_5px,transparent_5px_10px)]" style={{ width: `${pct(nowM)}%` }} />
{nowM > S && nowM < E && <span aria-hidden className="absolute inset-y-0 w-px bg-destructive" style={{ left: `${pct(nowM)}%` }} />}
{gaps.map(([a, b]) => (
<button
key={a}
type="button"
aria-label={`${fill(t.free, { from: tm(a), to: tm(b) })}, ${fill(t.bookFree, { room: r.name, from: tm(a) })}`}
onClick={() => choose(r.id, a, b)}
className={cn('group/gap absolute inset-y-2 cursor-pointer rounded-lg border border-dashed border-transparent text-[12px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))] transition-colors hover:border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] hover:bg-[color-mix(in_oklab,var(--primary)_6%,transparent)] focus-visible:border-[color-mix(in_oklab,var(--primary)_50%,var(--border))]', ring, sel?.room === r.id && sel.from === a && 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_9%,transparent)]')}
style={{ left: `${pct(a)}%`, width: `${round(pct(b) - pct(a), 3)}%` }}
>
<span className="flex h-full items-center justify-center gap-1 opacity-0 transition-opacity group-hover/gap:opacity-100 group-focus-visible/gap:opacity-100">
<Icon name="plus" size={13} />
{b - a >= 60 && tm(a)}
</span>
</button>
))}
{list.map((b, bi) => {
const a = toMin(b.start ?? '00:00');
const e = toMin(b.end ?? '00:00');
const live = a <= nowM && e > nowM;
return (
<div key={b.id} className={cn('sch-wipe absolute inset-y-2 z-[2] flex min-w-0 flex-col justify-center overflow-hidden rounded-lg border', e - a <= 45 ? 'px-1.5' : 'px-2.5', TONES[(b.tone ?? 0) % 4], e <= nowM && 'opacity-55', fresh === b.id && 'sch-flash')} style={{ left: `${pct(a)}%`, width: `calc(${round(pct(e) - pct(a), 3)}% - 2px)`, '--sch-d': `${ri * 60 + bi * 40}ms` } as React.CSSProperties} title={`${b.title} ${tm(a)}–${tm(e)}`}>
<span className="flex min-w-0 items-center gap-1.5">
{live && (
<span aria-hidden className="relative grid size-1.5 shrink-0 place-items-center">
<span className="sch-pulse absolute size-1.5 rounded-full bg-destructive" />
<span className="size-1.5 rounded-full bg-destructive" />
</span>
)}
<span className="truncate text-[12.5px] font-semibold">{b.title}</span>
</span>
<span className={cn('truncate text-[11.5px] tabular-nums text-muted-foreground', e - a <= 45 && 'hidden')}>
{tm(a)}–{tm(e)}
{b.by ? ` · ${b.by}` : ''}
</span>
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
<p className="sch-up text-[12.5px] text-muted-foreground max-xl:hidden">
<span className="me-1.5 inline-block h-2.5 w-px translate-y-0.5 bg-destructive" />
{fill(t.nowLine, { t: formatTime(now, locale) })} · {t.pickGap}
</p>
{/* Phone: one room as an agenda */}
<div className="grid grid-cols-[minmax(0,1fr)] gap-3 xl:hidden">
<div ref={rowRef} role="radiogroup" aria-label={t.rooms} className={cn('kit-no-scrollbar -mx-4 flex gap-1.5 overflow-x-auto px-4', fade)}>
{rooms.map((r) => (
<button key={r.id} type="button" role="radio" aria-checked={mroom === r.id} onClick={() => setMroom(r.id)} className={cn('flex h-11 shrink-0 cursor-pointer items-center gap-1.5 rounded-lg border px-3 text-[13px] font-medium', ring, mroom === r.id ? 'border-transparent bg-foreground text-background' : 'border-border bg-card text-muted-foreground')}>
{r.name}
<span className="tabular-nums opacity-70">{r.seats}</span>
</button>
))}
</div>
<ol key={mroom} className="sch-in relative grid gap-2">
{[...items.filter((b) => b.resource === mroom).map((b) => ({ kind: 'b' as const, a: toMin(b.start ?? '00:00'), e: toMin(b.end ?? '00:00'), b })), ...gapsOf(mroom).map(([a, e]) => ({ kind: 'g' as const, a, e, b: null }))]
.sort((x, y) => x.a - y.a)
.map((x) =>
x.kind === 'b' && x.b ? (
<li key={x.b.id} className={cn('flex gap-3 rounded-xl border p-3', TONES[(x.b.tone ?? 0) % 4], x.e <= nowM && 'opacity-60', fresh === x.b.id && 'sch-flash')}>
<span className="w-14 shrink-0 text-[12.5px] font-semibold tabular-nums">{tm(x.a)}</span>
<span className="min-w-0">
<span className="block text-[14px] font-semibold">{x.b.title}</span>
<span className="block text-[12.5px] tabular-nums text-muted-foreground">
{tm(x.a)}–{tm(x.e)}
{x.b.by ? ` · ${x.b.by}` : ''}
</span>
</span>
</li>
) : (
<li key={`g${x.a}`}>
<button type="button" onClick={() => choose(mroom, x.a, x.e)} className={cn('flex min-h-11 w-full cursor-pointer items-center gap-3 rounded-xl border border-dashed border-[color-mix(in_oklab,var(--primary)_40%,var(--border))] p-3 text-left text-[13.5px] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]', ring)}>
<Icon name="plus-circle" size={16} />
<span className="flex-1">{fill(t.free, { from: tm(x.a), to: tm(x.e) })}</span>
<Icon name="chevron-right" size={15} />
</button>
</li>
),
)}
{!gapsOf(mroom).length && <li className="py-3 text-center text-[13px] text-muted-foreground">{t.nothingFree}</li>}
</ol>
</div>
{sel && (
<form
ref={formRef}
className="sch-in grid gap-4 rounded-xl border border-border-strong bg-card p-4 shadow-[var(--shadow-md)] sm:p-5 md:grid-cols-[minmax(0,1fr)_auto_auto] md:items-end"
onSubmit={(e) => {
e.preventDefault();
const b: Booking = { id: `r${Date.now()}`, resource: sel.room, title: what.trim() || t.bookRoom, date: today, start: hhmm(sel.from), end: hhmm(sel.from + len), tone: 0, by: undefined };
setItems((x) => [...x, b]);
setFresh(b.id);
setSel(null);
setSay(fill(t.roomBooked, { room: roomName(b.resource), from: tm(sel.from), to: tm(sel.from + len) }));
void onAction?.('book', { booking: b });
}}
>
<label className="grid gap-1.5">
<span className="text-[13px] font-semibold text-foreground">{fill(t.bookingFor, { room: roomName(sel.room), from: tm(sel.from) })}</span>
<input value={what} onChange={(e) => setWhat(e.target.value)} placeholder={t.whatPlaceholder} className={field} />
</label>
<div className="grid gap-1.5">
<span id={`${id}-len`} className="text-[12px] font-medium text-muted-foreground">
{t.duration}
</span>
<Segmented label={t.duration} value={String(len)} onChange={(v) => setLen(Number(v))} className="w-full" options={lens.filter((l) => l <= sel.to - sel.from).map((l) => ({ value: String(l), label: fill(t.min, { n: l }) }))} />
</div>
<div className="flex gap-2">
<Button type="button" size="sm" variant="ghost" onClick={() => setSel(null)} className="max-md:h-11 max-md:flex-1">
{t.cancel}
</Button>
<Button type="submit" size="sm" iconLeft="check" className="max-md:h-11 max-md:flex-1">
{t.bookRoom}
</Button>
</div>
</form>
)}
</div>
<Live text={say} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* capacity */
/* ------------------------------------------------------------------ */
function heat(p: number) {
if (p > 100) return 'bg-[color-mix(in_oklab,var(--destructive)_22%,var(--card))] text-destructive font-semibold';
if (p >= 90) return 'bg-[color-mix(in_oklab,var(--primary)_55%,var(--card))] text-foreground font-semibold';
if (p >= 65) return 'bg-[color-mix(in_oklab,var(--primary)_28%,var(--card))] text-foreground';
if (p > 0) return 'bg-[color-mix(in_oklab,var(--primary)_10%,var(--card))] text-muted-foreground';
return 'bg-[color-mix(in_oklab,var(--foreground)_3%,var(--card))] text-subtle-foreground';
}
function Capacity({ ctx, title, subtitle, resources, load = [], weekStart, weeks: nw = 6 }: VP) {
const { t, locale } = ctx;
const id = useId();
const w0 = dayNum(weekStart);
const weeks = Array.from({ length: nw }, (_, i) => isoOf(w0 + i * 7));
const teams = [...new Set(resources.map((r) => r.team).filter((x): x is string => !!x))];
const [team, setTeam] = useState('all');
const [unit, setUnit] = useState<'p' | 'h'>('p');
const [cell, setCell] = useState<[number, number]>([0, 0]);
const [picked, setPicked] = useState<[string, string] | null>(() => {
const over = load.find((x) => x.projects.reduce((a, p) => a + p.hours, 0) > (resources.find((r) => r.id === x.resource)?.capacity ?? 40));
return over ? [over.resource, over.week] : null;
});
const [mweek, setMweek] = useState(0);
const [scope, enter] = useEnter<HTMLDivElement>();
const people = resources.filter((r) => team === 'all' || r.team === team);
const hoursOf = (rid: string, w: string) => (load.find((x) => x.resource === rid && x.week === w)?.projects ?? []).reduce((a, p) => a + p.hours, 0);
const capOf = (r: SchedResource) => r.capacity ?? 40;
const pctOf = (r: SchedResource, w: string) => Math.round((hoursOf(r.id, w) / capOf(r)) * 100);
const wk = (w: string, short = true) => fmtDay(w, locale, short ? { day: 'numeric', month: 'short' } : { day: 'numeric', month: 'long' });
const show = (r: SchedResource, w: string) => (unit === 'p' ? formatPercent(pctOf(r, w), locale) : `${hoursOf(r.id, w)} h`);
const avg = (w: string) => Math.round(people.reduce((a, r) => a + pctOf(r, w), 0) / Math.max(1, people.length));
const move = (e: React.KeyboardEvent, r: number, c: number) => {
const m: Record<string, [number, number]> = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1], Home: [0, -99], End: [0, 99] };
const v = m[e.key];
if (!v) return;
e.preventDefault();
const nr = Math.max(0, Math.min(people.length - 1, r + v[0]));
const nc = Math.max(0, Math.min(weeks.length - 1, c + v[1]));
setCell([nr, nc]);
document.getElementById(`${id}-${nr}-${nc}`)?.focus();
};
const pr = picked ? resources.find((r) => r.id === picked[0]) : undefined;
const pw = picked?.[1];
const detail = pr && pw && (
<div key={`${pr.id}${pw}`} className="sch-in">
<div className="flex items-center gap-3">
<Avatar name={pr.name} size="md" />
<div className="min-w-0">
<p className="truncate text-[15px] font-semibold text-foreground">{pr.name}</p>
<p className="text-[12.5px] text-muted-foreground">{fill(t.weekOf, { d: wk(pw, false) })}</p>
</div>
</div>
{(() => {
const h = hoursOf(pr.id, pw);
const c = capOf(pr);
const p = pctOf(pr, pw);
return (
<>
<p className="mt-4 flex items-baseline gap-2">
<span className={cn('text-[30px] font-semibold tabular-nums tracking-[-0.02em]', p > 100 ? 'text-destructive' : 'text-foreground')}>{formatPercent(p, locale)}</span>
<span className="text-[13px] tabular-nums text-muted-foreground">{fill(t.contract, { h, c })}</span>
</p>
<p className={cn('text-[13px] font-medium', h > c ? 'text-destructive' : h === c ? 'text-success-foreground' : 'text-muted-foreground')}>{h > c ? fill(t.over, { h: h - c }) : h === c ? t.exact : fill(t.under, { h: c - h })}</p>
<ul className="mt-4 grid gap-3">
{(load.find((x) => x.resource === pr.id && x.week === pw)?.projects ?? []).map((p, i) => (
<li key={p.name} className="grid gap-1.5">
<span className="flex items-center justify-between gap-3 text-[13px]">
<span className="truncate text-foreground">{p.name}</span>
<span className="tabular-nums text-muted-foreground">{p.hours} h</span>
</span>
<span aria-hidden className="relative h-1.5 overflow-hidden rounded-full bg-track">
<span className={cn('absolute inset-y-0 start-0 rounded-full', TONE_BAR[i % 4])} style={{ width: `${round(Math.min(100, (p.hours / c) * 100), 1)}%` }} />
</span>
</li>
))}
</ul>
</>
);
})()}
</div>
);
return (
<Main>
<Header title={title} subtitle={subtitle}>
{teams.length > 1 && (
<Segmented
label={t.team}
value={team}
onChange={(v) => (setTeam(v), setCell([0, 0]))}
options={[{ value: 'all', label: t.all }, ...teams.map((x) => ({ value: x, label: x }))]}
/>
)}
<Segmented
label={t.unit}
value={unit}
onChange={setUnit}
options={[
{ value: 'p', label: '%' },
{ value: 'h', label: t.hours },
]}
/>
</Header>
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-4 lg:grid-cols-[minmax(0,1fr)_19rem] lg:items-start">
<div className="grid min-w-0 gap-3">
<div className="sch-up overflow-x-auto rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] max-md:hidden">
<table role="grid" aria-label={t.capacity} className="w-full min-w-[38rem] table-fixed border-collapse">
<thead>
<tr className="border-b border-border bg-surface">
<th scope="col" className="w-48 px-4 py-2.5 text-left text-[12px] font-medium text-muted-foreground">
{t.who}
</th>
{weeks.map((w) => (
<th key={w} scope="col" className="px-1 py-2.5 text-center text-[12px] font-medium tabular-nums text-muted-foreground">
{wk(w)}
</th>
))}
</tr>
</thead>
<tbody>
{people.map((r, ri) => (
<tr key={r.id} className="border-b border-border last:border-b-0">
<th scope="row" className="px-4 py-2 text-left font-normal">
<span className="flex min-w-0 items-center gap-2.5">
<Avatar name={r.name} size="sm" />
<span className="min-w-0">
<span className="block truncate text-[13.5px] font-medium text-foreground">{r.name}</span>
<span className="block truncate text-[12px] text-muted-foreground">{r.role}</span>
</span>
</span>
</th>
{weeks.map((w, wi) => {
const p = pctOf(r, w);
const on = picked?.[0] === r.id && picked[1] === w;
return (
<td key={w} className="p-1">
<button
id={`${id}-${ri}-${wi}`}
type="button"
role="gridcell"
tabIndex={cell[0] === ri && cell[1] === wi ? 0 : -1}
aria-selected={on}
aria-label={fill(t.utilization, { who: r.name, d: wk(w, false), p: formatPercent(p, locale) })}
onClick={() => (setPicked([r.id, w]), setCell([ri, wi]))}
onKeyDown={(e) => move(e, ri, wi)}
onFocus={() => setCell([ri, wi])}
className={cn('sch-up grid h-12 w-full cursor-pointer place-items-center rounded-md text-[13px] tabular-nums transition-[transform,box-shadow] duration-200 hover:-translate-y-px hover:shadow-[var(--shadow-sm)]', ring, heat(p), p > 100 && 'sch-stripes', on && 'shadow-[0_0_0_2px_var(--foreground)]')}
style={{ '--sch-d': `${ri * 40 + wi * 30}ms` } as React.CSSProperties}
>
{show(r, w)}
</button>
</td>
);
})}
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-border bg-surface">
<th scope="row" className="px-4 py-2.5 text-left text-[12.5px] font-semibold text-foreground">
{t.teamAvg}
</th>
{weeks.map((w) => (
<td key={w} className={cn('px-1 py-2.5 text-center text-[13px] font-semibold tabular-nums', avg(w) > 100 ? 'text-destructive' : 'text-foreground')}>
{formatPercent(avg(w), locale)}
</td>
))}
</tr>
</tfoot>
</table>
</div>
<div className="sch-up flex flex-wrap gap-x-4 gap-y-2 text-[12px] text-muted-foreground max-md:hidden">
{[
[10, t.legendLow],
[70, t.legendGood],
[95, t.legendFull],
[120, t.legendOver],
].map(([p, l]) => (
<span key={l as string} className="inline-flex items-center gap-1.5">
<span aria-hidden className={cn('size-3 rounded-[4px]', heat(p as number), (p as number) > 100 && 'sch-stripes')} />
{l}
</span>
))}
</div>
{/* Phone: one week, a bar per person */}
<div className="grid gap-3 md:hidden">
<div role="radiogroup" aria-label={t.capacity} className="kit-no-scrollbar -mx-4 flex gap-1.5 overflow-x-auto px-4">
{weeks.map((w, i) => (
<button key={w} type="button" role="radio" aria-checked={mweek === i} onClick={() => setMweek(i)} className={cn('flex h-11 shrink-0 cursor-pointer items-center rounded-lg border px-3 text-[13px] font-medium tabular-nums', ring, mweek === i ? 'border-transparent bg-foreground text-background' : 'border-border bg-card text-muted-foreground')}>
{wk(w)}
</button>
))}
</div>
<ul key={mweek} className="sch-in overflow-hidden rounded-xl border border-border bg-card">
{people.map((r) => {
const w = weeks[mweek];
const p = pctOf(r, w);
const on = picked?.[0] === r.id && picked[1] === w;
return (
<li key={r.id} className="border-b border-border last:border-b-0">
<button type="button" aria-expanded={on} onClick={() => setPicked(on ? null : [r.id, w])} className={cn('flex w-full cursor-pointer items-center gap-3 px-3 py-3 text-left', ring)}>
<Avatar name={r.name} size="sm" />
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-2">
<span className="truncate text-[13.5px] font-medium text-foreground">{r.name}</span>
<span className={cn('text-[13px] font-semibold tabular-nums', p > 100 ? 'text-destructive' : 'text-foreground')}>{show(r, w)}</span>
</span>
<span aria-hidden className="relative mt-1.5 block h-2 overflow-hidden rounded-full bg-track">
<span className={cn('absolute inset-y-0 start-0 rounded-full', p > 100 ? 'bg-destructive' : p >= 90 ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--primary)_55%,var(--card))]')} style={{ width: `${round(Math.min(100, p), 1)}%` }} />
</span>
</span>
</button>
{on && <div className="border-t border-border bg-surface p-4">{detail}</div>}
</li>
);
})}
</ul>
</div>
</div>
<aside aria-label={t.capacity} className="sch-up rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] [--sch-d:120ms] sm:p-5 max-md:hidden lg:sticky lg:top-6" aria-live="polite">
{detail ?? <p className="py-6 text-center text-[13px] text-muted-foreground">{t.pickCell}</p>}
</aside>
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* availability */
/* ------------------------------------------------------------------ */
function Availability({ ctx, title, subtitle, resources, busy = [], weekStart, days: n, today, now = '10:40', dayStart = 8, dayEnd = 18, onAction }: VP) {
const { t, locale } = ctx;
const id = useId();
const w0 = dayNum(weekStart);
const days = Array.from({ length: Math.min(n, 5) }, (_, i) => isoOf(w0 + i));
const [who, setWho] = useState<string[]>(resources.map((r) => r.id));
const [len, setLen] = useState(60);
const [allowOne, setAllowOne] = useState(false);
const [picked, setPicked] = useState<string | null>(null);
const [say, setSay] = useState('');
const [mday, setMday] = useState(Math.max(0, Math.min(days.length - 1, dayNum(today) - w0)));
const [scope, enter] = useEnter<HTMLDivElement>();
const step = 30;
const slots = Array.from({ length: ((dayEnd - dayStart) * 60) / step }, (_, i) => dayStart * 60 + i * step);
const past = (d: string, a: number) => dayNum(d) < dayNum(today) || (d === today && a < toMin(now));
const isBusy = (rid: string, d: string, a: number, b: number) => busy.some((x) => x.resource === rid && x.date === d && toMin(x.start) < b && toMin(x.end) > a);
const freeCount = (d: string, a: number, b: number) => who.filter((r) => !isBusy(r, d, a, b)).length;
const tm = (m: number) => formatTime(hhmm(m), locale);
const need = who.length;
const enough = need >= 2;
const suggestions = useMemo(() => {
if (!enough) return [];
const out: { d: string; a: number; free: number; missing: string[] }[] = [];
for (const d of days) {
if (dayNum(d) < dayNum(today)) continue;
for (const a of slots) {
const b = a + len;
if (b > dayEnd * 60 || past(d, a)) continue;
const f = freeCount(d, a, b);
if (f === need || (allowOne && f === need - 1)) out.push({ d, a, free: f, missing: who.filter((r) => isBusy(r, d, a, b)) });
}
}
// Everyone first, then the earliest, at most one suggestion per hour
const seen = new Set<string>();
return out
.sort((x, y) => y.free - x.free || dayNum(x.d) - dayNum(y.d) || x.a - y.a)
.filter((x) => {
const k = `${x.d}-${Math.floor(x.a / 60)}`;
if (seen.has(k)) return false;
seen.add(k);
return true;
})
.slice(0, 6);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [who, len, allowOne, busy, enough]);
const label = (s: { d: string; a: number }) => fill(t.slotLabel, { day: fmtDay(s.d, locale, { weekday: 'short', day: 'numeric', month: 'short' }), from: tm(s.a), to: tm(s.a + len) });
const grid = (ds: string[]) => (
<div className="grid grid-cols-[3rem_minmax(0,1fr)] gap-x-2">
<div aria-hidden className="grid" style={{ gridTemplateRows: `2.25rem repeat(${slots.length}, 0.875rem)` }}>
<span />
{slots.map((m) => (
<span key={m} className="-translate-y-1.5 text-right text-[11px] leading-none tabular-nums text-subtle-foreground">
{m % 60 === 0 ? tm(m).replace(':00', '') : ''}
</span>
))}
</div>
<div className="grid gap-x-1.5" style={{ gridTemplateColumns: `repeat(${ds.length}, minmax(0, 1fr))` }}>
{ds.map((d, di) => (
<div key={d} className="grid" style={{ gridTemplateRows: `2.25rem repeat(${slots.length}, 0.875rem)` }}>
<span className={cn('text-center text-[12.5px]', d === today ? 'font-semibold text-foreground' : 'text-muted-foreground')}>
{fmtDay(d, locale, { weekday: 'short' }).replace('.', '')}
<span className="ms-1 tabular-nums">{fmtDay(d, locale, { day: 'numeric' })}</span>
</span>
{slots.map((m, si) => {
const gone = past(d, m);
const f = enough && !gone ? freeCount(d, m, m + step) : 0;
const all = enough && f === need;
const most = enough && f === need - 1;
const inPick = picked && picked.startsWith(d) && m >= Number(picked.split('|')[1]) && m < Number(picked.split('|')[1]) + len;
return (
<span
key={m}
aria-hidden
className={cn('sch-up border-x border-border', si === 0 && 'rounded-t-md border-t', si === slots.length - 1 && 'rounded-b-md border-b', m % 60 === 0 && si > 0 && 'border-t border-t-[color-mix(in_oklab,var(--border)_60%,transparent)]', gone ? 'bg-[color-mix(in_oklab,var(--foreground)_5%,var(--card))]' : all ? 'bg-[color-mix(in_oklab,var(--primary)_32%,var(--card))]' : most ? 'bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))]' : 'bg-[repeating-linear-gradient(135deg,color-mix(in_oklab,var(--foreground)_6%,transparent)_0_4px,transparent_4px_8px)]', inPick && 'bg-primary')}
style={{ '--sch-d': `${di * 50}ms` } as React.CSSProperties}
/>
);
})}
</div>
))}
</div>
</div>
);
return (
<Main>
<Header title={title} subtitle={subtitle} />
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-4 xl:grid-cols-[minmax(0,1fr)_21rem] xl:items-start">
<div className="grid min-w-0 gap-4">
<section aria-labelledby={`${id}-who`} className="sch-up grid gap-4 rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5">
<div>
<h2 id={`${id}-who`} className="text-[13px] font-semibold text-foreground">
{t.pickPeople}
</h2>
<div className="mt-2 flex flex-wrap gap-1.5">
{resources.map((r) => {
const on = who.includes(r.id);
return (
<button key={r.id} type="button" aria-pressed={on} onClick={() => (setWho((w) => (on ? w.filter((x) => x !== r.id) : [...w, r.id])), setPicked(null))} className={cn('inline-flex h-11 cursor-pointer items-center gap-2 rounded-full border ps-1 pe-3.5 text-[13px] font-medium transition-[background-color,border-color,color,transform] duration-200 active:scale-[0.97] sm:h-9', ring, on ? 'border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_9%,var(--card))] text-foreground' : 'border-border text-muted-foreground hover:text-foreground')}>
<span className={cn('rounded-full transition-opacity', !on && 'opacity-50')}>
<Avatar name={r.name} size="xs" />
</span>
{r.name}
{on && <Icon name="check" size={13} className="text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]" />}
</button>
);
})}
</div>
{!enough && <p className="sch-in mt-2 text-[13px] font-medium text-warning-foreground">{t.needTwo}</p>}
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="grid gap-1.5">
<span className="text-[12px] font-medium text-muted-foreground">{t.meetingLength}</span>
<Segmented label={t.meetingLength} value={String(len)} onChange={(v) => (setLen(Number(v)), setPicked(null))} className="w-full sm:w-auto" options={[30, 45, 60, 90].map((l) => ({ value: String(l), label: fill(t.min, { n: l }) }))} />
</div>
<button type="button" role="switch" aria-checked={allowOne} onClick={() => setAllowOne(!allowOne)} className={cn('inline-flex h-11 cursor-pointer items-center gap-2.5 rounded-lg px-1 text-[13px] font-medium text-foreground sm:h-9', ring)}>
<span aria-hidden className={cn('relative h-5 w-9 rounded-full transition-colors', allowOne ? 'bg-primary' : 'bg-track')}>
<span className={cn('absolute top-0.5 size-4 rounded-full bg-background shadow-[var(--shadow-sm)] transition-[left] duration-200', allowOne ? 'left-[18px]' : 'left-0.5')} />
</span>
{t.allowOne}
</button>
</div>
</section>
<section aria-label={t.freeAll} className="sch-up rounded-xl border border-border bg-card p-3 shadow-[var(--highlight),var(--shadow-sm)] [--sch-d:80ms] sm:p-4">
<div className="max-md:hidden">{grid(days)}</div>
<div className="grid gap-3 md:hidden">
<DayTabs days={days} value={mday} onChange={setMday} label={t.day} locale={locale} today={today} />
{grid([days[mday]])}
</div>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5 text-[12px] text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<span aria-hidden className="size-3 rounded-[4px] bg-[color-mix(in_oklab,var(--primary)_32%,var(--card))]" />
{t.freeAll}
</span>
<span className="inline-flex items-center gap-1.5">
<span aria-hidden className="size-3 rounded-[4px] bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))]" />
{t.freeMost}
</span>
<span className="inline-flex items-center gap-1.5">
<span aria-hidden className="size-3 rounded-[4px] border border-border bg-[repeating-linear-gradient(135deg,color-mix(in_oklab,var(--foreground)_10%,transparent)_0_3px,transparent_3px_6px)]" />
{t.busy}
</span>
<span className="inline-flex items-center gap-1.5">
<span aria-hidden className="size-3 rounded-[4px] border border-border bg-[color-mix(in_oklab,var(--foreground)_5%,var(--card))]" />
{t.past}
</span>
</div>
</section>
</div>
<aside aria-labelledby={`${id}-sug`} className="sch-up rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] [--sch-d:140ms] max-xl:order-first xl:sticky xl:top-6">
<h2 id={`${id}-sug`} className="flex items-center gap-2 border-b border-border px-4 py-3.5 text-[14px] font-semibold text-foreground sm:px-5">
<Icon name="sparkle" size={15} className="text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]" />
{t.suggestions}
</h2>
{suggestions.length ? (
<ul key={`${who.join()}${len}${allowOne}`}>
{suggestions.map((s, i) => {
const k = `${s.d}|${s.a}`;
const on = picked === k;
return (
<li key={k} className="sch-in flex items-center gap-3 border-b border-border px-4 py-3 last:border-b-0 sm:px-5" style={{ animationDelay: `${i * 50}ms` }}>
<span className="min-w-0 flex-1">
<span className="block text-[13.5px] font-medium text-foreground">{label(s)}</span>
<span className={cn('mt-0.5 flex items-center gap-1 text-[12px]', s.free === need ? 'text-success-foreground' : 'text-warning-foreground')}>
<Icon name={s.free === need ? 'check-circle' : 'alert-circle'} size={12} />
{s.free === need ? fill(t.allFree, { n: need }) : fill(t.oneMissing, { who: s.missing.map((m) => ctx.res(m)?.name.split(' ')[0]).join(', ') })}
</span>
</span>
{on ? (
<span className="sch-pop inline-flex h-9 items-center gap-1 text-[13px] font-medium text-success-foreground">
<Icon name="check" size={15} />
</span>
) : (
<Button
size="sm"
variant={i === 0 ? 'primary' : 'outline'}
aria-label={`${t.pick}, ${label(s)}`}
className="max-sm:h-11"
onClick={() => {
setPicked(k);
setSay(fill(t.picked, { when: label(s) }));
void onAction?.('pick', { date: s.d, start: hhmm(s.a), end: hhmm(s.a + len), people: who });
}}
>
{t.pick}
</Button>
)}
</li>
);
})}
</ul>
) : (
<p className="px-5 py-8 text-center text-[13px] text-muted-foreground">{enough ? t.noSlot : t.needTwo}</p>
)}
{picked && (
<p role="status" className="sch-in m-3 flex items-start gap-2 rounded-lg bg-[color-mix(in_oklab,var(--success)_10%,var(--card))] p-3 text-[13px] text-foreground">
<Icon name="check-circle" size={15} className="mt-px shrink-0 text-success-foreground" />
{fill(t.picked, { when: label({ d: picked.split('|')[0], a: Number(picked.split('|')[1]) }) })}
</p>
)}
</aside>
</div>
<Live text={say} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* Scheduler */
/* ------------------------------------------------------------------ */
/**
* Planning screens for teams and rooms: bookings per person and day you drag
* and resize, a shift plan with open shifts, rooms by the hour with a line for
* now, capacity as a heatmap and the best common time for a meeting.
* <Scheduler variant="resource-week" title="Team plan" resources={…} bookings={…} weekStart="2026-10-19" />
*/
export function Scheduler({ variant = 'resource-week', sidebar, labels, className, resources = [], today = '2026-10-21', weekStart = '2026-10-19', days = 5, ...props }: SchedulerProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const ctx: Ctx = useMemo(() => ({ t, locale, res: (i) => (i ? resources.find((r) => r.id === i) : undefined) }), [t, locale, resources]);
const p: VP = { ...props, ctx, resources, today, weekStart, days };
const body = (() => {
switch (variant) {
case 'shift-plan':
return <ShiftPlan {...p} />;
case 'room-day':
return <RoomDay {...p} />;
case 'capacity':
return <Capacity {...p} />;
case 'availability':
return <Availability {...p} />;
default:
return <ResourceWeek {...p} />;
}
})();
if (!sidebar)
return (
<div className={cn('min-h-dvh bg-background', className)}>
<Style />
{body}
</div>
);
return (
<Sidebar mobile="drawer" {...sidebar} className={className}>
<Style />
{body}
</Sidebar>
);
}Häufige Fragen
Wie installiere ich Ressourcenplanung?
Mit aiw add @ai-website/scheduler. Der Befehl legt die Datei components/ui/scheduler.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind avatar, button, dropdown-menu, icon, motion und sidebar. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Ressourcenplanung?
5 Varianten, Teamwoche, Schichtplan, Räume heute, Auslastung und Termin finden. Du wählst sie mit der Prop variant, zum Beispiel variant="shift-plan".
Kann ich die Texte von Ressourcenplanung übersetzen?
Ja. Ressourcenplanung bringt 92 eigene Texte auf Deutsch und Englisch mit, etwa „Neue Buchung“, „Buchung anlegen“ und „Was“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Ressourcenplanung einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Ressourcenplanung mit aiw_components und installieren sie mit aiw_add @ai-website/scheduler. Nenn im Prompt die Variante, etwa variant="resource-week", dann schreibt der Assistent deine Inhalte direkt in die Props.