# Ziehen, Dauer ändern und Geschäftsregeln

> Verschiebungen beim Ziehen prüfen, Überlappungen verhindern, Zeiten sperren, Ereignisse fixieren, asynchron bestätigen und jede Ablehnung in der Drag-Karte erklären.

Source: https://superscheduler.org/de/docs/drag-resize-rules/
Reviewed: 2026-10-07

Steuern Sie jeden Frame eines Ziehvorgangs in onEventMoving und onEventResizing: Setzen Sie args.allowed = false und args.message, um eine Position mit Begründung abzulehnen. Verhindern Sie Überlappungen mit allowEventOverlap={false} (oder pro Frame mit args.allowOverlap), sperren Sie Zeiten mit gesperrten Zellen, fixieren Sie einzelne Ereignisse mit moveDisabled und resizeDisabled, und treffen Sie die endgültige Entscheidung in onEventMove oder onEventResize, bei Bedarf asynchron mit args.async = true und args.loaded().

Beim Ziehen beweist eine Planungstafel ihren Wert, und hier stecken die meisten Geschäftsregeln: Dieser Auftrag braucht eine Hebebühne, jener Aufenthalt darf nicht in die Vergangenheit verschoben werden, der OP-Saal ist über Mittag geschlossen. SuperScheduler Pro fragt Ihren Code zu zwei Zeitpunkten. **Während der Nutzer zieht**, bei jeder Änderung des Schattens, können Sie die Position akzeptieren, ablehnen oder anpassen und den Grund nennen. **Beim Ablegen**, einmalig, können Sie die Änderung abbrechen, abwandeln oder bestätigen, auch nach einer Rückfrage bei Ihrem Server.

Dieser Leitfaden baut diese Regeln an einer Werkstattplanung mit Arbeitsplätzen auf und behandelt danach Überlappungen, gesperrte Zeiten, Sperren, asynchrone Bestätigung, die Drag-Karte und das Anlegen von Ereignissen durch Auswahl eines Zeitraums. Alles hier erfordert Pro; Lite ist schreibgeschützt.

## Wie über einen Ziehvorgang entschieden wird
1. Der Nutzer greift ein Ereignis. Gesperrte Ereignisse (`moveDisabled`) starten keinen Ziehvorgang.
2. Bei jeder Zeigerbewegung, die Zielzeit oder Zielzeile ändert, läuft **`onEventMoving`** (`onEventResizing` beim Ändern der Dauer). Ihre Regel setzt `args.allowed`, darf `args.start` und `args.end` anpassen und setzt `args.message`.
3. Danach wendet die Bibliothek ihre eigenen Prüfungen an: Überlappung mit anderen Ereignissen, wenn `allowEventOverlap` `false` ist, und gesperrte Zellen. Ein abgelehnter Schatten wird als verboten gezeichnet, und die Drag-Karte zeigt den Grund.
4. Beim Loslassen über einer abgelehnten Position passiert nichts: Das Ereignis kehrt zurück, und kein weiterer Callback läuft.
5. Beim Loslassen über einer akzeptierten Position läuft **`onEventMove`** (`onEventResize`) einmal, bevor sich der Speicher ändert. Es kann das Übernehmen abbrechen, abwandeln oder aufschieben.
6. Der Speicher wird aktualisiert, **`onEventMoved`** (`onEventResized`) läuft, und `onEventsChange` folgt im nächsten Microtask, wie in [Kontrollierte Ereignisse und Callbacks](https://superscheduler.org/de/docs/controlled-state/#callback-order) beschrieben.

Dieselbe Abfolge beim Übernehmen gilt für Verschiebungen und Dauer-Änderungen per Tastatur mit `keyboardMode: 'Full'`.

## Beim Ziehen prüfen
`onEventMoving` erhält die vorgeschlagene Position und schreibt die Entscheidung in seine Argumente zurück:

| Beschreibbar | Wirkung |
|---|---|
| `allowed` | `false` zeichnet den Schatten als verboten; Ablegen dort bewirkt nichts |
| `message` | Text, den die Drag-Karte zeigt, solange `allowed` `false` ist |
| `start`, `end` | Passen den Schatten an, zum Beispiel um die ursprünglichen Zeiten zu behalten, wenn sich nur die Zeile ändert |
| `allowOverlap` | Überschreibt `allowEventOverlap` nur für diesen Frame |
| `cssClass`, `html` | Klasse und Inhalt des Schattens |

Außerdem liest es den Kontext: `args.e` (das gezogene Ereignis, Ihre Daten in `args.e.data`), `args.resource` und `args.row` (die Zielzeile), `args.duration`, `args.conflicts`, `args.external` (von außerhalb des Planers gezogen) und die Modifikatortasten. `onEventResizing` arbeitet genauso mit `start`, `end`, `allowed`, `message` und `allowOverlap` und ergänzt `args.what`, den gezogenen Rand (`'start'` oder `'end'`).

```tsx
// src/WorkshopPlanning.tsx
import { useMemo } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'

const BAYS: SuperScheduler.ResourceData[] = [
  { id: 'bay-1', name: 'Bay 1 (lift)' },
  { id: 'bay-2', name: 'Bay 2 (lift)' },
  { id: 'bay-3', name: 'Bay 3' },
  { id: 'waiting', name: 'Waiting list' },
]
const BAYS_WITH_LIFT: ReadonlySet<SuperScheduler.ResourceId> = new Set(['bay-1', 'bay-2'])

const TIME_HEADERS: SuperScheduler.TimeHeaderData[] = [
  { groupBy: 'Day', format: 'dddd d MMMM' },
  { groupBy: 'Hour', format: 'HH:mm' },
]

/** A custom field of the job data (see "Custom fields" in the data model guide). */
function needsLift(data: SuperScheduler.EventData): boolean {
  return 'needsLift' in data && data.needsLift === true
}

interface Props {
  readonly jobs: SuperScheduler.EventData[]
  readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
}

export function WorkshopPlanning({ jobs, onEventsChange }: Props) {
  const config = useMemo<SchedulerProps>(
    () => ({
      startDate: '2026-10-12',
      days: 5,
      scale: 'CellDuration',
      cellDuration: 30,
      cellWidth: 48,
      timeHeaders: TIME_HEADERS,
      businessBeginsHour: 8,
      businessEndsHour: 18,
      showNonBusiness: false,
      useEventBoxes: 'Never',
      allowEventOverlap: false,
      conflictHighlight: true,

      // Runs on every shadow change: keep it synchronous and cheap.
      onEventMoving: (args) => {
        if (args.start.getTime() < SuperScheduler.Date.now().getTime()) {
          args.allowed = false
          args.message = 'Jobs cannot be moved into the past.'
          return
        }
        if (needsLift(args.e.data) && !BAYS_WITH_LIFT.has(args.resource)) {
          args.allowed = false
          args.message = 'This job needs a bay with a lift.'
          return
        }
        // The waiting list may hold overlapping jobs; the bays may not (allowEventOverlap above).
        args.allowOverlap = args.resource === 'waiting'
      },

      onEventResizing: (args) => {
        const minutes = (args.end.getTime() - args.start.getTime()) / 60_000
        if (minutes < 30) {
          args.allowed = false
          args.message = 'A job takes at least 30 minutes.'
        }
      },
    }),
    [],
  )

  const owned = useMemo(() => jobs.slice(), [jobs])

  return (
    <SuperSchedulerComponent
      {...config}
      resources={BAYS}
      events={owned}
      onEventsChange={onEventsChange}
    />
  )
}
```
Sie sollten eine Fünf-Tage-Planung in Halbstundenzellen von 08:00 bis 18:00 Uhr sehen. Ziehen Sie einen Auftrag, der eine Hebebühne braucht, auf Bay 3: Der Schatten wird als verboten markiert, und die Karte zeigt „This job needs a bay with a lift.“. Ziehen Sie einen beliebigen Auftrag auf einem Arbeitsplatz über einen anderen: Das wird als Überlappung abgelehnt. Legen Sie ihn auf der Warteliste ab: Dort stapeln sich beide Aufträge. Kürzen Sie einen Auftrag auf unter 30 Minuten: Die Dauer-Änderung wird abgelehnt.

> **Tip:**
> Diese Handler laufen in jedem Frame eines Ziehvorgangs; halten Sie sie also synchron und günstig: Schlagen Sie in vorberechneten Sets und Maps nach, rufen Sie nie einen Server auf. Langsame Prüfungen gehören in `onEventMove`, das einmal läuft.

## Überlappungen verhindern
`allowEventOverlap={false}` lehnt jede Verschiebung, Dauer-Änderung oder Zeitraumauswahl ab, die sich mit einem anderen Ereignis in derselben Zeile überlappen würde. Intervalle sind halboffen; direkt aufeinanderfolgende Ereignisse (eines endet um 11:00 Uhr, das nächste beginnt um 11:00 Uhr) gelten also nie als Überlappung.

Zwei Werkzeuge verfeinern die Regel je nach Situation:

- **`args.allowOverlap`** in `onEventMoving` und `onEventResizing` überschreibt die Option für den aktuellen Frame, wie es die Warteliste oben tut. Der Wert wird bei jedem Aufruf zurückgesetzt.
- **`args.conflicts`** listet die bestehenden Ereignisse, mit denen der Schatten in der Zielzeile kollidiert (bis zu acht), als `SuperScheduler.Event`-Wrapper. Damit unterscheiden Sie weiche von harten Konflikten:

```ts
// src/soft-conflicts.ts
import type { SchedulerProps, SuperScheduler } from 'super-scheduler'

function isTentative(data: SuperScheduler.EventData): boolean {
  return 'status' in data && data.status === 'tentative'
}

/** Tentative jobs may be double-booked; confirmed ones may not. */
export const softConflicts: Pick<
  SchedulerProps,
  'allowEventOverlap' | 'conflictHighlight' | 'onEventMoving'
> = {
  allowEventOverlap: false,
  // Outlines the events the shadow collides with (data-conflict) while dragging.
  conflictHighlight: true,
  onEventMoving: (args) => {
    // Up to eight colliding events in the target row: feedback, not exhaustive validation.
    const hard = args.conflicts.find((event) => !isTentative(event.data))
    if (hard === undefined) {
      // For this frame only; the instance option stays false.
      args.allowOverlap = true
      return
    }
    args.allowed = false
    args.message = `Overlaps ${hard.text()}, which is confirmed.`
  },
}
```
`conflictHighlight` umrandet die kollidierenden Ereignisse, während der Nutzer zieht (sie erhalten ein Attribut `data-conflict` und eine Umrandung in der Warnfarbe). So sieht der Nutzer, was im Weg ist, und nicht nur, dass etwas im Weg ist.

> **Limitation:**
> `args.conflicts` ist eine begrenzte Stichprobe für das Feedback, keine vollständige Validierung: Bei vielen Kollisionen listet es höchstens acht. Überlappungsregeln, die für jedes Ereignis gelten müssen, gehören zusätzlich auf Ihren Server.

## Zeiten mit gesperrten Zellen blockieren
Eine gesperrte Zelle wird schraffiert gezeichnet und lehnt Verschiebungen, Dauer-Änderungen und Zeitraumauswahlen ab, die sie berühren. Zellen lassen sich auf zwei Arten sperren:

- **Eine ganze Zeile:** `cellsDisabled: true` an der Ressource, für einen Arbeitsplatz in Reparatur oder ein Zimmer außer Betrieb.
- **Eine beliebige Zelle:** Setzen Sie `args.cell.properties.disabled = true` in `onBeforeCellRender`, für Mittagspausen, Feiertage oder Öffnungszeiten pro Ressource.

```tsx
// src/ClosedTime.tsx
import { useMemo } from 'react'
import { SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerBeforeCellRenderArgs, SuperScheduler } from 'super-scheduler'

const BAYS: SuperScheduler.ResourceData[] = [
  { id: 'bay-1', name: 'Bay 1' },
  { id: 'bay-2', name: 'Bay 2' },
  // Closed for refurbishment: every cell of the row is disabled.
  { id: 'bay-3', name: 'Bay 3', cellsDisabled: true },
]

/**
 * Module level, so its identity never changes: a new function per render would invalidate the
 * per-cell cache. Disabled cells are hatched and reject drops, resizes and range selection.
 */
function closeLunchBreak(args: SchedulerBeforeCellRenderArgs): void {
  if (args.cell.start.getHours() === 13) {
    args.cell.properties.disabled = true
    args.cell.properties.cssClass = 'lunch-break'
  }
}

export function ClosedTime({ jobs }: { jobs: SuperScheduler.EventData[] }) {
  const owned = useMemo(() => jobs.slice(), [jobs])
  return (
    <SuperSchedulerComponent
      startDate="2026-10-12"
      days={5}
      scale="CellDuration"
      cellDuration={30}
      resources={BAYS}
      events={owned}
      onBeforeCellRender={closeLunchBreak}
    />
  )
}
```
Sie sollten Bay 3 über die ganze Zeile schraffiert sehen und jeden Arbeitsplatz von 13:00 bis 14:00 Uhr. Aufträge lassen sich weder in die Mittagspause ablegen noch über sie hinweg verlängern.

> **Behavior:**
> Die Ergebnisse von `onBeforeCellRender` werden pro Zelle gecacht. Hängt das Ergebnis von Ereignissen ab (zum Beispiel „volle“ Zellen), setzen Sie `cellsAutoUpdated: true` an der Ressource, damit ihre Zellen neu berechnet werden, wenn sich ihre Ereignisse ändern, oder rufen Sie nach der Änderung `control.update()` auf. Halten Sie die Identität des Handlers wie oben stabil, sonst verwirft jedes Rendering den Cache.

Weitere Möglichkeiten, geschlossene Zeiten auszudrücken: Blenden Sie sie mit `showNonBusiness={false}` oder `onIncludeTimeCell` aus der Achse aus (siehe [Stunden, Minuten, Tage und Zoom](https://superscheduler.org/de/docs/time-scales-zoom/#business-hours)), oder stellen Sie sie als Ereignisse dar, die sich weder verschieben noch in der Dauer ändern lassen (`moveDisabled`, `resizeDisabled`), etwa einen Wartungsblock; solche Ereignisse zählen ebenfalls als Überlappung, wenn `allowEventOverlap` `false` ist.

## Einzelne Ereignisse sperren
| Feld des Ereignisses | Wirkung |
|---|---|
| `moveDisabled` | Das Ereignis lässt sich nicht verschieben |
| `resizeDisabled` | Die Dauer des Ereignisses lässt sich nicht ändern |
| `moveHDisabled` | Es kann die Zeile wechseln, aber nicht die Zeit |
| `moveVDisabled` | Es kann die Zeit wechseln, aber nicht die Zeile |
| `clickDisabled`, `deleteDisabled` | Es ignoriert Klicks bzw. hat keinen Lösch-Button |

Für den ganzen Planer schalten `eventMoveHandling="Disabled"` und `eventResizeHandling="Disabled"` die Gesten ab. Regeln, die davon abhängen, wer hinsieht (die Rezeption darf verschieben, ein Gast nicht), sind Berechtigungen: Berechnen Sie diese Felder aus der Rolle des Nutzers, bevor Sie die Ereignisse übergeben.

## Beim Ablegen bestätigen, auch asynchron
`onEventMove` und `onEventResize` laufen einmal pro Ablegen, bevor sich etwas ändert. Sie können:

- **Abbrechen** mit `args.preventDefault()`.
- **Das Ergebnis abwandeln**, indem Sie `args.newStart`, `args.newEnd` oder `args.newResource` zuweisen.
- **Aufschieben** mit `args.async = true` und dann `args.loaded()` aufrufen, sobald Sie eine Antwort haben. Ein Aufruf von `args.preventDefault()` vor `loaded()` bricht das Ablegen ab.

```ts
// src/confirm-move.ts
import type {
  SchedulerEventMoveArgs,
  SchedulerEventResizeArgs,
  SuperScheduler,
} from 'super-scheduler'

function inProgress(data: SuperScheduler.EventData): boolean {
  return 'status' in data && data.status === 'inProgress'
}

/**
 * onEventMove: called once on drop, before the store changes. The library never awaits a
 * handler, so an asynchronous decision defers the drop with `async` and finishes it with `loaded()`.
 */
export function confirmMove(args: SchedulerEventMoveArgs): void {
  // A synchronous veto needs no async: cancel and return. This final check also covers moves
  // made with the keyboard.
  if (inProgress(args.e.data) && args.newResource !== args.e.resource()) {
    args.preventDefault()
    args.control.message('A job in progress stays in its bay.')
    return
  }

  args.async = true
  const resource = String(args.newResource)
  void (async () => {
    try {
      const question = `Move ${args.e.text()} to ${resource}, ${args.newStart.toString('ddd d MMM HH:mm')}?`
      if (!(await confirmWithUser(question))) {
        args.preventDefault()
        return
      }
      await saveBooking({
        id: String(args.e.id()),
        resource,
        start: args.newStart.value,
        end: args.newEnd.value,
      })
    } catch {
      args.preventDefault()
      args.control.message('The move could not be saved.')
    } finally {
      // Always: completes the drop, or cancels it when preventDefault() was called first.
      args.loaded()
    }
  })()
}

/** The same protocol for resizing; `what` tells which edge moved. */
export function confirmResize(args: SchedulerEventResizeArgs): void {
  args.async = true
  void saveBooking({
    id: String(args.e.id()),
    resource: String(args.e.resource()),
    start: args.newStart.value,
    end: args.newEnd.value,
  })
    .catch(() => args.preventDefault())
    .finally(() => args.loaded())
}
```
Binden Sie sie als `onEventMove={confirmMove}` und `onEventResize={confirmResize}` ein. Solange die Entscheidung aussteht, bleibt das Ereignis an seiner ursprünglichen Position; es bewegt sich, wenn `loaded()` das Ablegen abschließt, oder bleibt, wo es ist, wenn das Ablegen abgebrochen wurde.

> **Behavior:**
> Handler sind mit dem Rückgabetyp `void` typisiert, und die Bibliothek wartet nie auf sie. Ein `async`-Handler besteht die Typprüfung, aber das Ablegen wird übernommen, sobald er sein Promise zurückgibt. Für asynchrone Entscheidungen setzen Sie `args.async = true` synchron und rufen `args.loaded()` genau einmal in einem `finally`-Block auf, damit eine fehlgeschlagene Anfrage ein Ablegen nie in der Schwebe lässt.

## Die Drag-Karte
Während des Ziehens zeigt eine Karte neben dem Zeiger die Zieldaten, die Dauer (Nächte bei ganztägigen Zeiträumen, sonst Stunden und Minuten), die Zielzeile und den Grund, warum eine Position abgelehnt wird: Ihre `args.message` oder das Ereignis, mit dem sie sich überlappt. Zusätzlich erscheint eine Datumsmarke im Zeitkopf (`headerMarker`). Beides ist standardmäßig aktiv.

Konfigurieren Sie die Karte mit einem einzigen stabilen Objekt:

```ts
// src/drag-card.ts
import { SuperScheduler } from 'super-scheduler'

// One stable object at module level: a new object per render would reconfigure the card.
export const DRAG_CARD: SuperScheduler.DragCardOptions = {
  // Intraday work: show the time of both edges.
  dateFormat: 'ddd d MMM HH:mm',
  movingDateFormat: 'ddd d MMM HH:mm',
  // Whole-day ranges count nights by default; other ranges show hours and minutes.
  duration: 'auto',
  row: true,
  labels: { overlapping: 'Overlaps', forbidden: 'Not allowed' },
}

// Replace the content when a refusal needs more room. The string is trusted HTML.
export const DRAG_CARD_WITH_REASON: SuperScheduler.DragCardOptions = {
  ...DRAG_CARD,
  html: (info) => {
    if (info.refusal === null) return null // null keeps the default content
    const reason = SuperScheduler.Util.escapeHtml(info.refusal)
    const row = SuperScheduler.Util.escapeHtml(info.rowName ?? '')
    return `<strong>${reason}</strong><br>${row}`
  },
}
```
Übergeben Sie `dragCard={DRAG_CARD}` oder `dragCard={false}`, um sie zu entfernen. Die Standardbeschriftungen sind ins Englische, Spanische, Katalanische, Baskische, Galicische, Deutsche, Französische, Italienische und Portugiesische übersetzt und folgen der `locale` des Planers; `labels` überschreibt sie. Die Option `html` erhält die Daten, den Zeilennamen, den ersten Konflikt, die Ablehnungsmeldung und die Position des Ereignisses vor dem Ziehen.

## Ereignisse durch Auswahl von Zeit anlegen
Ziehen über leere Zellen wählt einen Zeitraum aus; `onTimeRangeSelected` meldet ihn mit `start`, `end` (exklusiv), `resource` und `origin`. Ob dort ein Ereignis entsteht, entscheidet Ihre Anwendung:

```tsx
// src/CreateOnSelect.tsx
import { useCallback, useMemo, useState } from 'react'
import { SuperSchedulerComponent } from 'super-scheduler'
import type {
  SchedulerEventsChangeArgs,
  SchedulerTimeRangeSelectedArgs,
  SchedulerTimeRangeSelectingArgs,
  SuperScheduler,
} from 'super-scheduler'

const BAYS: SuperScheduler.ResourceData[] = [
  { id: 'bay-1', name: 'Bay 1' },
  { id: 'bay-2', name: 'Bay 2' },
]

/** Steers the selection while it is drawn, as onEventMoving does for moves: four hours at most. */
function limitToFourHours(args: SchedulerTimeRangeSelectingArgs): void {
  args.allowed = args.end.getTime() - args.start.getTime() <= 4 * 3_600_000
}

export function CreateOnSelect() {
  const [jobs, setJobs] = useState<SuperScheduler.EventData[]>([])
  const owned = useMemo(() => jobs.slice(), [jobs])
  const onEventsChange = useCallback(
    (args: SchedulerEventsChangeArgs) => setJobs([...args.events]),
    [],
  )

  const onTimeRangeSelected = useCallback((args: SchedulerTimeRangeSelectedArgs) => {
    // The selection shadow stays until cleared.
    args.control.clearSelection()
    // A plain click on an empty cell also selects it (origin 'click'): create only on a drag.
    if (args.origin !== 'drag') return
    setJobs((current) => [
      ...current,
      {
        id: crypto.randomUUID(),
        resource: args.resource,
        // Store strings: start and end arrive as SuperScheduler.Date (end exclusive).
        start: args.start.value,
        end: args.end.value,
        text: 'New job',
      },
    ])
  }, [])

  return (
    <SuperSchedulerComponent
      startDate="2026-10-12"
      days={5}
      scale="CellDuration"
      cellDuration={30}
      resources={BAYS}
      events={owned}
      allowEventOverlap={false}
      onTimeRangeSelecting={limitToFourHours}
      onTimeRangeSelected={onTimeRangeSelected}
      onEventsChange={onEventsChange}
    />
  )
}
```
Sie sollten sehen, wie ein Auswahlschatten dem Zeiger folgt, nicht über vier Stunden hinaus oder über einen anderen Auftrag wächst und beim Loslassen zu einem Ereignis „New job“ wird.

Drei Verhaltensweisen sollten Sie kennen:

- **Ein einfacher Klick ist ebenfalls eine Auswahl.** Ein Klick auf eine leere Zelle löst `onTimeRangeSelected` mit `origin: 'click'` und einer Zelle aus. Prüfen Sie `origin === 'drag'`, wenn ein Klick nichts anlegen darf.
- **Die Auswahl bleibt nach dem Loslassen sichtbar**, bis zur nächsten Auswahl oder bis `args.control.clearSelection()`.
- **Auswahlen folgen denselben Regeln wie Verschiebungen:** Sie können weder gesperrte Zellen überqueren noch belegte Zeit, wenn `allowEventOverlap` `false` ist. `onTimeRangeSelecting` steuert sie Frame für Frame mit `args.allowed`.

## Was in Ihrer Anwendung bleibt
Die Bibliothek setzt durch, was Sie konfigurieren, und meldet, was passiert. Ihre Anwendung verantwortet die Regeln selbst (welche Ressource welche Arbeit annimmt, wer was ändern darf), die serverseitige Validierung jeder Änderung und jede automatische Platzierung oder Optimierung. Das Verschieben einer Buchung plant nie die anderen um: Braucht Ihr Geschäft kaskadierende Änderungen, berechnen Sie sie und aktualisieren die Ereignisse selbst.

→ https://superscheduler.org/de/examples/clinic-appointments/
→ https://superscheduler.org/de/examples/manufacturing-orders/
→ https://superscheduler.org/de/examples/sports-club-courts/
## Nächste Schritte
- Die Änderungen speichern, die diese Regeln akzeptieren: [Kontrollierte Ereignisse und Callbacks](https://superscheduler.org/de/docs/controlled-state/#persistence).
- Nutzern erlauben, eine Verschiebung rückgängig zu machen: [Rückgängig machen und Wiederholen](https://superscheduler.org/de/docs/undo-redo/).
- Dieselben Regeln per Tastatur nutzen: [Tastatur, Barrierefreiheit und Touch](https://superscheduler.org/de/docs/keyboard-accessibility-touch/).
