Aller au contenu
SuperScheduler

Agence créativeStudio North (Entreprise fictive)

Planification des ressources d’une agence créative

Une graphiste est surréservée. Confiez deux tâches à un collègue d’un seul glisser, vérifiez ce qu’elles débloquent, puis annulez.

Échelle de temps
Démo avec données fictives
jours / semaines
Fonctionnalités Pro
DépendancesMultisélectionColonnes de ligneAnnuler et rétablirSlots de rendu React
Studio North
StratégieInes Varga70%DesignMira Okafor120%Jonas Brandt20%Leo Marchetti30%RédactionSaskia Moreau60%Theo Lindqvist40%ProductionAda Kowalczyk0%ClientRevues clientAudit de marqueInsights acheteursAtelier commissairesFinalisation du logoVisuels clésDesign du packagingAccrochesTextes d’expositionRevue clientRevue clientRevue clientKit de lancementProduction impriméeScripts du filmTextes réseaux sociauxFilm de lancementTeaser animéSérie d’affichesMise à jour du portfolio19 oct.26 oct.2 nov.9 nov.lun. 19mar. 20mer. 21jeu. 22ven. 23lun. 26mar. 27mer. 28jeu. 29ven. 30lun. 2mar. 3mer. 4jeu. 5ven. 6lun. 9mar. 10mer. 11jeu. 12ven. 13

SuperScheduler Pro n’est chargé que lorsque vous le demandez.

La situation

Studio North est une agence fictive où stratèges, graphistes, rédacteurs et une productrice travaillent en même temps sur trois campagnes clients. Chaque campagne est une chaîne de tâches : pas de visuels clés avant que le logo soit finalisé, pas de kit de lancement avant la validation de la revue par le client.

Dans un tableur, les problèmes apparaissent trop tard : une graphiste réservée sur deux campagnes à la fois, une revue qui ne peut pas bouger, une tâche confiée à quelqu’un qui ne peut pas la faire. Le planning doit montrer la charge de chacun, garder les dépendances visibles quand le travail change de mains et rendre un rééquilibrage facile à annuler.

Ce que vous allez faireVous sélectionnerez deux tâches, les confierez à un autre graphiste d’un seul glisser, verrez la colonne de charge se rééquilibrer pendant que les liens suivent, puis annulerez tout le déplacement en une étape.

Où s’arrête la bibliothèque et où commence votre application

SuperScheduler fournit

  • Arborescence d’équipes avec des colonnes d’en-tête de ligne (personne et charge) et leurs titres.
  • Sélection par clic et Ctrl/⌘+clic, API multiselect et déplacement groupé entre lignes (multiMoveVerticalMode: All).
  • Liens fin-début courbes qui suivent les tâches d’une ligne à l’autre, mis à jour via la prop links.
  • Historique annuler/rétablir où un déplacement groupé forme une seule entrée, avec des événements contrôlés via onEventsChange.
  • Barres de tâche React (renderEvent) et cartes de survol (eventHover) de super-scheduler/react-render ; zoom par jours et par semaines, week-ends masqués.

Le code de cet exemple décide

  • Quel rôle peut prendre quelle discipline, et le message de refus dans le cas contraire.
  • La charge du sprint par personne (jours ouvrés réservés sur dix), écrite dans la colonne de charge.
  • Les dépendances entre tâches, le moment où l’une est rompue, les liens dessinés (ceux des campagnes sélectionnées ou tous) et ce que liste la carte de survol.
  • Les revues fixées avec le client, le résumé de sélection, la mission et tous les textes de cette page.

Le code de cette démo

Ce sont les fichiers exécutés par cette page, pas une copie simplifiée. Ils compilent avec les exports publics de SuperScheduler Pro.

L’intégration : niveaux de zoom, colonnes d’en-tête de ligne, options de sélection et de déplacement groupé, slots de rendu React, et les callbacks où les règles acceptent ou refusent un déplacement.

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}
    />
  )
}
Télécharger le code de l’exemple (.zip)L’archive contient les fichiers de cet exemple et un README. Elle n’inclut ni SuperScheduler Pro ni aucune clé de téléchargement.

Partager ce scénario

Le lien ouvre cet exemple avec la vue actuelle (échelle et options). Il ne contient jamais vos modifications ni de données personnelles.

Un autre métier, un problème voisinPlanification de production audiovisuelleUn tournage déborde. Déplacez le montage qui en dépendait, comprenez pourquoi, puis revenez en arrière.Lire le cas métierProduction créative