---
title: "Planificación de recursos"
slug: "react-resources-app"
category: "Examples"
description: "Planificar juntos equipos, misiones y disponibilidad."
hosts: "react"
locale: "es"
reactExample: "app-resources.tsx"
reactExampleKind: "app"
order: 19
---

# Planificación de recursos

Planificar juntos equipos, misiones y disponibilidad. Esta demo completa conecta varios widgets reales con un mismo conjunto de datos.

## Vistas y flujos de trabajo

**Resumen · Recursos · Plan semanal · Lista de misiones**

- La búsqueda y el filtro de equipo se aplican a todas las vistas. Un filtro vacío muestra el estado vacío real de la tabla.
- Haz clic en una fila, una tarjeta o una cita del calendario y edita el registro en el panel de detalle.
- Navega semana a semana, adelante y atrás, en las vistas de calendario.
- Cambia el estado, el progreso, el equipo o la cita. Después, el cambio se mantiene visible al cambiar de vista.
- En la vista de recursos, selecciona un equipo o una barra. Asigna la misión a otro equipo en el panel de detalle y comprueba su nueva fila.

## App en vivo y código completo

Con **Abrir grande** la app está disponible a ancho completo. **Código** muestra exactamente el componente ejecutado. Puedes incorporarlo directamente en una app de navegador tras la [instalación](/docs/react/intro/setup-react).

```tsx
import { useMemo, useState } from 'react';
import {
  ArcWidgetBadge,
  ArcWidgetButton,
  ArcWidgetCalendarTimeline,
  ArcWidgetCalendarWeek,
  ArcWidgetInput,
  ArcWidgetKanban,
  ArcWidgetKpiBar,
  ArcWidgetProgressBar,
  ArcWidgetSelect,
  ArcWidgetSlider,
  ArcWidgetTable,
} from '@arcrider/arcwidgets-react';

// Vollständige Browser-Demo. Alle Ansichten verwenden denselben Datenbestand.
const config = {
  kind: 'resources',
  title: 'Ressourcenplanung',
  description: 'Teams, Einsätze und Verfügbarkeit gemeinsam planen.',
  recordLabel: 'Einsatz',
  countLabel: 'Einsätze',
  searchLabel: 'Einsätze oder Kunden suchen …',
  stages: ['Geplant', 'Unterwegs', 'Abgeschlossen'],
  views: [
    {
      id: 'overview',
      title: 'Übersicht',
    },
    {
      id: 'timeline',
      title: 'Ressourcen',
    },
    {
      id: 'calendar',
      title: 'Wochenplan',
    },
    {
      id: 'table',
      title: 'Einsatzliste',
    },
  ],
};
type Item = {
  id: string;
  title: string;
  account: string;
  owner: string;
  stage: number;
  value: number;
  progress: number;
  day: number;
  hour: number;
};
const initial: Item[] = [
  {
    id: 'E-201',
    title: 'Installation Standort Nord',
    account: 'Kunde Nord',
    owner: 'Team A',
    stage: 1,
    value: 2400,
    progress: 60,
    day: 0,
    hour: 9,
  },
  {
    id: 'E-202',
    title: 'Wartung Gebäudetechnik',
    account: 'Kunde West',
    owner: 'Team B',
    stage: 0,
    value: 1600,
    progress: 0,
    day: 1,
    hour: 10,
  },
  {
    id: 'E-203',
    title: 'Abnahme Standort Süd',
    account: 'Kunde Süd',
    owner: 'Team C',
    stage: 2,
    value: 900,
    progress: 100,
    day: 0,
    hour: 14,
  },
  {
    id: 'E-204',
    title: 'Anlage in Betrieb nehmen',
    account: 'Kunde Ost',
    owner: 'Team A',
    stage: 0,
    value: 3200,
    progress: 15,
    day: 3,
    hour: 9,
  },
  {
    id: 'E-205',
    title: 'Sensoren kalibrieren',
    account: 'Kunde Nord',
    owner: 'Team B',
    stage: 1,
    value: 1200,
    progress: 50,
    day: 2,
    hour: 13,
  },
  {
    id: 'E-206',
    title: 'Service und Prüfung',
    account: 'Kunde West',
    owner: 'Team C',
    stage: 0,
    value: 1800,
    progress: 0,
    day: 4,
    hour: 10,
  },
  {
    id: 'E-207',
    title: 'Einweisung durchführen',
    account: 'Kunde Süd',
    owner: 'Team A',
    stage: 0,
    value: 600,
    progress: 10,
    day: 1,
    hour: 14,
  },
  {
    id: 'E-208',
    title: 'Dokumentation übergeben',
    account: 'Kunde Ost',
    owner: 'Team C',
    stage: 2,
    value: 400,
    progress: 100,
    day: 2,
    hour: 9,
  },
];
const dayDate = (offset: number) => new Date(2026, 8, 14 + offset).getTime();
const money = (value: number) =>
  value.toLocaleString('de-DE', {
    style: 'currency',
    currency: 'EUR',
    maximumFractionDigits: 0,
  });
const tones = ['#a16207', '#52525b', '#15803d'];
const pill = (stage: number) => (
  <ArcWidgetBadge
    data={{
      value: config.stages[stage],
      singleColor: tones[stage],
      borderRadius: '5px',
      paddingX: '8px',
      paddingY: '4px',
    }}
  />
);

export default function Example() {
  const [items, setItems] = useState(initial);
  const [view, setView] = useState(config.views[0].id);
  const [period, setPeriod] = useState(0);
  const [query, setQuery] = useState('');
  const [owner, setOwner] = useState<string | number | null>(null);
  const [stage, setStage] = useState<number | null>(null);
  const [selectedId, setSelectedId] = useState(initial[0].id);
  const [message, setMessage] = useState(
    'Wähle einen Eintrag aus, um ihn zu bearbeiten.',
  );
  const owners = [...new Set(items.map((item) => item.owner))];
  const filtered = useMemo(
    () =>
      items.filter(
        (item) =>
          (!query ||
            `${item.title} ${item.account} ${item.owner}`
              .toLowerCase()
              .includes(query.toLowerCase())) &&
          (owner == null || item.owner === owner) &&
          (stage == null || item.stage === stage),
      ),
    [items, query, owner, stage],
  );
  const selected =
    filtered.find((item) => item.id === selectedId) ?? filtered[0];
  const completed = filtered.filter((item) => item.stage === 2).length;
  const total = filtered.reduce((sum, item) => sum + item.value, 0);
  const progress = filtered.length
    ? Math.round(
        filtered.reduce((sum, item) => sum + item.progress, 0) /
          filtered.length,
      )
    : 0;
  const update = (patch: Partial<Item>) => {
    if (!selected) return;
    setItems((previous) =>
      previous.map((item) =>
        item.id === selected.id ? { ...item, ...patch } : item,
      ),
    );
    setMessage(
      `${selected.title} aktualisiert. Die Änderung ist in allen Ansichten sichtbar.`,
    );
  };
  const lanes = config.stages.map((title, index) => ({
    id: `stage-${index}`,
    title,
    minWidth: '210px',
    items: filtered
      .filter((item) => item.stage === index)
      .map((item) => ({
        id: item.id,
        title: item.title,
        subtitle: item.account,
        value: (
          <ArcWidgetBadge
            data={{
              value: item.owner,
              backgroundColor: '#f4f4f5',
              fontColor: '#52525b',
            }}
          />
        ),
        clickable: true,
        draggable: true,
      })),
  }));
  const resourceRows = owners.map((team) => ({
    id: team,
    columns: [
      {
        title: team,
        subtitle: `${filtered.filter((item) => item.owner === team).length} Einsätze`,
        clickable: true,
      },
    ],
    timeEntries: filtered
      .filter((item) => item.owner === team)
      .map((item) => ({
        id: item.id,
        title: item.title,
        dateFrom: dayDate(item.day),
        dateTo: dayDate(item.day + 1),
        clickable: true,
        draggable: false,
        customLayout: (
          <div style={{ padding: '3px 6px' }}>
            <strong>{item.title}</strong>
            <div style={{ fontSize: 11 }}>{item.account}</div>
          </div>
        ),
      })),
  }));
  const timeEntries = filtered.map((item) => ({
    id: item.id,
    title: item.title,
    dateFrom: dayDate(item.day),
    dateTo: dayDate(item.day),
    timeFrom: item.hour * 3600000,
    timeTo: (item.hour + 1) * 3600000,
    clickable: true,
    draggable: false,
    customLayout: (
      <div>
        <strong>{item.title}</strong>
        <div>{item.owner}</div>
      </div>
    ),
  }));
  const table = (
    <ArcWidgetTable
      id="app-records"
      data={{
        height: view === 'overview' ? '330px' : '460px',
        header: {
          columns: [
            {
              title: config.recordLabel,
              width: 'fraction',
              sort: { enabled: true, type: 'text' },
            },
            { title: 'Team', width: '105px' },
            { title: 'Status', width: '125px' },
            {
              title: config.kind === 'sales' ? 'Wert' : 'Fortschritt',
              width: '110px',
              sort: { enabled: true, type: 'number' },
            },
          ],
        },
        table: filtered.map((item) => ({
          id: item.id,
          clickable: true,
          rowColor: selected?.id === item.id ? '#f4f4f5' : '#ffffff',
          columns: [
            {
              value: (
                <div>
                  <strong>{item.title}</strong>
                  <div style={{ fontSize: 11, color: '#71717a' }}>
                    {item.account}
                  </div>
                </div>
              ),
              width: 'fraction',
              sort: { value: item.title },
            },
            { value: item.owner, width: '105px' },
            { value: pill(item.stage), width: '125px' },
            {
              value:
                config.kind === 'sales' ? (
                  money(item.value)
                ) : (
                  <ArcWidgetProgressBar
                    data={{
                      valueProgress: item.progress,
                      valueTotal: 100,
                      valueText: `${item.progress}%`,
                      width: '90px',
                      progressColor: '#18181b',
                    }}
                  />
                ),
              width: '110px',
              sort: {
                value: config.kind === 'sales' ? item.value : item.progress,
              },
            },
          ],
        })),
        emptyTable: {
          title: 'Keine passenden Einträge',
          value: (
            <div style={{ padding: 24, textAlign: 'center' }}>
              <strong>{'Keine passenden Einträge'}</strong>
              <p>{'Setze die Suche oder den Team-Filter zurück.'}</p>
            </div>
          ),
        },
      }}
      onCellClick={(_event, payload) => {
        if (payload.row_index != null && filtered[payload.row_index])
          setSelectedId(filtered[payload.row_index].id);
      }}
    />
  );

  return (
    <div className="demo-app">
      <style>{`
      .demo-app { --line:#e4e4e7; --muted:#71717a; background:#fafafa; border:1px solid var(--line); border-radius:10px; overflow:hidden; color:#18181b; }
      .demo-app * { box-sizing:border-box; }
      .demo-app .app-head { padding:22px 24px 0; background:#fff; }
      .demo-app .app-kicker { font-size:10px; letter-spacing:1.4px; text-transform:uppercase; color:var(--muted); margin-bottom:7px; }
      .demo-app .app-title { display:flex; align-items:center; justify-content:space-between; gap:12px; }
      .demo-app h1 { font-size:24px; letter-spacing:-.7px; margin:0; }
      .demo-app .subtitle { color:var(--muted); margin:8px 0 18px; font-size:12px; }
      .demo-app nav { display:flex; gap:20px; overflow-x:auto; }
      .demo-app nav button { border:0; background:none; color:var(--muted); padding:12px 0; font:inherit; white-space:nowrap; cursor:pointer; border-bottom:2px solid transparent; }
      .demo-app nav button[aria-pressed=true] { color:#18181b; border-bottom-color:#18181b; font-weight:600; }
      .demo-app .filters { display:flex; flex-wrap:wrap; align-items:center; gap:12px; padding:16px 24px; border-top:1px solid var(--line); border-bottom:1px solid var(--line); background:#fff; }
      .demo-app .search { flex:1 1 180px; min-width:120px; }
      .demo-app .filter-select { flex:0 1 180px; width:180px; }
      .demo-app .filter-reset { font:inherit; font-size:11px; border:0; background:none; text-decoration:underline; cursor:pointer; color:var(--muted); }
      .demo-app .app-body { display:grid; grid-template-columns:minmax(0,1fr) 250px; gap:20px; padding:20px; }
      .demo-app .main-view { min-width:0; }
      .demo-app .surface { background:#fff; border:1px solid var(--line); border-radius:8px; overflow:hidden; }
      .demo-app .surface-head { display:flex; justify-content:space-between; align-items:center; padding:14px 16px; border-bottom:1px solid var(--line); }
      .demo-app .surface-head h2 { font-size:13px; margin:0; }
      .demo-app .surface-head span { color:var(--muted); font-size:11px; }
      .demo-app .widget-scroll { overflow:auto; }
      .demo-app .widget-wide { min-width:570px; padding:10px; }
      .demo-app .metrics { margin-bottom:16px; overflow:auto; }
      .demo-app .metrics > div { min-width:460px; }
      .demo-app .detail { padding:18px; align-self:start; }
      .demo-app .detail h2 { font-size:17px; line-height:1.4; margin:14px 0 6px; }
      .demo-app .detail label { display:block; font-size:11px; color:var(--muted); margin:20px 0 7px; }
      .demo-app .detail small { color:var(--muted); font-size:11px; }
      .demo-app .detail-actions { display:grid; gap:8px; margin-top:20px; }
      .demo-app .app-footer { padding:12px 24px; border-top:1px solid var(--line); font-size:11px; color:var(--muted); background:#fff; }
      .demo-app .view-tools {display:flex;align-items:center;gap:12px;padding:10px 16px;border-bottom:1px solid var(--line);font-size:11px;} .demo-app .view-tools span {margin:auto;text-align:center;} .demo-app .view-tools button {font:inherit;cursor:pointer;background:#fafafa;border:1px solid var(--line);border-radius:5px;padding:6px 8px;}
      .demo-app .app-date { font-size:11px; white-space:nowrap; color:var(--muted); }
      .demo-app button:focus-visible { outline:2px solid #a1a1aa; outline-offset:3px; }
      @media(max-width:1000px) { .demo-app .app-body { grid-template-columns:minmax(0,1fr); } .demo-app .detail { display:grid; grid-template-columns:1fr 1fr; gap:0 24px; } .demo-app .detail-actions { grid-column:1/-1; display:flex; } }
      @media(max-width:560px) { .demo-app .app-head {padding:18px 14px 0} .demo-app h1 {font-size:20px} .demo-app .view-tools {display:flex;align-items:center;gap:12px;padding:10px 16px;border-bottom:1px solid var(--line);font-size:11px;} .demo-app .view-tools span {margin:auto;text-align:center;} .demo-app .view-tools button {font:inherit;cursor:pointer;background:#fafafa;border:1px solid var(--line);border-radius:5px;padding:6px 8px;}
      .demo-app .app-date {display:none} .demo-app .filters {padding:12px 14px} .demo-app .app-body {padding:12px;gap:12px} .demo-app .detail {display:block} .demo-app nav {gap:16px} .demo-app .detail-actions {display:grid} }
    `}</style>
      <header className="app-head">
        <div className="app-kicker">Arc Rider · Example App</div>
        <div className="app-title">
          <h1>{config.title}</h1>
          <span className="app-date">Interaktive Demo</span>
        </div>
        <p className="subtitle">{config.description}</p>
        <nav aria-label="Ansichten">
          {config.views.map((tab) => (
            <button
              key={tab.id}
              aria-pressed={view === tab.id}
              onClick={() => setView(tab.id)}
            >
              {tab.title}
            </button>
          ))}
        </nav>
      </header>
      <div className="filters">
        <div className="search">
          <ArcWidgetInput
            id="app-search"
            data={{
              value: query,
              type: 'text',
              width: '100%',
              placeholderSettings: { value: config.searchLabel },
              liveUpdate: { debounce: 150 },
              reset: { visible: true },
            }}
            onChange={(value) => setQuery(String(value ?? ''))}
          />
        </div>
        <div className="filter-select">
          <ArcWidgetSelect
            id="app-owner"
            data={{
              width: '100%',
              currentValue: owner ?? 'Alle Teams',
              items: owners.map((value) => ({
                value,
                title: value,
                active: owner === value,
              })),
              reset: { title: 'Alle Teams' },
            }}
            onChange={setOwner}
          />
        </div>
        <button
          className="filter-reset"
          onClick={() => {
            setQuery('');
            setOwner(null);
            setStage(null);
          }}
        >
          Filter zurücksetzen{stage == null ? '' : ` · ${config.stages[stage]}`}
        </button>
      </div>
      <div className="app-body">
        <section className="main-view">
          {view === 'overview' && (
            <div className="metrics">
              <ArcWidgetKpiBar
                data={{
                  settings: {
                    height: '120px',
                    colorValue: '#18181b',
                    colorLabel: '#71717a',
                    fontSizeValue: '26px',
                    backgroundColor: '#fff',
                    borderColor: '#e4e4e7',
                  },
                  cards: [
                    {
                      card: 1,
                      title: config.countLabel,
                      value: filtered.length,
                      clickable: true,
                    },
                    {
                      card: 2,
                      title:
                        config.kind === 'sales'
                          ? 'Pipeline-Wert'
                          : 'Durchschnitt',
                      value:
                        config.kind === 'sales' ? money(total) : `${progress}%`,
                    },
                    {
                      card: 3,
                      title: config.stages[2],
                      value: completed,
                      clickable: true,
                    },
                  ],
                }}
                onClick={(_event, index) => setStage(index === 2 ? 2 : null)}
              />
            </div>
          )}
          <div className="surface">
            <div className="surface-head">
              <h2>{config.views.find((tab) => tab.id === view)?.title}</h2>
              <span>{filtered.length} Einträge</span>
            </div>
            {(view === 'calendar' || view === 'timeline') && (
              <div className="view-tools">
                <button
                  aria-label="Vorherige Woche"
                  onClick={() => setPeriod((n) => n - 1)}
                >
                  ←
                </button>
                <span>
                  {new Date(dayDate(period * 7)).toLocaleDateString('de-DE')} –{' '}
                  {new Date(
                    dayDate(period * 7 + (view === 'timeline' ? 13 : 6)),
                  ).toLocaleDateString('de-DE')}
                </span>
                <button
                  aria-label="Nächste Woche"
                  onClick={() => setPeriod((n) => n + 1)}
                >
                  →
                </button>
              </div>
            )}
            {(view === 'overview' || view === 'table') && (
              <div className="widget-scroll">
                <div className="widget-wide">{table}</div>
              </div>
            )}
            {view === 'board' && (
              <div className="widget-scroll">
                <div className="widget-wide">
                  <ArcWidgetKanban
                    id="app-board"
                    data={{
                      height: '430px',
                      empty: { enabled: true, value: 'Keine Einträge' },
                      swimlanes: lanes,
                    }}
                    onItemClick={(_event, p) => {
                      const item =
                        lanes[p.swimlane_index]?.items[p.item_index_in_section];
                      if (item) setSelectedId(String(item.id));
                    }}
                    onItemDrop={async (p) => {
                      const source =
                        lanes[p.source_swimlane_index]?.items[
                          p.item_index_in_section
                        ];
                      if (!source) return;
                      const target = lanes[
                        p.target_swimlane_index
                      ].items.filter((item) => item.id !== source.id);
                      const before = target[p.target_index_in_section]?.id;
                      setItems((previous) => {
                        const item = previous.find(
                          (item) => item.id === source.id,
                        );
                        if (!item) return previous;
                        const next = previous.filter(
                          (item) => item.id !== source.id,
                        );
                        const position = before
                          ? next.findIndex((item) => item.id === before)
                          : next.length;
                        next.splice(position < 0 ? next.length : position, 0, {
                          ...item,
                          stage: p.target_swimlane_index,
                          progress:
                            p.target_swimlane_index === 2
                              ? 100
                              : item.stage === 2
                                ? 0
                                : item.progress,
                        });
                        return next;
                      });
                      setSelectedId(String(source.id));
                      setMessage(
                        `${source.title} nach ${config.stages[p.target_swimlane_index]} verschoben.`,
                      );
                    }}
                  />
                </div>
              </div>
            )}
            {view === 'timeline' && (
              <div className="widget-scroll">
                <div className="widget-wide">
                  <ArcWidgetCalendarTimeline
                    id="app-resources"
                    data={{
                      height: '460px',
                      timeZoneBalance: 0,
                      lang: 'de',
                      dateSettings: {
                        startDate: dayDate(period * 7),
                        duration: 14,
                      },
                      columns: [{ title: 'Ressource', width: '150px' }],
                      zoomSettings: {
                        enabled: true,
                        persist: false,
                        defaultDayWidth: '60px',
                      },
                      items: resourceRows,
                    }}
                    onEntryClick={(row, index) => {
                      const item = resourceRows[row]?.timeEntries[index];
                      if (item) setSelectedId(String(item.id));
                    }}
                    onLabelClick={(row) => {
                      setOwner(owners[row]);
                      setMessage(`${owners[row]} ausgewählt.`);
                    }}
                  />
                </div>
              </div>
            )}
            {view === 'calendar' && (
              <div className="widget-scroll">
                <div className="widget-wide">
                  <ArcWidgetCalendarWeek
                    id="app-calendar"
                    data={{
                      height: '490px',
                      timeZoneBalance: 0,
                      lang: 'de',
                      dateSettings: {
                        startDate: dayDate(period * 7),
                        duration: 7,
                      },
                      timeSettings: {
                        timeStart: 8 * 3600000,
                        duration: 10 * 3600000,
                      },
                      timeEntries,
                    }}
                    onEntryClick={(index) => {
                      if (filtered[index]) setSelectedId(filtered[index].id);
                    }}
                  />
                </div>
              </div>
            )}
          </div>
        </section>
        <aside className="surface detail" aria-label="Details">
          {selected ? (
            <>
              <div>
                <small>
                  {config.recordLabel} · {selected.id}
                </small>
                <h2>{selected.title}</h2>
                <small>{selected.account}</small>
                <div style={{ marginTop: 12 }}>{pill(selected.stage)}</div>
                <label>Verantwortliches Team</label>
                <ArcWidgetSelect
                  id="detail-owner"
                  data={{
                    width: '100%',
                    currentValue: selected.owner,
                    items: owners.map((value) => ({
                      value,
                      title: value,
                      active: value === selected.owner,
                    })),
                    reset: { hide: true },
                  }}
                  onChange={(value) => {
                    if (value != null) update({ owner: String(value) });
                  }}
                />
              </div>
              <div>
                <label>
                  {config.kind === 'sales'
                    ? 'Abschlusswahrscheinlichkeit'
                    : 'Fortschritt'}{' '}
                  · {selected.progress}%
                </label>
                <ArcWidgetSlider
                  id="detail-progress"
                  data={{
                    value: selected.progress,
                    track: { backgroundColor: '#e4e4e7' },
                    fill: { backgroundColor: '#18181b' },
                    thumb: { borderColor: '#18181b' },
                    min: 0,
                    max: 100,
                    step: 5,
                    width: '100%',
                  }}
                  onChange={(progress) =>
                    update({
                      progress,
                      stage: progress === 100 ? 2 : selected.stage,
                    })
                  }
                />
                <label>
                  {config.kind === 'sales'
                    ? 'Opportunity-Wert'
                    : 'Budget / Auftragswert'}
                </label>
                <strong>{money(selected.value)}</strong>
                <label>Termin</label>
                <strong>
                  {new Date(dayDate(selected.day)).toLocaleDateString('de-DE', {
                    day: '2-digit',
                    month: '2-digit',
                  })}{' '}
                  · {selected.hour}:00
                </strong>
              </div>
              <div className="detail-actions">
                <ArcWidgetButton
                  id="detail-stage"
                  data={{
                    title: `Weiter: ${config.stages[(selected.stage + 1) % 3]}`,
                    width: '100%',
                    backgroundColor: '#18181b',
                    fontColor: '#fff',
                  }}
                  onClick={() =>
                    update({
                      stage: (selected.stage + 1) % 3,
                      progress:
                        selected.stage === 1
                          ? 100
                          : selected.stage === 2
                            ? 0
                            : selected.progress,
                    })
                  }
                />
                <ArcWidgetButton
                  id="detail-reschedule"
                  data={{
                    title: 'Termin einen Tag später',
                    width: '100%',
                    backgroundColor: '#f4f4f5',
                    fontColor: '#18181b',
                  }}
                  onClick={() => update({ day: selected.day + 1 })}
                />
              </div>
            </>
          ) : (
            <p>Keine Einträge für diesen Filter.</p>
          )}
        </aside>
      </div>
      <footer className="app-footer" role="status">
        {message} · Änderungen bleiben in dieser Demo.
      </footer>
    </div>
  );
}
```

## Interacción entre los widgets

La app mantiene los registros en un state de React. `useMemo` deriva la lista filtrada; la tabla, el tablero y el calendario obtienen de ahí su configuración respectiva. Los callbacks seleccionan IDs estables o modifican el state compartido. Los widgets se encargan de la presentación y la interacción; la aplicación es la propietaria de los datos de negocio.

## Límites de la demo

Los datos son ficticios y permanecen en la memoria del navegador del componente en ejecución. No hay servidor, ni inicio de sesión, ni almacenamiento real. Las citas del calendario se modifican mediante el panel de detalle; los callbacks de drag actuales todavía no entregan horas de destino completas. Tras recargar, la app vuelve a empezar con los datos iniciales.
