// DynamicKanban — the `view_type: "kanban"` renderer, a sibling of // `DynamicTable`. Given the SAME contract (model + endpoint + injected // ApiProvider), it fetches the model's metadata + records, groups the records // into board lanes by the `group_by` stage column, and lets the user drag a // card between lanes. // // Reuse, not reinvention: // - Metadata + records come through the same `useApi()` client and the same // `/metadata/table/:model` + `/data/:model` endpoints as DynamicTable. // - Card fields render through `ActivityValueRenderer`, the existing pure // single-value renderer that mirrors `defaultGetDynamicColumns`' display // logic (currency, status, date, relation chip, …) — so a card cell and a // table cell look identical. // - Per-card actions reuse `resolveRowActions` (capability-gated, same as the // table's action column) + `useDynamicRowActions`, the EXACT shared handler // DynamicTable's row menu dispatches through (view/edit/delete/link/custom). // // The one thing it owns that the table doesn't: an OPTIMISTIC drag-to-move. // Dropping a card into another lane mutates local state immediately and fires // `PUT /data/:model/me/:id { : }`; if the request fails // the move is reverted and a toast surfaces. This sidesteps the "refetch loses // scroll/selection" gap a naive re-query would introduce. // // Transitions: when the metadata carries `transitions[]`, a card may only be // dropped into a lane reachable from its current stage. Disallowed lanes dim // while dragging and reject the drop. import * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useI18nResourceVersion } from './use-i18n-resource-version' import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors, useDraggable, useDroppable, type DragStartEvent, type DragEndEvent, } from '@dnd-kit/core' import { SortableContext, horizontalListSortingStrategy, useSortable, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { arrayMove } from '@dnd-kit/sortable' import { Calendar, CircleDot, GripVertical, Hash, ListFilter, MoreHorizontal, RotateCcw, Search, Settings2, Tag, ToggleLeft, Type, X, } from 'lucide-react' import { toast } from 'sonner' import { Badge, Button, Card, CardContent, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Input, Popover, PopoverContent, PopoverTrigger, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, Skeleton, } from '@asteby/metacore-ui/primitives' import { ColumnFilterControl, FilterValueCombobox, type ColumnFilterType } from '@asteby/metacore-ui/data-table' import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib' import { useApi } from './api-context' import { useRealtimeDefault, useRealtimeTick } from './realtime-context' import { useStageAutomations, StageAutomationsButton, type StageAutomation, type NewStageAutomation, } from './stage-automations' import { useCustomStages, splitCustomStages, mergeLaneStages, resolveSmartLanes, cardMatchesStageFilters, smartLaneParams, AddStageColumn, CustomStageDialog, CustomStageDeleteDialog, StageConfigDialog, SmartLane, type CustomStage, type CustomStageFilter, type StageConfigTarget, } from './custom-stages' import { useStageOverrides } from './stage-overrides' import { useDynamicFilters } from './use-dynamic-filters' import { FilterChipsRow, summarizeFilterValues, translateOptionLabels, } from './filter-chips' import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll' import { useMetadataCache } from './metadata-cache' import { ActivityValueRenderer } from './activity-value-renderer' import { DynamicIcon } from './dynamic-icon' import { isColumnVisibleInTable } from './column-visibility' import { isRowActionVisible, relationKeyFor } from './dynamic-columns' import { useCan, usePermissionsActive, resolveRowActions } from './permissions-context' import { useDynamicRowActions } from './dynamic-row-actions' import { useStageLayout } from './stage-layout' import type { TableMetadata, ColumnDefinition, ApiResponse, ActionDefinition, StageMeta, StageTransition, } from './types' // Re-exported for tests + backward-compat: these live in ./filter-chips now // (shared with DynamicTable) but were historically imported from here. export { summarizeFilterValues, translateOptionLabels } // --------------------------------------------------------------------------- // Pure helpers (exported for unit tests — no React, no transport) // --------------------------------------------------------------------------- /** * Resolves the board lanes for a kanban view. Prefers the model-level * `metadata.stages` (the kernel's `stages[]`); falls back to the `group_by` * column's `options` (the kernel projects the stage machine onto the status * display). Returns lanes sorted by `order` (then declared order). Empty when * neither source is present — the caller renders a "no stages" notice. */ export function deriveStages(metadata: TableMetadata): StageMeta[] { const fromMeta = metadata.stages if (fromMeta && fromMeta.length > 0) { return [...fromMeta].sort(sortByOrder) } const groupBy = metadata.group_by if (!groupBy) return [] const col = metadata.columns.find((c) => c.key === groupBy) const opts = col?.options ?? [] return opts.map((o, i) => ({ key: String(o.value), label: o.label, color: o.color, order: i, })) } function sortByOrder(a: StageMeta, b: StageMeta): number { const ao = a.order ?? Number.MAX_SAFE_INTEGER const bo = b.order ?? Number.MAX_SAFE_INTEGER return ao - bo } /** * Buckets records into a `stageKey → rows[]` map, one entry per declared stage * (in stage order), plus a trailing `__unassigned__` bucket for rows whose * stage value matches no declared lane (so nothing silently vanishes). Empty * lanes are kept so the board always shows every stage. */ export const UNASSIGNED_LANE = '__unassigned__' export function groupByStage( records: any[], groupByKey: string, stages: StageMeta[], ): Map { const map = new Map() for (const s of stages) map.set(s.key, []) const known = new Set(stages.map((s) => s.key)) for (const row of records) { const raw = row?.[groupByKey] const key = raw === null || raw === undefined ? '' : String(raw) if (known.has(key)) { map.get(key)!.push(row) } else { if (!map.has(UNASSIGNED_LANE)) map.set(UNASSIGNED_LANE, []) map.get(UNASSIGNED_LANE)!.push(row) } } return map } /** * Whether a card may move `from → to` given the declared transitions. No * transitions declared → unrestricted (the kernel still validates server-side). * A move to the same stage is always a no-op "allowed". `'*'` is a wildcard on * either side. */ export function isTransitionAllowed( transitions: StageTransition[] | undefined, from: string, to: string, ): boolean { if (from === to) return true if (!transitions || transitions.length === 0) return true return transitions.some( (t) => (t.from === from || t.from === '*') && (t.to === to || t.to === '*'), ) } /** * Returns a NEW grouping with `cardId` moved from `fromStage` to `toStage` * (appended to the destination lane). Pure — does not mutate the input map. * Used by the optimistic drop handler so the board updates before the PUT * resolves, and so the previous grouping can be restored on failure. */ export function applyOptimisticMove( grouped: Map, cardId: string | number, fromStage: string, toStage: string, groupByKey: string, ): Map { const next = new Map() for (const [k, rows] of grouped) next.set(k, [...rows]) const fromRows = next.get(fromStage) ?? [] const idx = fromRows.findIndex((r) => String(r.id) === String(cardId)) if (idx === -1) return next const [moved] = fromRows.splice(idx, 1) const updated = { ...moved, [groupByKey]: toStage } const toRows = next.get(toStage) ?? [] toRows.push(updated) next.set(toStage, toRows) return next } /** * Returns a NEW per-lane pagination map with the server totals adjusted for a * card moving `fromStage` → `toStage`: the source loses one, the destination * gains one. Lanes whose `total` is still unknown (`null`, not yet topped up) * are left alone. Pure — backs the optimistic drag so a partial lane's * `count/total` header stays truthful, and can be restored on PUT failure. */ export function applyLaneTotalsOnMove( pagination: Record, fromStage: string, toStage: string, ): Record { const next = { ...pagination } const bump = (key: string, delta: number) => { const st = next[key] if (st && st.total != null) { next[key] = { ...st, total: Math.max(0, st.total + delta) } } } bump(fromStage, -1) bump(toStage, +1) return next } /** * Formats a lane header's count badge. Three cases: * - A lane filter/search is active → `shown/loaded` (the client-side narrowed * count over what this lane has loaded). * - Otherwise, when the stage's server total is known → `shown` alone once * everything is loaded (`shown >= total`), else `shown/total` (partial). * - Total still unknown → just `shown`. * Pure — exported for unit tests. */ export function formatLaneCount( shown: number, loaded: number, serverTotal: number | null, laneActive: boolean, ): string { if (laneActive) return `${shown}/${loaded}` if (serverTotal != null) { return shown >= serverTotal ? String(serverTotal) : `${shown}/${serverTotal}` } return String(shown) } /** * Picks the columns shown on a card: a `title` column (first searchable column, * else first text-ish column) and up to `maxFields` secondary columns. Excludes * the group_by column (it's the lane itself) and any column hidden from the * table view (visibility modal/list, or `hidden`). */ export function selectCardColumns( metadata: TableMetadata, maxFields = 3, ): { title: ColumnDefinition | null; fields: ColumnDefinition[] } { const groupBy = metadata.group_by const visible = metadata.columns.filter( (c) => c.key !== groupBy && !c.hidden && isColumnVisibleInTable(c) && c.key !== 'id', ) const title = visible.find((c) => c.searchable) ?? visible.find((c) => c.type === 'text' || c.cellStyle === 'truncate-text') ?? visible[0] ?? null const fields = visible .filter((c) => c.key !== title?.key) .slice(0, maxFields) return { title, fields } } /** The all-zeros UUID — a Go zero-value FK serialized as "set" when it isn't. */ const ZERO_UUID = /^0{8}-0{4}-0{4}-0{4}-0{12}$/ /** * The value a card cell should render for a column — same resolution the * table's cells apply. An FK column (`_id`) prefers the backend-resolved * sibling object (`card.`, e.g. `{ name }` / `{ value, label }`) over the * raw UUID; a zero-UUID FK counts as unset (renders the em-dash). Pure — * exported for unit tests. */ export function cardCellValue(card: any, col: ColumnDefinition): unknown { const raw = card?.[col.key] if (typeof raw === 'string' && ZERO_UUID.test(raw)) return null const relKey = relationKeyFor(col) if (relKey !== col.key) { const sibling = card?.[relKey] if (sibling && typeof sibling === 'object') return sibling } return raw } /** * Whether a card passes a lane funnel. Picked select/facet `values` match by * equality (IN — the card's field value must be one of them); a free-text * `text` matches by case-insensitive substring. No field / no criteria → passes. * Pure — exported for unit tests. */ export function cardMatchesLaneFunnel( card: any, filter: { field?: string; values?: string[]; text?: string } | undefined, ): boolean { if (!filter?.field) return true const raw = String(card?.[filter.field] ?? '') if (filter.values && filter.values.length > 0) { return filter.values.includes(raw) } if (filter.text?.trim()) { return raw.toLowerCase().includes(filter.text.trim().toLowerCase()) } return true } /** * Count of applied criteria on a lane funnel: the number of picked select/facet * `values`, else 1 for a free-text `text`, else 0. Drives the funnel's count * badge. Pure — exported for unit tests. */ export function laneFunnelCount( value: { values?: string[]; text?: string } | undefined, ): number { if (value?.values?.length) return value.values.length if (value?.text?.trim()) return 1 return 0 } /** * Whether a card matches a free-text lane search: a case-insensitive substring * over the card's title + every visible field value (`String(v)`). Empty query * matches everything. Pure — exported for unit tests. */ export function cardMatchesLaneQuery( card: any, cols: ColumnDefinition[], query: string, ): boolean { const q = query.trim().toLowerCase() if (!q) return true return cols.some((c) => String(card?.[c.key] ?? '') .toLowerCase() .includes(q), ) } // --------------------------------------------------------------------------- // Theme hook (mirrors the private one in dynamic-columns / activity-renderer) // --------------------------------------------------------------------------- function useIsDarkTheme(): boolean { const [isDark, setIsDark] = useState( () => typeof document !== 'undefined' && document.documentElement.classList.contains('dark'), ) useEffect(() => { if (typeof document === 'undefined') return const sync = () => setIsDark(document.documentElement.classList.contains('dark')) const observer = new MutationObserver(sync) observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'], }) return () => observer.disconnect() }, []) return isDark } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- /** * Per-lane client-side filter. Two AND-combined dimensions: * - The funnel: a `field` plus EITHER `values` (chosen from a select/facet — * matched by equality/IN against the card's field value) OR `text` (a * free-text substring for text-only fields). * - `query`: the lane search — a substring over the card title + field values. */ interface LaneFilterState { field?: string values?: string[] text?: string query?: string } /** Incremental pagination bookkeeping for one lane/stage. */ interface LanePageState { /** Next stage-scoped page to request. */ nextPage: number /** Server total for the stage (from response meta), or null if unknown. */ total: number | null /** A top-up request is in flight. */ loading: boolean /** No more pages for this stage. */ done: boolean } export interface DynamicKanbanProps { /** Model key as registered on the backend (e.g. "issue"). */ model: string /** * Data endpoint base — the org-scoped LIST endpoint (e.g. * `/data//me`). The optimistic update PUTs to `/`. */ endpoint?: string /** Bump to force a metadata + records refetch (same contract as DynamicTable). */ refreshTrigger?: any /** * Refetch the board when the host's realtime client reports a data event * for this model (same contract as DynamicTable's `realtime`). Off by * default; `` flips the default. */ realtime?: boolean /** Called when a card is clicked (outside its action menu). */ onCardClick?: (row: any) => void /** * Host hook for `view`/`edit` card actions (STRING contract — same as * DynamicTable's `onAction`). When provided, `view`/`edit` route to the host * (e.g. its seeded record modal); when omitted they open the SDK's built-in * record dialog. `delete`/link/custom actions are always handled in-SDK. */ onAction?: (action: string, row: any) => void /** * Size of the INITIAL board page (one request, grouped into lanes). Each * lane then tops up incrementally on scroll (see `lanePageSize`). Defaults * to 50 — enough to fill the visible lanes without loading the whole board. */ pageSize?: number /** * Page size for a lane's incremental top-up fetch (scoped by * `f_=`). Defaults to 25. */ lanePageSize?: number /** IANA timezone for datetime card fields (org config). */ timeZone?: string /** ISO 4217 currency for money card fields (org config). */ currency?: string /** * Static equality filters always applied to the board (never shown as a * removable chip). Same contract as DynamicTable's `defaultFilters`. */ defaultFilters?: Record } export function DynamicKanban({ model, endpoint, refreshTrigger, realtime: realtimeProp, onCardClick, onAction, pageSize = 50, lanePageSize = 25, timeZone, currency, defaultFilters, }: DynamicKanbanProps) { const { t, i18n } = useTranslation() const api = useApi() const isDark = useIsDarkTheme() // Realtime refetch (opt-in) — debounced counter bumped by DATA_EVENTs for // this model; folded into the board refetch effect next to refreshTrigger. const realtimeDefault = useRealtimeDefault() const realtimeTick = useRealtimeTick({ models: [model], enabled: realtimeProp ?? realtimeDefault, }) // Stage automations (Bitrix-style per-lane rules). Degrades to no-op when // the host has no `/stage-automations` endpoint — the ⚡ affordance hides. const automations = useStageAutomations(model) // Custom stages (Bitrix-style user-defined columns). Degrades to no-op when // the host has no `/custom-stages` endpoint — the "+ Agregar etapa" column // and lane menus simply don't render. const customStages = useCustomStages(model) // Per-org overrides for DECLARED lanes (rename/recolor/conditions). Degrades // to no-op when the host has no `/stage-overrides` endpoint — the ⚙ gear then // hides on declared lanes (custom lanes keep it via /custom-stages). const stageOverrides = useStageOverrides(model) // Dialog state: create/edit a stage, and the delete confirmation. const [stageDialogOpen, setStageDialogOpen] = useState(false) const [editingStage, setEditingStage] = useState(null) const [deletingStage, setDeletingStage] = useState(null) // The gear (⚙) "Configurar etapa" dialog — one UI for declared + custom lanes. const [configTarget, setConfigTarget] = useState(null) const openCreateStage = useCallback(() => { setEditingStage(null) setStageDialogOpen(true) }, []) const openEditStage = useCallback((s: CustomStage) => { setEditingStage(s) setStageDialogOpen(true) }, []) const openDeleteStage = useCallback((s: CustomStage) => { setDeletingStage(s) }, []) const { getMetadata, setMetadata: cacheMetadata } = useMetadataCache() const cachedMeta = getMetadata(model) const [metadata, setMetadata] = useState(cachedMeta || null) const [records, setRecords] = useState([]) const [loading, setLoading] = useState(!cachedMeta) const [loadingData, setLoadingData] = useState(true) // Per-stage incremental pagination for infinite scroll. The initial board // page (grouped into lanes) is fetched once; each lane then tops up its OWN // stage via `f_=&page=n`, appended (deduped by id) into the // shared `records`. `total` is the stage's server count when the response // meta carries it. Reset whenever the filters/search change. const [lanePagination, setLanePagination] = useState< Record >({}) // Active drag card id (for the DragOverlay + drop-zone highlighting). const [activeId, setActiveId] = useState(null) // Active drag LANE id — a header drag reorders columns (Trello/Bitrix-style) // rather than moving a card. Kept apart so onDragEnd routes by draggable type. const [activeLaneId, setActiveLaneId] = useState(null) // Per-org lane order. `useStageLayout` reports whether the host wired the // `/stage-layout` endpoint (→ lane drag turns on) and persists the chosen // order. `laneOrderOverride` is the OPTIMISTIC session order applied on top of // the metadata (the backend also stamps `stages[]/smart_lanes[].order`, so the // board already paints ordered on load — this only backs the live drag + the // revert-on-failure). Null → follow the metadata order. const stageLayout = useStageLayout(model) const [laneOrderOverride, setLaneOrderOverride] = useState(null) const laneReorderEnabled = stageLayout.available // Monotonic token for the current board load. Bumped on every fetchData so a // slow eager-totals response from a superseded filter set can't inject stale // stage totals into the fresh board. const fetchGenRef = useRef(0) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), ) // ---- metadata fetch (same path as DynamicTable) ---- useEffect(() => { let cancelled = false const cached = getMetadata(model) if (cached) { setMetadata(cached) setLoading(false) } else { setLoading(true) } api .get(`/metadata/table/${model}`) .then((res) => { if (cancelled) return const body = res.data as ApiResponse if (body.success) { setMetadata(body.data) cacheMetadata(model, body.data) } }) .catch((err) => { if (!cancelled && !cached) console.error('Error al cargar la configuración del tablero', err) }) .finally(() => { if (!cancelled) setLoading(false) }) return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps }, [model]) // Shared metadata-driven filter engine — the SAME configs, option prefetch // and `f_` serialization DynamicTable uses, so the board filters // identically to its table sibling. const { dynamicFilters, globalFilter, setGlobalFilter, columnFilterConfigs, filterParams, activeFilterCount, handleDynamicFilterChange, clearAll, } = useDynamicFilters(metadata, { defaultFilters, model, endpoint }) // ---- initial board page (one request, grouped into lanes) ---- // Resets the per-lane pagination so every lane restarts its incremental // top-up from scratch — called on mount, refresh, and any filter/search // change (fetchData's identity changes with filterParams). const fetchData = useCallback(async () => { if (!metadata) return const gen = ++fetchGenRef.current setLoadingData(true) try { const res = (await api.get(endpoint || `/data/${model}`, { params: { page: 1, per_page: pageSize, ...filterParams }, })) as { data: ApiResponse } if (res.data.success) setRecords(res.data.data || []) } catch (err) { console.error('Error al cargar las tarjetas', err) } finally { setLoadingData(false) } setLanePagination({}) // Eager per-lane totals: so every lane header shows the REAL stage count // on first render (not just what the global page happened to load), fire // one lightweight `per_page=1` request per declared stage — in parallel, // scoped by the SAME active filters/search (`f_=` on top // of filterParams). We read only `meta.total`; the row itself is ignored // (the lane loads its real cards via loadMoreLane on scroll). The // "unassigned" lane can't be stage-scoped, so it keeps its loaded count. const gb = metadata.group_by const declaredStages = deriveStages(metadata) if (gb && declaredStages.length > 0) { void Promise.all( declaredStages.map(async (stage) => { try { const r = (await api.get(endpoint || `/data/${model}`, { params: { ...filterParams, ...smartLaneParams(stage.filters), page: 1, per_page: 1, [`f_${gb}`]: stage.key, }, })) as { data: ApiResponse & { meta?: any } } const total = r.data.meta?.total ?? r.data.meta?.count ?? null if (total == null || gen !== fetchGenRef.current) return setLanePagination((p) => { const existing = p[stage.key] // A real page fetch already learned this lane's total. if (existing?.total != null) return p return { ...p, [stage.key]: { nextPage: existing?.nextPage ?? 1, total, loading: existing?.loading ?? false, done: existing?.done ?? total === 0, }, } }) } catch (err) { // A failed total just leaves the header on the loaded count. } }), ) } }, [api, endpoint, model, metadata, pageSize, filterParams]) // Load the next page for ONE lane/stage and append it (deduped by id) into // the shared records. Scoped by `f_=` on top of the active // filterParams (the stage scope wins over any global group_by filter). const groupByKey = metadata?.group_by || '' // Extra per-lane conditions (stage overrides) keyed by stage. A real lane // that carries these queries its data — and counts its header — with the // stage scope PLUS these filters (serialized like a smart lane's), so the // top-up + eager-total requests below layer them on. Sourced from // `metadata.stages` (the kernel applies declared + custom-real overrides). const stageExtraFilters = useMemo(() => { const m = new Map() for (const s of metadata?.stages ?? []) { if (s.filters && s.filters.length > 0) { m.set( s.key, s.filters.map((f) => ({ field: f.field, op: f.op as CustomStageFilter['op'], value: f.value, })), ) } } return m }, [metadata?.stages]) const loadMoreLane = useCallback( async (stageKey: string) => { if (!metadata || !groupByKey) return const current = lanePagination[stageKey] if (current?.loading || current?.done) return const nextPage = current?.nextPage ?? 1 setLanePagination((p) => ({ ...p, [stageKey]: { nextPage, total: current?.total ?? null, loading: true, done: false, }, })) try { const res = (await api.get(endpoint || `/data/${model}`, { params: { ...filterParams, ...smartLaneParams(stageExtraFilters.get(stageKey)), page: nextPage, per_page: lanePageSize, [`f_${groupByKey}`]: stageKey, }, })) as { data: ApiResponse & { meta?: any } } const rows = res.data.success ? res.data.data || [] : [] const total = res.data.meta?.total ?? res.data.meta?.count ?? null setRecords((prev) => dedupeById(prev, rows)) setLanePagination((p) => ({ ...p, [stageKey]: { nextPage: nextPage + 1, total, loading: false, // Exhausted when the server returned a short page. done: rows.length < lanePageSize, }, })) } catch (err) { console.error(`Error al cargar más tarjetas de ${stageKey}`, err) setLanePagination((p) => ({ ...p, [stageKey]: { nextPage, total: current?.total ?? null, loading: false, done: false, }, })) } }, [api, endpoint, model, metadata, groupByKey, filterParams, lanePageSize, lanePagination, stageExtraFilters], ) // Refetch when metadata resolves, on an explicit refresh, or when the // filters change. `fetchData` is stable while `filterParams` is unchanged // (both memoized), so this only re-runs on real input changes. Debounced so // typing in the search box doesn't fire a request per keystroke. useEffect(() => { if (!metadata) return const handle = setTimeout(() => { void fetchData() }, 200) return () => clearTimeout(handle) // realtimeTick: data events for this model (see the `realtime` prop). // eslint-disable-next-line react-hooks/exhaustive-deps }, [fetchData, metadata, refreshTrigger, realtimeTick]) // Filterable fields for the toolbar, in metadata order (explicit filters // first, then filterable columns), each labeled from its metadata source. const filterFields = useMemo(() => { if (!metadata) return [] const out: { key: string label: string config: NonNullable> }[] = [] // Option labels come from the manifest as i18n keys (e.g. // "integration_github.stage.backlog"). ColumnFilterControl lives in the // ui package (no i18n), so translate labels HERE — on the static options // and on whatever the facet loader resolves — before they ever reach a // control, chip or value summary. A raw value (a repo name) has no key, // so t() returns it verbatim via defaultValue. const tr = (label: string) => t(label, { defaultValue: label }) for (const [key, config] of columnFilterConfigs) { const f = metadata.filters?.find((x) => x.key === key) const c = metadata.columns.find((x) => x.key === key) const rawLabel = f?.label || c?.label || key const translatedConfig = { ...config, options: translateOptionLabels(config.options, tr), loadOptions: config.loadOptions ? (q?: string) => config.loadOptions!(q).then((opts) => translateOptionLabels(opts, tr), ) : undefined, } out.push({ key, label: tr(rawLabel), config: translatedConfig, }) } return out }, [metadata, columnFilterConfigs, t]) // Split filters into active (with a selection) and the rest — the Sheet // groups the active ones on top, the rest alphabetically. Also drives the // removable chip row below the toolbar. const { activeFields, inactiveFields } = useMemo(() => { const active = filterFields.filter( (f) => (f.config.selectedValues?.length ?? 0) > 0, ) const inactive = filterFields .filter((f) => (f.config.selectedValues?.length ?? 0) === 0) .sort((a, b) => a.label.localeCompare(b.label)) return { activeFields: active, inactiveFields: inactive } }, [filterFields]) // Sheet (grouped global filters) open state + per-lane client-side filters. // A lane filter narrows ONLY that stage's already-fetched cards by a field // value — instant, no refetch — so a user can drill into one column without // touching the rest of the board (the global filters, by contrast, refetch // the whole board server-side). const [filtersOpen, setFiltersOpen] = useState(false) // Per-lane client-side narrowing. Two independent, AND-combined dimensions: // - `field`/`value`: the funnel — a field-scoped substring match. // - `query`: the lane search — a substring over the card title + every // visible field value. // A lane with neither is dropped from the map (so it reads as "unfiltered"). const [laneFilters, setLaneFilters] = useState< Record >({}) const updateLaneFilter = useCallback( (stageKey: string, patch: Partial) => { setLaneFilters((prev) => { const merged: LaneFilterState = { ...prev[stageKey], ...patch } const next = { ...prev } const hasFunnel = !!( merged.field && ((merged.values && merged.values.length > 0) || merged.text?.trim()) ) const hasQuery = !!merged.query?.trim() if (hasFunnel || hasQuery) next[stageKey] = merged else delete next[stageKey] return next }) }, [], ) const declaredStages = useMemo( () => (metadata ? deriveStages(metadata) : []), [metadata], ) // The kernel merges custom real stages into `metadata.stages` (custom: true) // and serves smart lanes in `metadata.smart_lanes` — the metadata is the // painting source (ops #704). The CRUD list (`customStages.stages`) only // backs the management dialog: it carries the `id`/filters the metadata // omits, so we still split it to build `customByKey` (and to fall back when // the metadata hasn't caught up yet). const { laneStages: customLaneStages, smartStages: crudSmartStages } = useMemo(() => splitCustomStages(customStages.stages), [customStages.stages]) const { lanes: stages, customByKey } = useMemo( () => mergeLaneStages(declaredStages, customLaneStages), [declaredStages, customLaneStages], ) // Smart lanes to paint: metadata first, folding in CRUD ids by key. const smartStages = useMemo( () => resolveSmartLanes(metadata?.smart_lanes, crudSmartStages, model), [metadata?.smart_lanes, crudSmartStages, model], ) // Position a newly created stage after every existing lane (real + smart). const nextStagePosition = useMemo(() => { const positions = customStages.stages.map((s) => s.position ?? 0) const base = stages.length + smartStages.length return Math.max(base, ...positions, 0) + 1 }, [customStages.stages, stages.length, smartStages.length]) const transitions = metadata?.transitions // Unified, ordered list of the DRAGGABLE lanes (real stages + smart lanes) — // the sortable sequence the header drag reorders. Real stages and smart lanes // are merged by their `order` (the backend stamps a global sequence once a // custom order exists); a `laneOrderOverride` from the live drag wins. Ties // (the default, un-customized case where both sets start at 0) keep stages // before smart lanes, matching the pre-reorder layout. const renderLanes = useMemo< Array< | { kind: 'stage'; stage: StageMeta } | { kind: 'smart'; stage: CustomStage } > >(() => { const arr: Array<{ kind: 'stage' | 'smart' stage: any order: number }> = [ ...stages.map((s, i) => ({ kind: 'stage' as const, stage: s, order: s.order ?? i, })), ...smartStages.map((s, i) => ({ kind: 'smart' as const, stage: s, order: s.position ?? 1000 + i, })), ] if (laneOrderOverride) { const idx = new Map(laneOrderOverride.map((k, i) => [k, i])) arr.sort( (a, b) => (idx.get(a.stage.key) ?? Number.MAX_SAFE_INTEGER) - (idx.get(b.stage.key) ?? Number.MAX_SAFE_INTEGER), ) } else { arr.sort( (a, b) => a.order - b.order || (a.kind === b.kind ? 0 : a.kind === 'stage' ? -1 : 1), ) } return arr.map(({ kind, stage: s }) => kind === 'stage' ? { kind: 'stage' as const, stage: s as StageMeta } : { kind: 'smart' as const, stage: s as CustomStage }, ) }, [stages, smartStages, laneOrderOverride]) // The sortable ids, in current visual order — SortableContext items + the // basis for the arrayMove the drop computes. const boardLaneKeys = useMemo( () => renderLanes.map((l) => l.stage.key), [renderLanes], ) // Real (card-droppable) stage keys — guards a card drop from ever landing on // a smart lane (a saved view, never a stored stage value). const realStageKeys = useMemo( () => new Set(stages.map((s) => s.key)), [stages], ) const grouped = useMemo( () => groupByStage(records, groupByKey, stages), [records, groupByKey, stages], ) const { title: titleCol, fields: fieldCols } = useMemo( () => (metadata ? selectCardColumns(metadata) : { title: null, fields: [] }), [metadata], ) // Columns the lane search scans: the card title + its visible field cells. const searchCols = useMemo( () => [titleCol, ...fieldCols].filter(Boolean) as ColumnDefinition[], [titleCol, fieldCols], ) // Row-placement actions resolved EXACTLY like DynamicTable's action column: // capability-gated (when a is mounted) and with the // implicit View/Edit/Delete trio materialized for CRUD models. An action the // user lacks permission for never appears. const can = useCan() const permissionsActive = usePermissionsActive() const rowActions = useMemo( () => metadata ? resolveRowActions(metadata, model, can, permissionsActive, (k, fb) => t(k, { defaultValue: fb }), ) : [], [metadata, model, can, permissionsActive, t], ) // Shared row-action dispatch + dialogs — view/edit/delete/link/custom behave // identically to a table row (the card menu used to forward the raw action // object to the host and silently no-op). const { handleInternalAction, dialogs: rowActionDialogs } = useDynamicRowActions({ model, endpoint, metadata, onAction, onRefresh: fetchData, }) const cardById = useMemo(() => { const m = new Map() for (const r of records) m.set(String(r.id), r) return m }, [records]) const stageOfCard = useCallback( (id: string): string => { const card = cardById.get(id) const raw = card?.[groupByKey] return raw === null || raw === undefined ? '' : String(raw) }, [cardById, groupByKey], ) const onDragStart = useCallback((e: DragStartEvent) => { if (e.active.data.current?.type === 'lane') { setActiveLaneId(String(e.active.id)) } else { setActiveId(String(e.active.id)) } }, []) // Optimistic lane reorder: reorder the columns in local state immediately, // PUT the full new order, and revert + toast on failure. Keys mix real // stages and smart lanes (the backend applies the order to both). const reorderLanes = useCallback( async (activeKey: string, overKey: string) => { const from = boardLaneKeys.indexOf(activeKey) const to = boardLaneKeys.indexOf(overKey) if (from === -1 || to === -1 || from === to) return const next = arrayMove(boardLaneKeys, from, to) const prev = laneOrderOverride setLaneOrderOverride(next) try { await stageLayout.save(next) } catch { setLaneOrderOverride(prev) toast.error( t('dynamic.stage_layout.save_error', { defaultValue: 'No se pudo guardar el orden', }), ) } }, [boardLaneKeys, laneOrderOverride, stageLayout, t], ) const onDragEnd = useCallback( async (e: DragEndEvent) => { setActiveId(null) setActiveLaneId(null) const { active, over } = e // A header drag reorders columns rather than moving a card. if (active.data.current?.type === 'lane') { if (over) await reorderLanes(String(active.id), String(over.id)) return } if (!over) return const cardId = String(active.id) const destStage = String(over.id) // Never drop a card onto a smart lane (a saved view, not a stage). if (!realStageKeys.has(destStage) && destStage !== UNASSIGNED_LANE) return const srcStage = stageOfCard(cardId) if (srcStage === destStage) return if (!isTransitionAllowed(transitions, srcStage, destStage)) { toast.error( t('kanban.invalidTransition', { defaultValue: 'Movimiento no permitido entre estas etapas', }), ) return } // OPTIMISTIC: move the card in local state immediately. const prevRecords = records const prevPagination = lanePagination setRecords((rs) => rs.map((r) => String(r.id) === cardId ? { ...r, [groupByKey]: destStage } : r, ), ) // Keep the server totals consistent with the moved card so a lane's // `count/total` header stays truthful with partial lanes: one leaves // the source stage, one joins the destination. setLanePagination((p) => applyLaneTotalsOnMove(p, srcStage, destStage)) try { const base = endpoint || `/data/${model}` // `base` is the org-scoped list endpoint (e.g. `/data//me`), // so the per-record update is just `/` — same convention // as DynamicTable/DynamicRelation. Appending an extra `/me` here // produced `/data//me/me/` → 404 on drag-to-move. const res = (await api.put(`${base}/${cardId}`, { [groupByKey]: destStage, })) as { data?: ApiResponse } if (res?.data && res.data.success === false) { throw new Error(res.data.message || 'update_failed') } } catch (err: any) { // REVERT + toast on failure. setRecords(prevRecords) setLanePagination(prevPagination) toast.error( t('kanban.moveFailed', { defaultValue: 'No se pudo mover la tarjeta', }) + (err?.response?.data?.message ? `: ${err.response.data.message}` : ''), ) } }, [api, endpoint, groupByKey, lanePagination, model, records, stageOfCard, t, transitions, realStageKeys, reorderLanes], ) // Board-level "Restablecer orden": drop the stored order and refetch the // metadata so the lanes fall back to the DECLARED order. Optimistic — reverts // its local override on failure. const resetLaneOrder = useCallback(async () => { const prev = laneOrderOverride setLaneOrderOverride(null) try { await stageLayout.reset() const res = await api.get(`/metadata/table/${model}`) const body = res.data as ApiResponse if (body.success) { setMetadata(body.data) cacheMetadata(model, body.data) } } catch { setLaneOrderOverride(prev) toast.error( t('dynamic.stage_layout.reset_error', { defaultValue: 'No se pudo restablecer el orden', }), ) } }, [api, cacheMetadata, laneOrderOverride, model, stageLayout, t]) if (loading) { return (
{[0, 1, 2, 3].map((i) => (
))}
) } if (!metadata || !groupByKey || stages.length === 0) { return (
{t('kanban.noStages', { defaultValue: 'Este modelo no declara etapas para la vista de tablero.', })}
) } const activeCard = activeId ? cardById.get(activeId) : null const activeStage = activeId ? stageOfCard(activeId) : '' // The synthetic "Sin etapa" lane (only when some record's stage matches no // declared lane). Rendered after the sortable lanes, never draggable itself. const unassignedStage: StageMeta | null = grouped.has(UNASSIGNED_LANE) ? { key: UNASSIGNED_LANE, label: t('kanban.unassigned', { defaultValue: 'Sin etapa' }), color: 'slate', order: Number.MAX_SAFE_INTEGER, } : null // Whether a card may drop into a lane given the active card's stage + the // declared transitions (a same-stage or unrestricted move always passes). const droppableAllowedFor = (stageKey: string) => !activeId || stageKey === activeStage || isTransitionAllowed(transitions, activeStage, stageKey) // Opens the gear (⚙) "Configurar etapa" dialog for a lane, routing to the // right backend by kind: a custom real stage edits through /custom-stages, a // declared stage through /stage-overrides. Seeds the current label/color and // any extra conditions the lane already carries. const openConfigStage = (stage: StageMeta) => { const custom = customStages.available ? customByKey.get(stage.key) : undefined const raw = stageExtraFilters.get(stage.key) ?? (custom?.filters as CustomStageFilter[] | undefined) ?? [] const filters: CustomStageFilter[] = raw.map((f) => ({ field: f.field, op: f.op as CustomStageFilter['op'], value: f.value, })) if (custom) { setConfigTarget({ kind: 'custom', stageKey: stage.key, id: custom.id, label: custom.label, color: custom.color, filters, customStage: custom, }) } else { const original = stage.original ? { label: stage.original.label, color: stage.original.color, filters: stage.original.filters?.map((f) => ({ field: f.field, op: f.op as CustomStageFilter['op'], value: f.value, })), } : undefined setConfigTarget({ kind: 'declared', stageKey: stage.key, label: t(stage.label, { defaultValue: stage.label }), color: stage.color ?? 'slate', filters, overridden: !!stage.overridden, isFinal: stage.is_final, original, }) } } // Whether a lane offers the gear: custom real stages always (their CRUD is // wired); declared lanes only when the host wired /stage-overrides. Never on // the synthetic "Sin etapa" lane. const isConfigurable = (stage: StageMeta): boolean => { if (stage.key === UNASSIGNED_LANE) return false return customByKey.has(stage.key) ? customStages.available : stageOverrides.available } // Builds every `KanbanLane` prop (minus the dnd wiring) for one stage — // shared by the sortable real stages and the plain-droppable unassigned lane. const buildLaneProps = (stage: StageMeta): Omit => { const allCards = grouped.get(stage.key) ?? [] // Extra lane conditions (stage override): the server already scopes this // lane's top-up + total queries by them, but the shared INITIAL board page // is unscoped, so narrow those cards client-side too (belt-and-suspenders). const extraFilters = stageExtraFilters.get(stage.key) ?? [] // Per-lane client-side narrowing (instant, scoped to this stage). The // funnel (field/value) and the lane search (query) are AND-combined. const laneFilter = laneFilters[stage.key] let cards = allCards if (extraFilters.length > 0) { cards = cards.filter((c) => cardMatchesStageFilters(c, extraFilters)) } if (laneFilter?.field) { cards = cards.filter((c) => cardMatchesLaneFunnel(c, laneFilter)) } if (laneFilter?.query?.trim()) { cards = cards.filter((c) => cardMatchesLaneQuery(c, searchCols, laneFilter.query!), ) } const laneState = lanePagination[stage.key] const isUnassigned = stage.key === UNASSIGNED_LANE const laneHasMore = !isUnassigned && !laneState?.done return { stage, count: cards.length, totalCount: allCards.length, serverTotal: laneState?.total ?? null, hasMore: laneHasMore, loadingMore: !!laneState?.loading, onLoadMore: () => loadMoreLane(stage.key), filterFields, laneFilter, onFunnelChange: (f) => updateLaneFilter(stage.key, { field: f?.field, values: f?.values, text: f?.text, }), onQueryChange: (q) => updateLaneFilter(stage.key, { query: q }), isDark, dimmed: !!activeId && !droppableAllowedFor(stage.key), model, columns: metadata?.columns ?? [], automationsAvailable: automations.available && stage.key !== UNASSIGNED_LANE, automationRules: automations.byStage.get(stage.key) ?? [], onAutomationCreate: automations.create, onAutomationUpdate: automations.update, onAutomationRemove: automations.remove, extraFilters, configurable: isConfigurable(stage), onConfigure: () => openConfigStage(stage), children: loadingData && cards.length === 0 ? ( <> ) : cards.length === 0 ? (

{t('kanban.emptyLane', { defaultValue: 'Sin tarjetas' })}

) : ( cards.map((card) => ( )) ), } } // Props for one smart (virtual) lane — a read-only, filter-defined column. const buildSmartProps = ( smart: CustomStage, ): React.ComponentProps => ({ stage: smart, model, endpoint, defaultFilters, pageSize, isDark, refreshTrigger, onEdit: openEditStage, onDelete: openDeleteStage, renderCard: (card: any) => ( ), }) const showResetOrder = laneReorderEnabled && (stageLayout.hasCustomLayout || !!laneOrderOverride) return (
{/* Filter bar — global search + one chip per filterable field, the SAME set the DynamicTable exposes in its column headers. Changing any control refetches the board server-side (debounced) via the shared useDynamicFilters engine. */}
setGlobalFilter(e.target.value)} placeholder={t('kanban.searchPlaceholder', { defaultValue: 'Buscar...' })} className="h-8 w-52 pl-8 text-sm" />
{filterFields.length > 0 && ( {t('kanban.filters', { defaultValue: 'Filtros' })} {/* Active filters grouped on top, the rest alphabetical below a separator. Each row: label + its control; the active field is highlighted by ColumnFilterControl's own active styling. Same server-side engine as before. */}
{activeFields.length > 0 && ( <>

{t('kanban.activeFilters', { defaultValue: 'Con filtros activos', })}

{activeFields.map((field) => ( ))} {inactiveFields.length > 0 && (
)} )} {inactiveFields.map((field) => ( ))}
{activeFilterCount > 0 ? t('kanban.activeCount', { defaultValue: '{{count}} activos', count: activeFilterCount, }) : t('kanban.noActiveFilters', { defaultValue: 'Sin filtros', })}
)} {showResetOrder && ( )}
{/* Removable chip row — shared with DynamicTable. Instant feedback without opening the Sheet; a chip's X clears that field. */} {/* Horizontal SortableContext over the draggable lanes (real stages + smart lanes). Card drags don't touch it — their active id isn't a sortable item, so the lanes never shift when moving a card; a `data.type` tag routes drop handling. */}
{renderLanes.map((lane) => lane.kind === 'stage' ? ( ) : laneReorderEnabled ? ( ) : ( ), )} {/* Synthetic "Sin etapa" lane — a card drop target, not sortable. */} {unassignedStage && ( )} {/* "+ Agregar etapa" — only when the host wired /custom-stages. */} {customStages.available && ( )}
{activeCard ? ( ) : null} {rowActionDialogs}
{customStages.available && ( <> { if (!o) setDeletingStage(null) }} stage={deletingStage} reassignTargets={stages.map((s) => ({ key: s.key, label: s.label, }))} onConfirm={(s, reassignTo) => customStages.remove(s.id, reassignTo) } /> )} {/* Unified "Configurar etapa" dialog opened by the per-lane ⚙ gear — routes saves to /stage-overrides (declared) or /custom-stages (custom) by the target's kind. */} { if (!o) setConfigTarget(null) }} columns={metadata?.columns ?? []} target={configTarget} onSaveOverride={async (stageKey, patch) => { await stageOverrides.save(stageKey, patch) // The override changes the served label/color/filters — refetch // metadata so the board repaints, and reload the cards. try { const res = await api.get(`/metadata/table/${model}`) const body = res.data as ApiResponse if (body.success) { setMetadata(body.data) cacheMetadata(model, body.data) } } catch { /* keep the current metadata on a refetch miss */ } void fetchData() }} onResetOverride={async (stageKey) => { await stageOverrides.reset(stageKey) try { const res = await api.get(`/metadata/table/${model}`) const body = res.data as ApiResponse if (body.success) { setMetadata(body.data) cacheMetadata(model, body.data) } } catch { /* keep the current metadata on a refetch miss */ } void fetchData() }} onUpdateCustom={customStages.update} onDeleteCustom={openDeleteStage} />
) } // --------------------------------------------------------------------------- // Sheet filter row — a labeled ColumnFilterControl for the Filtros panel. // --------------------------------------------------------------------------- interface SheetFilterField { key: string label: string config: { filterType: string filterKey: string options: { label: string; value: string; icon?: string; color?: string }[] selectedValues: string[] onFilterChange: (filterKey: string, values: string[]) => void loading?: boolean searchEndpoint?: string loadOptions?: (q?: string) => Promise } } /** * A per-data-type glyph for the Filtros panel rows (and their popover header): * Hash for numbers, Calendar for dates, CircleDot for the pipeline stage, Tag * for value pickers, ToggleLeft for booleans, Type for free text. */ function filterTypeIcon(filterType: string, isStage: boolean): React.ReactNode { if (isStage) return switch (filterType) { case 'number_range': return case 'date_range': return case 'boolean': return case 'select': case 'dynamic_select': case 'facet': return default: return } } function SheetFilterRow({ field, isStage, }: { field: SheetFilterField isStage: boolean }) { const summary = summarizeFilterValues( field.config.selectedValues, field.config.options, ) return ( ) } // --------------------------------------------------------------------------- // Lane (droppable + sortable column) // --------------------------------------------------------------------------- /** * The drag-and-drop wiring a lane wrapper hands to `KanbanLane`. Card drops use * `setNodeRef`/`isOver`; the header drag (lane reorder) uses `handleRef` + * `handleProps` on the lane's title cluster and `style` (the sortable * transform). `draggable` gates whether the reorder grip + listeners render. */ interface LaneDnd { setNodeRef: (el: HTMLElement | null) => void isOver: boolean draggable: boolean isDragging?: boolean style?: React.CSSProperties handleRef?: (el: HTMLElement | null) => void handleProps?: Record } // A real stage lane: sortable (header drag reorders) AND a card drop target. // One `useSortable` provides both roles; `disabled.draggable` follows whether // lane reordering is available, `disabled.droppable` follows the per-card // transition gate. function SortableStageLane({ reorderEnabled, droppableDisabled, laneProps, }: { reorderEnabled: boolean droppableDisabled: boolean laneProps: Omit }) { const { setNodeRef, setActivatorNodeRef, attributes, listeners, transform, transition, isDragging, isOver, } = useSortable({ id: laneProps.stage.key, data: { type: 'lane' }, disabled: { draggable: !reorderEnabled, droppable: droppableDisabled }, }) const dnd: LaneDnd = { setNodeRef, isOver, isDragging, draggable: reorderEnabled, handleRef: setActivatorNodeRef, handleProps: { ...attributes, ...listeners }, style: { transform: CSS.Translate.toString(transform), transition, zIndex: isDragging ? 30 : undefined, position: isDragging ? 'relative' : undefined, }, } return } // The synthetic "Sin etapa" lane: a card drop target, never draggable. function DroppableStageLane({ droppableDisabled, laneProps, }: { droppableDisabled: boolean laneProps: Omit }) { const { setNodeRef, isOver } = useDroppable({ id: laneProps.stage.key, disabled: droppableDisabled, }) return ( ) } // A smart lane: sortable (header drag reorders) but NOT a card drop target — its // droppable is disabled while a card is dragged, enabled while a lane is dragged // (so a stage/smart lane can be reordered relative to it). function SortableSmartLane({ droppableDisabled, smartProps, }: { droppableDisabled: boolean smartProps: React.ComponentProps }) { const { setNodeRef, setActivatorNodeRef, attributes, listeners, transform, transition, isDragging, } = useSortable({ id: smartProps.stage.key, data: { type: 'lane' }, disabled: { draggable: false, droppable: droppableDisabled }, }) const dnd: LaneDnd = { setNodeRef, isOver: false, isDragging, draggable: true, handleRef: setActivatorNodeRef, handleProps: { ...attributes, ...listeners }, style: { transform: CSS.Translate.toString(transform), transition, zIndex: isDragging ? 30 : undefined, position: isDragging ? 'relative' : undefined, }, } return } interface LaneFilterField { key: string label: string config?: ColumnFilterConfigLike } /** Minimal shape the lane funnel reads off a shared filter config. */ interface ColumnFilterConfigLike { filterType?: string filterKey?: string options?: { label: string; value: string; color?: string; count?: number }[] loadOptions?: (q?: string) => Promise< { label: string; value: string; color?: string; count?: number }[] > } /** The funnel's committed value: a field + either picked `values` or free `text`. */ interface LaneFunnelValue { field: string values?: string[] text?: string } interface KanbanLaneProps { stage: StageMeta count: number totalCount: number /** Server-reported total for the stage (from response meta), or null. */ serverTotal: number | null /** More server pages available for this stage. */ hasMore: boolean /** A top-up request for this stage is in flight. */ loadingMore: boolean /** Request the next page for this stage. */ onLoadMore: () => void filterFields: LaneFilterField[] laneFilter: LaneFilterState | undefined onFunnelChange: (filter: LaneFunnelValue | null) => void onQueryChange: (query: string) => void isDark: boolean dimmed: boolean /** Drag-and-drop wiring from the lane's sortable/droppable wrapper. */ dnd: LaneDnd /** Model key + columns for the stage-automations editor. */ model: string columns: ColumnDefinition[] /** Whether the ⚡ automations affordance should render for this lane. */ automationsAvailable: boolean automationRules: StageAutomation[] onAutomationCreate: (draft: NewStageAutomation) => Promise onAutomationUpdate: ( id: StageAutomation['id'], patch: Partial, ) => Promise onAutomationRemove: (id: StageAutomation['id']) => Promise /** Extra lane conditions (stage override) — drives the header filter dot + tooltip. */ extraFilters: CustomStageFilter[] /** Whether the ⚙ "Configurar etapa" gear should render for this lane. */ configurable: boolean /** Opens the gear config dialog for this lane. */ onConfigure: () => void children: React.ReactNode } function KanbanLane({ stage, count, totalCount, serverTotal, hasMore, loadingMore, onLoadMore, filterFields, laneFilter, onFunnelChange, onQueryChange, isDark, dimmed, dnd, model, columns, automationsAvailable, automationRules, onAutomationCreate, onAutomationUpdate, onAutomationRemove, extraFilters, configurable, onConfigure, children, }: KanbanLaneProps) { const { t } = useTranslation() // Re-resolve the lane label when an addon i18n bundle lands after the board // painted (async addResourceBundle) — otherwise a manifest-key label like // "integration_github.stage.in_progress" stays raw until an unrelated // re-render. Independent of the host's react-i18next bindI18nStore config. useI18nResourceVersion() // Infinite scroll: the sentinel lives at the bottom of the lane's own scroll // container; a load in flight or an exhausted stage disables it. const { rootRef, sentinelRef } = useInfiniteScrollSentinel({ onLoadMore, disabled: !hasMore || loadingMore, }) const headerStyle = generateBadgeStyles(stage.color || optionColor(stage.key), { isDark, }) const funnelField = filterFields.find((f) => f.key === laneFilter?.field) const funnelActive = !!( laneFilter?.field && ((laneFilter.values && laneFilter.values.length > 0) || laneFilter.text?.trim()) ) const queryActive = !!laneFilter?.query?.trim() const laneActive = funnelActive || queryActive const activeFieldLabel = funnelField?.label ?? laneFilter?.field // Human summary of the funnel value: resolved option labels for picked // values, or the raw free text. const funnelSummary = laneFilter?.values && laneFilter.values.length > 0 ? summarizeFilterValues(laneFilter.values, funnelField?.config?.options) : laneFilter?.text ?? '' // Inline lane search: a Search icon expands an Input; Escape or blur-while- // empty collapses it. The query itself lives in the parent's laneFilters so // it survives collapse and combines with the funnel. const [searchOpen, setSearchOpen] = useState(queryActive) const searchRef = useRef(null) useEffect(() => { if (searchOpen) searchRef.current?.focus() }, [searchOpen]) const funnelValue: LaneFunnelValue | undefined = laneFilter?.field ? { field: laneFilter.field, values: laneFilter.values, text: laneFilter.text, } : undefined // Stage-override conditions active on this lane → a small filter dot in the // header, its `title` listing the conditions (e.g. "priority es igual high"). const hasConditions = extraFilters.length > 0 const conditionsSummary = extraFilters .map((f) => { const opLabel = t(`dynamic.custom_stages.op.${f.op}`, { defaultValue: f.op === 'eq' ? 'es igual' : f.op === 'neq' ? 'distinto' : f.op === 'contains' ? 'contiene' : 'en lista', }) return `${f.field} ${opLabel} ${f.value}` }) .join(' · ') return (
{/* Title cluster doubles as the reorder handle (Trello/Bitrix): grabbing the label drags the whole column. A subtle grip fades in on hover; the lane action buttons stay outside so they never initiate a drag. */}
{dnd.draggable && ( )} {t(stage.label, { defaultValue: stage.label })} {formatLaneCount(count, totalCount, serverTotal, laneActive)} {hasConditions && ( )}
{/* Lane actions — always visible in muted (a hidden hover-reveal was undiscoverable); active state is a primary tint + a count badge on the funnel. */}
{automationsAvailable && ( )} {configurable && ( )}
{searchOpen && (
onQueryChange(e.target.value)} onKeyDown={(e) => { if (e.key === 'Escape') { onQueryChange('') setSearchOpen(false) } }} onBlur={() => { if (!laneFilter?.query?.trim()) setSearchOpen(false) }} placeholder={t('kanban.searchLanePlaceholder', { defaultValue: 'Buscar tarjetas...', })} className="h-7 pl-7 text-xs" />
)} {funnelActive && (
{activeFieldLabel}: {funnelSummary}
)} {/* Plain vertical-scroll column, NOT a Radix ScrollArea: the ScrollArea viewport wraps its content in a `display:table` element that shrink-to-fits the WIDEST card, so once the card text wraps freely (no line-clamp) the cards grew past the lane and spilled out of the stage. A normal `overflow-y-auto` block constrains every card to the lane width so text wraps inside it. */}
{children} {loadingMore && ( )} {/* Sentinel: entering view triggers the next stage page. */} {hasMore && (
)}
) } // LaneFilterButton — the per-column funnel. Picks a field + a value and narrows // ONLY this lane's cards (client-side, in the parent). Draft state lives here so // typing doesn't refilter mid-keystroke; Apply/Enter commits, Limpiar clears. function LaneFilterButton({ fields, value, onChange, }: { fields: LaneFilterField[] value: LaneFunnelValue | undefined onChange: (filter: LaneFunnelValue | null) => void }) { const { t } = useTranslation() const [open, setOpen] = useState(false) const [field, setField] = useState(value?.field ?? fields[0]?.key ?? '') const [values, setValues] = useState(value?.values ?? []) const [text, setText] = useState(value?.text ?? '') // Re-seed the draft from the committed filter each time the popover opens. useEffect(() => { if (open) { setField(value?.field ?? fields[0]?.key ?? '') setValues(value?.values ?? []) setText(value?.text ?? '') } }, [open, value, fields]) if (fields.length === 0) return null const active = !!( value && ((value.values && value.values.length > 0) || value.text?.trim()) ) // Number of applied criteria on this lane's funnel (drives the count badge). const activeCount = laneFunnelCount(value) // The value step mirrors the sheet: when the chosen field is a select or a // facet (static options OR a lazy loader), render the SAME pro combobox — // multi-select, searchable, with counts. Only a genuinely free-text field // (no options, no loader) falls back to a raw "Contiene..." input. const cfg = fields.find((f) => f.key === field)?.config const hasValuePicker = (cfg?.options?.length ?? 0) > 0 || !!cfg?.loadOptions const toggle = (v: string) => setValues((prev) => prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v], ) const apply = () => { if (field && values.length > 0) onChange({ field, values }) else if (field && text.trim()) onChange({ field, text: text.trim() }) else onChange(null) setOpen(false) } const clear = () => { onChange(null) setOpen(false) } return ( {hasValuePicker ? ( // `key={field}` remounts the combobox on field switch so it // reloads that field's values from scratch (no stale list).
) : ( setText(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') apply() }} placeholder={t('kanban.filterValue', { defaultValue: 'Contiene...', })} className="h-8 w-full text-xs" /> )}
) } // --------------------------------------------------------------------------- // Card (draggable) // --------------------------------------------------------------------------- interface KanbanCardProps { card: any titleCol: ColumnDefinition | null fieldCols: ColumnDefinition[] actions: ActionDefinition[] locale: string timeZone?: string currency?: string onClick?: (row: any) => void /** STRING contract — dispatch the action by its key (see useDynamicRowActions). */ onAction: (actionKey: string, record: any) => void /** When false the card is static (no drag) — used by read-only smart lanes. */ draggable?: boolean } function KanbanCard({ card, titleCol, fieldCols, actions, locale, timeZone, currency, onClick, onAction, draggable = true, }: KanbanCardProps) { const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: String(card.id), data: { type: 'card' }, disabled: !draggable, }) const visibleActions = actions.filter((a) => isRowActionVisible(a, card)) return ( onClick?.(card)} data-card-id={String(card.id)} >
{titleCol ? ( ) : ( {String(card.id)} )}
{visibleActions.length > 0 && ( e.stopPropagation()}> {visibleActions.map((a) => ( { e.stopPropagation() onAction(a.key, card) }} > {a.label} ))} )}
{fieldCols.map((col) => (
{col.label}:
))}
) } // Static preview rendered inside the DragOverlay (no dnd hooks, no menu). function CardPreview({ card, titleCol, fieldCols, locale, timeZone, currency, }: Omit) { return (
{titleCol ? ( ) : ( String(card.id) )}
{fieldCols.map((col) => (
{col.label}:
))}
) }