---
title: "Custom Kanban"
slug: "custom-kanban"
category: "Widgets"
reactComponent: "ArcWidgetKanban"
reactTier: "premium"
reactSince: "0.1.0-alpha.5"
reactExample: "kanban.tsx"
locale: "es"
hosts: "react"
---

# Kanban en React

Representa tareas o procesos como tarjetas en columnas de estado. La selección y el estado guardado del tablero pertenecen a tu aplicación React.

Referencia para `@arcrider/arcwidgets-react@0.1.0-alpha.5`. [Instalación](/docs/react/intro/setup-react) · [Todos los componentes](/docs/react/intro/react-components).

## Ejemplo completo

```tsx
import {
  ArcWidgetBadge,
  ArcWidgetIcon,
  ArcWidgetKanban,
  ArcWidgetLayout,
  ArcWidgetText,
} from '@arcrider/arcwidgets-react';
import { useState } from 'react';

const lang = document.documentElement.lang.startsWith('en')
  ? 'en'
  : document.documentElement.lang.startsWith('es')
    ? 'es'
    : 'de';
const requested = new URLSearchParams(location.search).get('data');
const pack = requested === 'empty' || requested === 'busy' ? requested : 'demo';
const t = (de: string, en: string, es: string) =>
  lang === 'en' ? en : lang === 'es' ? es : de;

const LANES = {
  neu: {
    id: 'neu',
    title: t('Neu', 'New', 'Nuevo'),
    borderColor: '#F59E0B',
  },
  offer: {
    id: 'offer',
    title: t('Angebot verschickt', 'Offer sent', 'Oferta enviada'),
    borderColor: '#8B5CF6',
  },
  won: {
    id: 'won',
    title: t('Gewonnen', 'Won', 'Ganado'),
    borderColor: '#22C55E',
  },
  lost: {
    id: 'lost',
    title: t('Verloren', 'Lost', 'Perdido'),
    borderColor: '#E04F5F',
  },
} as const;

type LaneId = keyof typeof LANES;

type Deal = {
  id: string;
  title: string;
  company: string;
  value: string;
  chance: number;
  overdue?: boolean;
};

const deal = (
  id: string,
  title: string,
  company: string,
  value: string,
  chance: number,
  overdue = false,
): Deal => ({ id, title, company, value, chance, overdue });

const demoDeals: Record<LaneId, Deal[]> = {
  neu: [
    deal(
      '1',
      t('Website-Relaunch', 'Website relaunch', 'Rediseño web'),
      'Müller GmbH',
      '18.400,00 €',
      20,
      true,
    ),
    deal(
      '2',
      t('Erstgespräch', 'First call', 'Primera llamada'),
      'Stahlwerk West',
      '6.200,00 €',
      10,
    ),
    deal(
      '7',
      t('Shop-Anbindung', 'Shop integration', 'Integración tienda'),
      'Nordlicht KG',
      '9.600,00 €',
      15,
    ),
    deal(
      '8',
      t('App-Redesign', 'App redesign', 'Rediseño app'),
      'Bauer AG',
      '24.100,00 €',
      25,
    ),
  ],
  offer: [
    deal(
      '3',
      t('CRM-Einführung', 'CRM rollout', 'Implantación CRM'),
      'Globex Inc.',
      '42.000,00 €',
      60,
    ),
    deal(
      '4',
      t('Servicevertrag', 'Service contract', 'Contrato servicio'),
      'Acme Corp',
      '12.800,00 €',
      45,
    ),
    deal(
      '9',
      t('Schulungspaket', 'Training package', 'Paquete formación'),
      'Wayne Ent.',
      '16.400,00 €',
      55,
    ),
  ],
  won: [
    deal(
      '5',
      t('Lizenzpaket', 'Licence pack', 'Paquete licencias'),
      'Umbrella Co.',
      '96.500,00 €',
      100,
    ),
    deal(
      '10',
      t('Wartung 24/7', 'Maintenance 24/7', 'Mantenimiento 24/7'),
      'Stark Ind.',
      '54.000,00 €',
      100,
    ),
    deal(
      '11',
      t('Schnittstellen', 'Integration', 'Integración'),
      'Soylent Ltd.',
      '31.200,00 €',
      100,
    ),
  ],
  lost: [
    deal(
      '6',
      t('Pilotprojekt', 'Pilot project', 'Proyecto piloto'),
      'Initech',
      '8.900,00 €',
      0,
    ),
    deal(
      '12',
      t('Workshop-Tag', 'Workshop day', 'Día de taller'),
      'Pied Piper',
      '4.800,00 €',
      0,
    ),
  ],
};

const extraDeals: Record<LaneId, Deal[]> = {
  neu: [
    deal(
      '13',
      t('Newsletter-Relaunch', 'Newsletter relaunch', 'Rediseño newsletter'),
      'Hooli',
      '7.400,00 €',
      18,
    ),
    deal(
      '14',
      t('Messestand', 'Fair booth', 'Stand feria'),
      'InGen',
      '11.200,00 €',
      22,
    ),
  ],
  offer: [
    deal(
      '15',
      t('Cloud-Migration', 'Cloud migration', 'Migración cloud'),
      'Cyberdyne',
      '38.000,00 €',
      50,
    ),
    deal(
      '16',
      t('Support-Plus', 'Support plus', 'Soporte plus'),
      'Massive Dyn.',
      '9.100,00 €',
      40,
    ),
  ],
  won: [
    deal(
      '17',
      t('Jahreslizenz', 'Annual licence', 'Licencia anual'),
      'Pied Piper',
      '21.000,00 €',
      100,
    ),
  ],
  lost: [
    deal(
      '18',
      t('PoC intern', 'Internal PoC', 'PoC interno'),
      'Hooli',
      '3.200,00 €',
      0,
    ),
  ],
};

const busyDeals: Record<LaneId, Deal[]> = {
  neu: [...demoDeals.neu, ...extraDeals.neu],
  offer: [...demoDeals.offer, ...extraDeals.offer],
  won: [...demoDeals.won, ...extraDeals.won],
  lost: [...demoDeals.lost, ...extraDeals.lost],
};

const emptyDeals: Record<LaneId, Deal[]> = {
  neu: [],
  offer: [],
  won: [],
  lost: [],
};

const deals =
  pack === 'empty' ? emptyDeals : pack === 'busy' ? busyDeals : demoDeals;

const itemView = (item: Deal, borderColor: string) => ({
  id: item.id,
  clickable: true,
  draggable: true,
  paddingX: '12px',
  paddingY: '10px',
  backgroundColor: '#ffffff',
  borderPosition: 'left',
  borderColor: item.overdue ? '#DC2626' : borderColor,
  borderWidth: '3px',
  title: (
    <span style={{ fontSize: 12, fontWeight: 600, color: '#18181b' }}>
      {item.title}
    </span>
  ),
  subtitle: (
    <span>
      <span style={{ fontSize: 11, color: '#52525b' }}>{item.company}</span>
      <br />
      <span style={{ fontSize: 11, fontWeight: 600, color: '#18181b' }}>
        {item.value}
      </span>
    </span>
  ),
  value: (
    <span
      style={{
        display: 'flex',
        alignItems: 'center',
        gap: 6,
        width: '100%',
        marginTop: 2,
      }}
    >
      <span
        style={{
          flex: 1,
          height: 6,
          borderRadius: 999,
          background: '#E5E7EB',
          overflow: 'hidden',
        }}
      >
        <span
          style={{
            display: 'block',
            width: `${item.chance}%`,
            height: '100%',
            background: borderColor,
            borderRadius: 999,
          }}
        />
      </span>
      <span
        style={{
          fontSize: 10,
          fontWeight: 600,
          color: '#18181b',
          minWidth: 28,
          textAlign: 'right',
        }}
      >
        {item.chance}%
      </span>
    </span>
  ),
});

const startLanes = (Object.keys(LANES) as LaneId[]).map((id) => ({
  ...LANES[id],
  items: deals[id],
}));

const PLUS_BLOCK = 4;

function LaneHeader({
  title,
  count,
  dot,
  onAdd,
}: {
  title: string;
  count: number;
  dot: string;
  onAdd: () => void;
}) {
  return (
    <ArcWidgetLayout
      data={{
        embedded: true,
        direction: 'horizontal',
        alignX: 'left',
        alignY: 'center',
        gap: '8px',
        width: '100%',
        height: 'auto',
        blocks: [
          {
            width: 'auto',
            height: 'auto',
            value: (
              <ArcWidgetIcon
                data={{
                  name: 'circle',
                  style: 'fill',
                  iconSize: '10px',
                  color: dot,
                  containerSize: '12px',
                  backgroundColor: 'transparent',
                }}
              />
            ),
          },
          {
            width: 'auto',
            height: 'auto',
            value: (
              <ArcWidgetText
                data={{
                  value: title,
                  fontSize: '14px',
                  fontWeight: '700',
                  fontColor: '#111827',
                }}
              />
            ),
          },
          {
            width: 'auto',
            height: 'auto',
            value: (
              <ArcWidgetBadge
                data={{
                  value: String(count),
                  fontSize: '11px',
                  fontWeight: '700',
                  fontColor: '#6B21A8',
                  backgroundColor: '#F3E8FF',
                  borderColor: '#E9D5FF',
                  borderRadius: '999px',
                  paddingX: '8px',
                  paddingY: '2px',
                }}
              />
            ),
          },
          { width: 'fraction', height: '1px', value: '' },
          {
            width: 'auto',
            height: 'auto',
            clickable: true,
            value: (
              <ArcWidgetIcon
                data={{
                  name: 'plus',
                  style: 'bold',
                  iconSize: '14px',
                  color: '#6B7280',
                  containerSize: '24px',
                  borderRadius: '8px',
                  backgroundColor: '#F4F5F7',
                }}
              />
            ),
          },
        ],
      }}
      onBlockClick={(block_index) => {
        if (block_index === PLUS_BLOCK) onAdd();
      }}
    />
  );
}

export default function Example() {
  const [lanes, setLanes] = useState(startLanes);
  const [selected, setSelected] = useState(
    t(
      'Karte ziehen oder anklicken',
      'Drag or click a card',
      'Arrastra o pulsa una tarjeta',
    ),
  );
  const addDeal = (laneId: LaneId) => {
    setLanes((current) =>
      current.map((lane) =>
        lane.id === laneId
          ? {
              ...lane,
              items: [
                ...lane.items,
                deal(
                  `${laneId}-${lane.items.length + 1}-${Date.now()}`,
                  t('Neuer Deal', 'New deal', 'Nuevo trato'),
                  t('Ohne Kontakt', 'No contact', 'Sin contacto'),
                  '—',
                  laneId === 'won' ? 100 : laneId === 'lost' ? 0 : 15,
                ),
              ],
            }
          : lane,
      ),
    );
    setSelected(
      `${t('Deal angelegt in', 'Deal added to', 'Trato añadido a')} ${LANES[laneId].title}`,
    );
  };
  return (
    <>
      <ArcWidgetKanban
        id="deals"
        data={{
          height: '380px',
          collapsible: false,
          showScrollbar: true,
          empty: {
            enabled: true,
            value: t('Keine Deals', 'No deals', 'Sin tratos'),
          },
          swimlanes: lanes.map((lane) => ({
            id: lane.id,
            title: (
              <LaneHeader
                title={lane.title}
                count={lane.items.length}
                dot={lane.borderColor}
                onAdd={() => addDeal(lane.id)}
              />
            ),
            minWidth: '300px',
            fontColor: '#111827',
            backgroundColorHeader: '#FFFFFF',
            backgroundColorBody: '#F4F5F7',
            showAmount: false,
            empty: true,
            items: lane.items.map((item) => itemView(item, lane.borderColor)),
          })),
        }}
        onItemClick={(_event, { item_index }) =>
          setSelected(`${t('Deal', 'Deal', 'Trato')} ${item_index + 1}`)
        }
        onItemDrop={async (payload) => {
          const next = lanes.map((lane) => ({
            ...lane,
            items: [...lane.items],
          }));
          const [item] = next[payload.source_swimlane_index].items.splice(
            payload.item_index_in_section,
            1,
          );
          next[payload.target_swimlane_index].items.splice(
            payload.target_index_in_section,
            0,
            item,
          );
          setLanes(next);
          setSelected(t('Karte verschoben', 'Card moved', 'Tarjeta movida'));
        }}
      />
      <p aria-live="polite">{selected}</p>
    </>
  );
}
```

## Props del componente

### id

ID opcional y estable de la instancia del widget. Sin especificarlo, React genera un ID.

### data

Configuración de tipo `ArcWidgetKanbanData`. Pasa los cambios como objetos nuevos; gestiona los datos de negocio y la selección en el estado de React.

### onAction

Fallback opcional `(type: string, ...args: unknown[]) => unknown`. El callback concreto tiene prioridad. Detalles: [Estado y eventos](/docs/react/patterns/react-state-events).

### onItemClick

Evento del navegador y posición de la tarjeta.

```typescript
onItemClick?: (event: Event, payload: ArcWidgetKanbanItemClickPayload) => void;
```

### onItemDrop

Nueva posición de una tarjeta movida. Un promise de guardado devuelto mantiene la operación abierta hasta que finaliza; el rechazo revierte el cambio.

```typescript
onItemDrop?: (payload: ArcWidgetKanbanItemDropPayload) => void | Promise<void>;
```

## Configuración

Los siguientes campos pertenecen a `data`. Los tamaños se indican como strings, los números y valores de tiempo según su tipo respectivo.

### height

Altura del widget, p. ej. `"320px"`. Los valores porcentuales necesitan un contenedor padre con altura definida.

Tipo: `string`.

### empty

Visualización para columnas vacías; `true` o una etiqueta y colores propios.

Tipo: `boolean | string | ArcWidgetKanbanEmpty`.

### swimlanes

Columnas con `items` planos o `sections` con nombre.

Tipo: `ArcWidgetKanbanSwimlane[]`.

### scrollSettings

Comportamiento de scroll del área de contenido.

El tipo y los campos anidados están en `ArcWidgetKanbanData`, bajo tipos de datos.

### showScrollbar

Visibilidad de la barra de scroll.

Tipo: `boolean`.

### collapsible

Expandir o contraer; el formato concreto está en el tipo.

Tipo: `boolean`.

## Comportamiento y límites

- Una columna contiene o bien `items` planos o bien `sections` con nombre, cada una con sus propios `items`. Los grupos necesitan un `id` estable.

- `onItemClick` entrega primero el evento del navegador, luego los índices de columna, grupo y tarjeta. Estos índices se refieren a la estructura de datos pasada.

- Activa `draggable` solo con un `onItemDrop` adecuado. Devuelve su promise de guardado; un promise rechazado cancela el cambio optimista. Actualiza tu estado tras un guardado exitoso.

- La visualización de columna vacía se activa con `empty: true` o una configuración propia.

Los campos `ArcWidgetSlot` admitidos permiten elementos JSX individuales, texto o descriptores de widget. Pasa texto externo como texto JSX; los strings HTML se interpretan como HTML. [Combinar contenidos](/docs/react/patterns/nested-widget-descriptors).

## Tipos de datos

Estos tipos relacionados con el widget se exportan desde el paquete. También están disponibles tipos comunes como `ArcWidgetSlot`, `ArcWidgetStyles` y `ArcReactWidgetProps`. Las firmas de índice permiten extensiones; no garantizan que el widget evalúe campos arbitrarios.

```typescript
export interface ArcWidgetKanbanItemClickPayload {
	swimlane_index: number;
	section_index: number;
	item_index: number;
	item_index_in_section: number;
}

export interface ArcWidgetKanbanItemDropPayload {
	source_swimlane_index: number;
	target_swimlane_index: number;
	item_index: number;
	source_section_index: number;
	target_section_index: number;
	item_index_in_section: number;
	target_index_in_section: number;
}

export interface ArcWidgetKanbanItem {
	id?: string | number;
	title?: ArcWidgetSlot;
	subtitle?: ArcWidgetSlot;
	value?: ArcWidgetSlot;
	clickable?: boolean;
	draggable?: boolean;
	paddingX?: string;
	paddingY?: string;
	borderPosition?: string;
	borderColor?: string;
	borderWidth?: string;
}

export interface ArcWidgetKanbanSection {
	id: string;
	title?: ArcWidgetSlot;
	items?: ArcWidgetKanbanItem[];
	hideWhenEmpty?: boolean;
	collapsed?: boolean;
	[key: string]: unknown;
}

export interface ArcWidgetKanbanEmpty {
	enabled?: boolean;
	value?: ArcWidgetSlot;
	backgroundColor?: string;
	fontColor?: string;
	fontWeight?: string | number;
}

export interface ArcWidgetKanbanSwimlane {
	id?: string | number;
	title?: ArcWidgetSlot;
	minWidth?: string;
	empty?: boolean | string | ArcWidgetKanbanEmpty;
	items?: ArcWidgetKanbanItem[];
	sections?: ArcWidgetKanbanSection[];
	scrollY?: { scrollbar?: { enabled?: boolean }; [key: string]: unknown };
	[key: string]: unknown;
}

export interface ArcWidgetKanbanData extends Omit<ArcWidgetBaseData, 'actions'> {
	height?: string;
	empty?: boolean | string | ArcWidgetKanbanEmpty;
	swimlanes?: ArcWidgetKanbanSwimlane[];
	scrollSettings?: {
		scrollX?: { scrollbar?: { enabled?: boolean }; [key: string]: unknown } | boolean;
		scrollY?: { scrollbar?: { enabled?: boolean }; [key: string]: unknown } | boolean;
		[key: string]: unknown;
	};
	showScrollbar?: boolean;
	collapsible?: boolean;
}

export type ArcWidgetKanbanProps = ArcReactWidgetProps<ArcWidgetKanbanData> & {
	onItemClick?: (event: Event, payload: ArcWidgetKanbanItemClickPayload) => void;
	onItemDrop?: (payload: ArcWidgetKanbanItemDropPayload) => void | Promise<void>;
};
```
