Examples
Cockpit de ventas
Cockpit de ventas
Acompañar oportunidades desde el primer contacto hasta el cierre. Esta demo completa conecta varios widgets reales con un mismo conjunto de datos.
Vistas y flujos de trabajo
Resumen · Pipeline · Oportunidades · Actividades
- 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 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 <a href="/docs/react/intro/setup-react">instalación</a>.
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: 'sales', title: 'Vertriebs-Cockpit', description: 'Opportunities vom Erstgespräch bis zum Abschluss begleiten.', recordLabel: 'Opportunity', countLabel: 'Opportunities', searchLabel: 'Opportunities oder Kunden suchen …', stages: ['Qualifiziert', 'Angebot', 'Gewonnen'], views: [ { id: 'overview', title: 'Übersicht', }, { id: 'board', title: 'Pipeline', }, { id: 'table', title: 'Opportunities', }, { id: 'calendar', title: 'Aktivitäten', }, ], }; type Item = { id: string; title: string; account: string; owner: string; stage: number; value: number; progress: number; day: number; hour: number; }; const initial: Item[] = [ { id: 'V-301', title: 'Service-Portal', account: 'Kunde Nord', owner: 'Team Wachstum', stage: 1, value: 18500, progress: 65, day: 0, hour: 9, }, { id: 'V-302', title: 'Digitale Auftragserfassung', account: 'Kunde West', owner: 'Team Beratung', stage: 0, value: 12400, progress: 25, day: 1, hour: 10, }, { id: 'V-303', title: 'Ressourcen- und Einsatzplan', account: 'Kunde Süd', owner: 'Team Wachstum', stage: 2, value: 24800, progress: 100, day: 0, hour: 14, }, { id: 'V-304', title: 'Inventarverwaltung', account: 'Kunde Ost', owner: 'Team Beratung', stage: 1, value: 9600, progress: 70, day: 2, hour: 11, }, { id: 'V-305', title: 'Projekt-Dashboard', account: 'Kunde Mitte', owner: 'Team Partnerschaften', stage: 0, value: 14800, progress: 35, day: 3, hour: 10, }, { id: 'V-306', title: 'Kunden-Self-Service', account: 'Kunde Hafen', owner: 'Team Partnerschaften', stage: 1, value: 32000, progress: 80, day: 4, hour: 13, }, { id: 'V-307', title: 'Digitale Abnahme', account: 'Kunde Park', owner: 'Team Beratung', stage: 0, value: 8400, progress: 15, day: 2, hour: 15, }, { id: 'V-308', title: 'Vertriebsübersicht', account: 'Kunde Campus', owner: 'Team Wachstum', stage: 2, value: 11600, 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> ); }</code></pre>
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.