Zum Inhalt springen
SuperScheduler

KreativagenturStudio North (Fiktives Unternehmen)

Ressourcenplanung für eine Kreativagentur

Eine Designerin ist doppelt gebucht. Übergeben Sie zwei Aufgaben mit einem Ziehen, prüfen Sie, was sie freigeben, und nehmen Sie es zurück.

Zeitskala
Demo mit synthetischen Daten
Tage / Wochen
Pro-Funktionen
AbhängigkeitenMehrfachauswahlZeilenspaltenRückgängig und WiederholenReact-Render-Slots
Studio North
StrategieInes Varga70%DesignMira Okafor120%Jonas Brandt20%Leo Marchetti30%TextSaskia Moreau60%Theo Lindqvist40%ProduktionAda Kowalczyk0%KundeKundenreviewsMarkenauditKäufer-InsightsKuratoren-WorkshopLogo-FeinschliffKey VisualsPackaging-DesignClaimsAusstellungstexteKundenreviewKundenreviewKundenreviewLaunch-KitDruckproduktionFilmskripteSocial-Media-TexteLaunch-FilmTeaser-AnimationPlakatseriePortfolio-Update19. Okt.26. Okt.2. Nov.9. Nov.Mo., 19.Di., 20.Mi., 21.Do., 22.Fr., 23.Mo., 26.Di., 27.Mi., 28.Do., 29.Fr., 30.Mo., 2.Di., 3.Mi., 4.Do., 5.Fr., 6.Mo., 9.Di., 10.Mi., 11.Do., 12.Fr., 13.

SuperScheduler Pro wird erst geladen, wenn Sie es anfordern.

Die Ausgangslage

Studio North ist eine fiktive Agentur, in der Strategie, Design, Text und eine Producerin gleichzeitig an drei Kundenkampagnen arbeiten. Jede Kampagne ist eine Kette von Aufgaben: keine Key Visuals, bevor das Logo steht, kein Launch-Kit vor der Freigabe durch den Kunden.

In einer Tabelle fallen Probleme zu spät auf: eine Designerin, die unbemerkt auf zwei Kampagnen gleichzeitig gebucht ist, ein Kundenreview, das sich nicht verschieben lässt, eine Aufgabe bei jemandem, der sie nicht erledigen kann. Das Board muss die Auslastung jeder Person zeigen, Abhängigkeiten sichtbar halten, wenn Arbeit den Besitzer wechselt, und eine Umverteilung leicht rückgängig machen.

Was Sie tun werdenSie wählen zwei Aufgaben aus, übergeben sie mit einem Ziehen an einen anderen Designer, sehen, wie sich die Auslastungsspalte ausgleicht und die Verknüpfungen mitwandern, und nehmen die ganze Verschiebung in einem Schritt zurück.

Wo die Bibliothek endet und Ihre Anwendung beginnt

SuperScheduler liefert

  • Team-Baum mit Spalten im Zeilenkopf (Person und Auslastung) samt Spaltentiteln.
  • Auswahl per Klick und Strg/⌘+Klick, Multiselect-API und gemeinsames Verschieben über Zeilen hinweg (multiMoveVerticalMode: All).
  • Geschwungene Ende-Anfang-Verknüpfungen, die den Aufgaben von Zeile zu Zeile folgen, aktualisiert über die links-Prop.
  • Verlauf für Rückgängig und Wiederholen, in dem eine Mehrfachverschiebung ein einziger Eintrag ist, mit kontrollierten Ereignissen über onEventsChange.
  • React-Aufgabenbalken (renderEvent) und Hover-Karten (eventHover) aus super-scheduler/react-render; Zoomstufen nach Tagen und Wochen ohne Wochenenden.

Der Code dieses Beispiels entscheidet

  • Welche Rolle welche Disziplin übernehmen darf, und die Ablehnungsmeldung, wenn nicht.
  • Die Sprint-Auslastung pro Person (gebuchte Arbeitstage von zehn), geschrieben in die Auslastungsspalte.
  • Die Abhängigkeiten zwischen Aufgaben, wann eine verletzt ist, welche Verknüpfungen gezeichnet werden (die der ausgewählten Kampagnen oder alle) und was die Hover-Karte auflistet.
  • Fest mit dem Kunden vereinbarte Reviews, die Auswahlübersicht, die Mission und alle Texte dieser Seite.

Der Code hinter dieser Demo

Das sind die Dateien, die diese Seite ausführt, keine vereinfachte Kopie. Sie kompilieren gegen die öffentlichen Exporte von SuperScheduler Pro.

Die Integration: Zoomstufen, Spalten im Zeilenkopf, Optionen für Mehrfachauswahl und Mehrfachverschiebung, React-Render-Slots und die Callbacks, in denen die Regeln eine Verschiebung annehmen oder ablehnen.

scheduler.tsxtsx
/**
 * Studio North resource plan: teams and specialists over a four-week window, with a load column.
 *
 * Library: timeline, team tree with row-header columns, event multiselection, dependency links,
 * undo history, keyboard, zoom, and React content (task bars and hover cards) through
 * `super-scheduler/react-render`.
 * Application: who can take which task, moving a selection together, the sprint load in the
 * second column, and when a dependency is broken. Those rules live in ./rules and run inside the
 * callbacks below.
 */
import { useEffect, useMemo, useState, type RefObject } from 'react'
import { SuperScheduler } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'
import type { SchedulerHistory } from 'super-scheduler/history'
import { SuperSchedulerComponent } from 'super-scheduler/react-render'
import { createHeaderHook } from '../../scheduler/live-headers'
import { toneClass } from '../../scheduler/tones'
import {
  CAMPAIGN_TONES,
  civilOf,
  clientOf,
  isSpecialistId,
  isTaskId,
  readTaskFields,
  specialistName,
  toResources,
  type TaskEvent,
} from './adapter'
import {
  WINDOW,
  type CampaignId,
  type Discipline,
  type SpecialistId,
  type Task,
  type TaskId,
} from './model'
import {
  dependentsOf,
  moveProblem,
  planGroupMove,
  type DependencyIssue,
  type GroupMovePlan,
  type MoveProblem,
  type PlannedMove,
} from './rules'
import { TaskBar, TaskHoverCard } from './task-card'
import type { StudioNorthText, ZoomId } from './text'

export interface StudioNorthSchedulerProps {
  readonly events: TaskEvent[]
  readonly tasks: readonly Task[]
  readonly issues: readonly DependencyIssue[]
  /** Sprint load per specialist (1 = 100 %), computed by the rules. */
  readonly loads: ReadonlyMap<SpecialistId, number>
  readonly text: StudioNorthText
  /** BCP 47 tag for labels, and the scheduler locale id. */
  readonly tag: string
  readonly schedulerLocale: string
  /** Level on mount. Later changes go through control.zoom so the view animates from where it is. */
  readonly initialZoom: ZoomId
  /** Every dependency, or only those of the campaigns in `focus` (the selection). */
  readonly showAllLinks: boolean
  readonly focus: ReadonlySet<CampaignId>
  readonly history: SchedulerHistory
  readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
  readonly dateRange: (start: string, end: string) => string
  readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
  readonly onSelectionChange: (ids: TaskId[]) => void
  readonly onMoved: (id: TaskId) => void
  /** A selection dropped together: the application applies and records it as one change. */
  readonly onGroupMove: (moves: readonly PlannedMove[]) => void
  readonly onResized: (id: TaskId) => void
  readonly onRefused: (message: string) => void
  readonly onZoomLevel: (level: ZoomId) => void
}

const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
  {
    id: 'days',
    properties: {
      scale: 'Day',
      cellWidth: 56,
      timeHeaders: [{ groupBy: 'Week' }, { groupBy: 'Day' }],
    },
  },
  {
    id: 'weeks',
    properties: {
      scale: 'Week',
      cellWidth: 168,
      timeHeaders: [{ groupBy: 'Month' }, { groupBy: 'Week' }],
    },
  },
]

const ZOOM_IDS: readonly ZoomId[] = ['days', 'weeks']

/** Outline of a selected task, in the campaign's tone; a task that starts too early gets a red one. */
const SELECTED_CLASS = 'sn-selected outline-2 outline-offset-2 outline-[var(--tone)]'
const LATE_CLASS = 'outline-2 outline-offset-1 outline-danger'

/** Width of the two row-header columns (specialist, sprint load); narrower on phones. */
const COLUMN_WIDTHS = { regular: [168, 76], compact: [116, 56] } as const

/** Why one task cannot go to `resource` (a team row, the wrong role, a fixed review), or null. */
function singleProblem(
  data: object,
  resource: SuperScheduler.ResourceId,
): { problem: MoveProblem; discipline: Discipline } | null {
  const fields = readTaskFields(data)
  if (fields === null) return null
  if (!isSpecialistId(resource)) return { problem: 'wrong-role', discipline: fields.discipline }
  const problem = moveProblem(fields, resource)
  return problem === null ? null : { problem, discipline: fields.discipline }
}

export function StudioNorthScheduler(props: StudioNorthSchedulerProps) {
  const {
    events,
    tasks,
    issues,
    loads,
    text,
    tag,
    schedulerLocale,
    initialZoom,
    showAllLinks,
    focus,
    history,
    controlRef,
    dateRange,
  } = props
  const {
    onEventsChange,
    onSelectionChange,
    onMoved,
    onGroupMove,
    onResized,
    onRefused,
    onZoomLevel,
  } = props

  const resources = useMemo(() => toResources(text), [text])
  // Header labels follow the active level. An animated zoom renders the new level's headers before
  // control.scale reports it, so the hook is rebuilt (and the headers repainted) once a zoom lands.
  const [headerHook, setHeaderHook] = useState(() => createHeaderHook(tag))
  const late = useMemo(() => new Set<string>(issues.map((issue) => issue.task)), [issues])

  // Columns are part of the configuration: titles from the page text, widths chosen once on mount.
  const [nameWidth, loadWidth] = useMemo(
    () =>
      window.matchMedia('(max-width: 639px)').matches
        ? COLUMN_WIDTHS.compact
        : COLUMN_WIDTHS.regular,
    [],
  )
  const rowHeaderColumns = useMemo<SuperScheduler.RowHeaderColumnData[]>(
    () => [
      { title: text.columns.specialist, width: nameWidth },
      { title: text.columns.load, width: loadWidth },
    ],
    [text, nameWidth, loadWidth],
  )

  // Finish-to-start links come from the business model, not from the scheduler. By default only
  // the selected campaigns show theirs, so a busy board stays readable.
  const links = useMemo<SuperScheduler.LinkData[]>(() => {
    return tasks.flatMap((task) =>
      !showAllLinks && !focus.has(task.campaign)
        ? []
        : task.after.map((before) => ({
            id: `${before}->${task.id}`,
            from: before,
            to: task.id,
            type: 'FinishToStart' as const,
            ...(issues.some((issue) => issue.task === task.id && issue.waitsFor === before)
              ? { cssClass: 'ss-link--late' }
              : {}),
          })),
    )
  }, [tasks, issues, showAllLinks, focus])

  const config = useMemo<SchedulerProps>(() => {
    const refusal = (found: { problem: MoveProblem; discipline: Discipline }) =>
      found.problem === 'fixed' ? text.rules.fixed : text.rules.wrongRole[found.discipline]

    // The selected tasks move together when the dragged one is part of a multiple selection.
    const groupPlan = (
      dragged: SuperScheduler.EventId,
      start: SuperScheduler.Date,
      resource: SuperScheduler.ResourceId,
    ): GroupMovePlan | null => {
      const ids = new Set(controlRef.current?.multiselect.events().map((event) => event.id()))
      if (ids.size < 2 || !ids.has(dragged) || !isTaskId(dragged)) return null
      const selection = tasks.filter((task) => ids.has(task.id))
      return planGroupMove(selection, dragged, civilOf(start), String(resource))
    }

    return {
      locale: schedulerLocale,
      startDate: WINDOW.start,
      days: 28,
      weekStarts: 1,
      zoomLevels: ZOOM_LEVELS,
      zoom: initialZoom,
      zoomPosition: 'left',
      // Weekends stay hidden: the plan counts working days only.
      showNonBusiness: false,
      useEventBoxes: 'Never',
      // Moves keep a task's working days: dropping a Thursday–Friday task on Friday ends it Monday.
      eventMoveSkipNonBusiness: true,
      // Keep full labels and task text at the week level (24 px per day) on this small board.
      lod: { zoomedOut: 20 },
      height: '100%',
      treeEnabled: true,
      treePreventParentUsage: true,
      rowHeaderColumns,
      rowHeaderWidth: nameWidth + loadWidth,
      rowHeaderWidthAutoFit: false,
      eventHeight: 30,
      rowMarginTop: 4,
      rowMarginBottom: 4,
      // People can be double-booked; the load column makes it visible instead of refusing it.
      allowEventOverlap: true,
      // Click selects, Ctrl/Cmd+click adds to the selection.
      eventClickHandling: 'Select',
      allowMultiSelect: true,
      timeRangeSelectedHandling: 'Disabled',
      dragCard: { duration: false },
      durationBarVisible: false,
      showToolTip: false,
      eventHoverHandling: 'Disabled',
      keyboardEnabled: true,
      keyboardTarget: 'component',
      keyboardMode: 'Full',
      history,
      controlRef,
      onEventsChange,

      onBeforeTimeHeaderRender: headerHook,

      onBeforeRowHeaderRender: (args) => {
        const isGroup = args.row.data.children !== undefined
        args.row.cssClass = isGroup ? 'ss-rh--group' : ''
        const id = args.row.id
        const value = isSpecialistId(id) ? loads.get(id) : undefined
        const column = args.row.columns[1]
        if (value === undefined || column === undefined) return
        const percent = Math.round(value * 100)
        const tone = value > 1 ? 'font-semibold text-danger' : 'text-ink-2'
        column.html = `<span class="tabular ${tone}" title="${SuperScheduler.Util.escapeHtml(text.loadTitle.replace('{value}', String(percent)))}">${percent}%</span>`
        column.horizontalAlignment = 'right'
      },

      onBeforeEventRender: (args) => {
        const fields = readTaskFields(args.data)
        const id = args.data.id
        if (fields === null || !isTaskId(id)) return
        // HTML fallback shown until the React bar (renderEvent) mounts.
        const title = SuperScheduler.Util.escapeHtml(text.tasks[id])
        const client = SuperScheduler.Util.escapeHtml(clientOf(fields.campaign))
        args.data.html = `<span class="ss-evt"><span class="ss-evt__title">${title}</span><span class="ss-evt__meta">${client}</span></span>`
        const classes = ['ss-ev', toneClass(CAMPAIGN_TONES[fields.campaign])]
        if (fields.fixed === true) classes.push('ss-ev--hatched')
        // Selected tasks get an outline in their tone; the control re-runs this hook on selection changes.
        const selected = args.control.multiselect.events().some((event) => event.id() === id)
        if (late.has(id))
          classes.push(
            'ss-ev--late',
            selected ? 'sn-selected outline-2 outline-offset-2 outline-danger' : LATE_CLASS,
          )
        else if (selected) classes.push(SELECTED_CLASS)
        args.data.cssClass = classes.join(' ')
      },

      // Role compatibility while dragging, for the dragged task and the selection that follows it.
      onEventMoving: (args) => {
        const plan = groupPlan(args.e.id(), args.start, args.resource)
        const found =
          plan === null ? singleProblem(args.e.data, args.resource) : plan.ok ? null : plan
        if (found !== null) {
          args.allowed = false
          args.message = refusal(found)
        }
      },

      // Final check before the commit (also covers keyboard moves). A selection is moved by the
      // application: the library commit is cancelled and the whole plan becomes one change.
      onEventMove: (args) => {
        const plan = groupPlan(args.e.id(), args.newStart, args.newResource)
        if (plan !== null) {
          args.preventDefault()
          if (plan.ok) onGroupMove(plan.moves)
          else onRefused(refusal(plan))
          return
        }
        const found = singleProblem(args.e.data, args.newResource)
        if (found !== null) {
          args.preventDefault()
          onRefused(refusal(found))
        }
      },

      onEventMoved: (args) => {
        const id = args.e.id()
        if (isTaskId(id)) onMoved(id)
      },

      onEventResized: (args) => {
        const id = args.e.id()
        if (isTaskId(id)) onResized(id)
      },

      // The control owns the selection; React keeps a copy of the ids for the detail panel.
      onEventSelected: () => {
        const selected = controlRef.current?.multiselect.events() ?? []
        onSelectionChange(selected.map((event) => event.id()).filter(isTaskId))
      },

      onZoom: (args) => {
        if (args.phase !== 'end') return
        setHeaderHook(() => createHeaderHook(tag))
        const level = ZOOM_IDS[args.level]
        if (level !== undefined) onZoomLevel(level)
      },
    }
  }, [
    history,
    controlRef,
    late,
    loads,
    tasks,
    rowHeaderColumns,
    nameWidth,
    loadWidth,
    onEventsChange,
    onMoved,
    onGroupMove,
    onRefused,
    onResized,
    onSelectionChange,
    onZoomLevel,
    schedulerLocale,
    headerHook,
    tag,
    text,
    initialZoom,
  ])

  // React task bars: the late and fixed states come from the rules, the rest from the event.
  const renderEvent = useMemo<NonNullable<SchedulerProps['renderEvent']>>(
    () =>
      ({ e, data, width }) => {
        const id = e.id()
        const fields = readTaskFields(data)
        if (!isTaskId(id) || fields === null) return e.text()
        return (
          <TaskBar
            title={text.tasks[id]}
            client={clientOf(fields.campaign)}
            late={late.has(id)}
            fixed={fields.fixed === true}
            compact={width < 200}
          />
        )
      },
    [late, text],
  )

  // Hover card: what the task waits for and what it unblocks, from the model's dependencies.
  const eventHover = useMemo<NonNullable<SchedulerProps['eventHover']>>(
    () => ({
      delay: 300,
      placement: 'auto',
      render: ({ e }) => {
        const task = tasks.find((candidate) => candidate.id === e.id())
        if (task === undefined) return null
        const name = (id: TaskId) =>
          `${clientOf(tasks.find((candidate) => candidate.id === id)?.campaign ?? 'studio')} · ${text.tasks[id]}`
        return (
          <TaskHoverCard
            eyebrow={`${clientOf(task.campaign)} · ${text.campaigns[task.campaign]}`}
            title={text.tasks[task.id]}
            details={[
              { label: text.fields.owner, value: specialistName(task.owner, text) },
              { label: text.fields.discipline, value: text.disciplines[task.discipline] },
              { label: text.fields.dates, value: dateRange(task.start, task.end) },
            ]}
            waitsFor={task.after.map((id) => ({
              id,
              name: name(id),
              late: issues.some((issue) => issue.task === task.id && issue.waitsFor === id),
            }))}
            unblocks={dependentsOf(task.id, tasks).map((next) => ({
              id: next.id,
              name: name(next.id),
              late: issues.some((issue) => issue.task === next.id && issue.waitsFor === task.id),
            }))}
            labels={{
              waitsFor: text.fields.waitsFor,
              unblocks: text.fields.unblocks,
              nothing: text.hover.nothing,
              hint: text.hover.hint,
            }}
          />
        )
      },
    }),
    [tasks, issues, text, dateRange],
  )

  // Imperative and once: a scroll prop would be re-applied on every update.
  useEffect(() => {
    controlRef.current?.scrollTo(WINDOW.start, false, 'left')
  }, [controlRef])

  return (
    <SuperSchedulerComponent
      {...config}
      resources={resources}
      events={events}
      links={links}
      renderEvent={renderEvent}
      eventHover={eventHover}
    />
  )
}
Quellcode des Beispiels herunterladen (.zip)Das Archiv enthält die Dateien dieses Beispiels und eine README. SuperScheduler Pro und Download-Schlüssel sind nicht enthalten.

Dieses Szenario teilen

Der Link öffnet dieses Beispiel mit der aktuellen Ansicht (Zeitskala und Optionen). Er enthält nie Ihre Änderungen oder persönliche Daten.

Eine andere Branche, ein verwandtes ProblemPlanung einer VideoproduktionEin Dreh dauert länger. Verschieben Sie den abhängigen Schnitt, verstehen Sie warum, und nehmen Sie es zurück.Zum AnwendungsfallKreativproduktion