---
title: "Cockpit de proyectos"
slug: "react-projects-app"
category: "Examples"
description: "Tareas, progreso y fechas en un solo lugar."
hosts: "react"
locale: "es"
reactExample: "app-projects.tsx"
reactExampleKind: "app"
order: 18
---

# Cockpit de proyectos

Tareas, progreso y fechas en un solo lugar. Esta demo completa conecta varios widgets reales con un mismo conjunto de datos.

## Vistas y flujos de trabajo

**Resumen · Kanban · Tareas · Calendario**

- 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.
- Arrastra una tarjeta entre las columnas. El estado, la fila de la tabla, el panel de detalle y los indicadores usan a partir de entonces el mismo registro actualizado.

## 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: 'projects',
  title: 'Projekt-Cockpit',
  description: 'Aufgaben, Fortschritt und Termine an einem Ort.',
  recordLabel: 'Aufgabe',
  countLabel: 'Aufgaben',
  searchLabel: 'Aufgaben oder Projekte suchen …',
  stages: ['Geplant', 'In Arbeit', 'Erledigt'],
  views: [
    {
      id: 'overview',
      title: 'Übersicht',
    },
    {
      id: 'board',
      title: 'Kanban',
    },
    {
      id: 'table',
      title: 'Aufgaben',
    },
    {
      id: 'calendar',
      title: 'Kalender',
    },
  ],
};
type Item = {
  id: string;
  title: string;
  account: string;
  owner: string;
  stage: number;
  value: number;
  progress: number;
  day: number;
  hour: number;
};
const initial: Item[] = [
  {
    id: 'P-101',
    title: 'Anforderungen abstimmen',
    account: 'Kundenportal',
    owner: 'Konzeption',
    stage: 1,
    value: 3200,
    progress: 60,
    day: 0,
    hour: 9,
  },
  {
    id: 'P-102',
    title: 'Dashboard gestalten',
    account: 'Kundenportal',
    owner: 'Design',
    stage: 1,
    value: 4800,
    progress: 35,
    day: 1,
    hour: 10,
  },
  {
    id: 'P-103',
    title: 'Datenmodell aufsetzen',
    account: 'Kundenportal',
    owner: 'Entwicklung',
    stage: 0,
    value: 6000,
    progress: 10,
    day: 2,
    hour: 11,
  },
  {
    id: 'P-104',
    title: 'Angebote überarbeiten',
    account: 'Auftragsmanagement',
    owner: 'Konzeption',
    stage: 2,
    value: 1800,
    progress: 100,
    day: 0,
    hour: 14,
  },
  {
    id: 'P-105',
    title: 'Kalender integrieren',
    account: 'Service-App',
    owner: 'Entwicklung',
    stage: 1,
    value: 3600,
    progress: 70,
    day: 3,
    hour: 10,
  },
  {
    id: 'P-106',
    title: 'Nutzertest vorbereiten',
    account: 'Service-App',
    owner: 'Design',
    stage: 0,
    value: 1600,
    progress: 0,
    day: 4,
    hour: 14,
  },
  {
    id: 'P-107',
    title: 'Freigabe einholen',
    account: 'Auftragsmanagement',
    owner: 'Konzeption',
    stage: 0,
    value: 900,
    progress: 20,
    day: 2,
    hour: 15,
  },
  {
    id: 'P-108',
    title: 'Dokumentation erstellen',
    account: 'Kundenportal',
    owner: 'Entwicklung',
    stage: 2,
    value: 1400,
    progress: 100,
    day: 1,
    hour: 14,
  },
];
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.
