Agencia creativaStudio North (Negocio ficticio)
Planificación de recursos en una agencia creativa
Una diseñadora tiene doble reserva. Pasa dos tareas a un compañero con un solo arrastre, revisa qué desbloquean y deshazlo.
- Escala temporal
- Demo con datos sintéticos
- días / semanas
- Capacidades Pro
- DependenciasMultiselecciónColumnas de filaDeshacer y rehacerSlots de renderizado React
Carga SuperScheduler Pro solo cuando lo pides.
La situación
Studio North es una agencia ficticia en la que estrategas, diseñadores, redactores y una productora trabajan a la vez en tres campañas de clientes. Cada campaña es una cadena de tareas: no hay visuales clave antes de cerrar el logotipo, ni kit de lanzamiento antes de que el cliente apruebe la revisión.
En una hoja de cálculo los problemas aparecen tarde: una diseñadora asignada a dos campañas al mismo tiempo, una revisión que no se puede mover, una tarea en manos de quien no puede hacerla. El tablero tiene que mostrar la carga de cada persona, mantener visibles las dependencias cuando el trabajo cambia de manos y permitir deshacer un reparto con facilidad.
Qué vas a hacerSeleccionarás dos tareas, se las pasarás a otro diseñador con un solo arrastre, verás cómo se reequilibra la columna de carga mientras los vínculos las siguen y desharás todo el movimiento en un paso.
Dónde termina la librería y empieza tu aplicación
SuperScheduler aporta
- Árbol de equipos con columnas en la cabecera de fila (persona y carga) y sus títulos.
- Selección con clic y Ctrl/⌘+clic, la API multiselect y el movimiento múltiple entre filas (multiMoveVerticalMode: All).
- Vínculos fin-inicio curvos que siguen a las tareas de una fila a otra, actualizados mediante la prop links.
- Historial de deshacer y rehacer en el que un movimiento múltiple es una sola entrada, con eventos controlados mediante onEventsChange.
- Barras de tarea en React (renderEvent) y tarjetas emergentes (eventHover) de super-scheduler/react-render; zoom por días y semanas con los fines de semana ocultos.
El código de este ejemplo decide
- Qué rol puede asumir cada disciplina y el mensaje de rechazo cuando no puede.
- La carga del sprint por persona (días laborables reservados sobre diez), escrita en la columna de carga.
- Las dependencias entre tareas, cuándo se rompe una, qué vínculos se dibujan (los de las campañas seleccionadas o todos) y qué muestra la tarjeta emergente.
- Las revisiones fijadas con el cliente, el resumen de la selección, la misión y todos los textos de esta página.
El código de esta demo
Son los archivos que ejecuta esta página, no una copia simplificada. Compilan contra los exports públicos de SuperScheduler Pro.
La integración: niveles de zoom, columnas de la cabecera de fila, opciones de selección y movimiento múltiple, slots de renderizado React y los callbacks donde las reglas aceptan o rechazan un movimiento.
/**
* 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}
/>
)
}Comparte este escenario
El enlace abre este ejemplo con la vista actual (escala y opciones). Nunca contiene tus cambios ni datos personales.