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
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.
/**
* 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}
/>
)
}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.