// 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, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from 'react' import { useTranslation } from 'react-i18next' import { useTimeZone, useCurrency, useNumberLocale } from './org-runtime-context' import { QueryClient, QueryClientContext, QueryClientProvider, useQueryClient, } from '@tanstack/react-query' import { useI18nResourceVersion } from './use-i18n-resource-version' import { DndContext, KeyboardSensor, PointerSensor, closestCenter, defaultDropAnimationSideEffects, pointerWithin, rectIntersection, useSensor, useSensors, useDraggable, useDroppable, type Announcements, type CollisionDetection, type DragStartEvent, type DragEndEvent, type DragOverEvent, type DropAnimation, type KeyboardCoordinateGetter, } from '@dnd-kit/core' import { SortableContext, horizontalListSortingStrategy, sortableKeyboardCoordinates, 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, Input, Popover, PopoverContent, PopoverTrigger, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, Skeleton, } from '@asteby/metacore-ui/primitives' // The card "…" menu renders RowActionMenuItem (dynamic-columns), whose // DropdownMenuItem comes from the ROOT '@asteby/metacore-ui' entry. The menu // shell MUST come from that same entry: hosts share the root entry as a Module // Federation singleton while '/primitives' is bundled locally, so mixing them // yields two Radix Menu contexts and the click crashes the app with // "`MenuItem` must be used within `Menu`" (Pitsline taller kanban, 2026-10-05). import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@asteby/metacore-ui' 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 { useOptimisticMutation } from './use-optimistic-mutation' import { useFlipAnimation } from './use-flip-animation' import { PortalDragOverlay } from './portal-drag-overlay' import { objectLabel } from './dynamic-relation-helpers' import { useMetadataCache } from './metadata-cache' import { useRecordMutationTick } from './use-record-mutation-tick' import { useOptionsResolver } from './use-options-resolver' import { ActivityValueRenderer } from './activity-value-renderer' import { DynamicIcon } from './dynamic-icon' import { isColumnVisibleInTable } from './column-visibility' import type { RowActionPredicate } from './dynamic-columns-shim' import { aggregateOf, formatAggregateTotal, isRowActionVisible, lifecycleStageField, relationKeyFor, RowActionMenuItem, } from './dynamic-columns' import { useCan, usePermissionsActive, useRoleGate, 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` — the same rule as the kernel's * `StageMachine.Allows`, so a lane the board offers never ends in a 422: * * - no `transitions` in the metadata → no stage machine, any move; * - a move to the same stage is a no-op, always allowed; * - a card with no stage yet — or one outside `stageKeys` (a value written * before the machine existed) — is being placed, not moved: any stage takes it; * - otherwise the move must be declared. An EMPTY list means the machine allows * no moves between its stages. * * `'*'` is a wildcard on either side. */ export function isTransitionAllowed( transitions: StageTransition[] | undefined, from: string, to: string, stageKeys?: ReadonlySet, ): boolean { if (from === to) return true if (!transitions) return true if (from === '' || (stageKeys && !stageKeys.has(from))) { return !stageKeys || stageKeys.has(to) } 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) } /** Reads a boolean-ish opt-in from a column's `styleConfig` (`display_config`). */ function cardFlag(col: ColumnDefinition, key: string): boolean { const v = (col.styleConfig as Record | undefined)?.[key] return v === true || v === 'true' || v === 1 } /** * Picks the columns shown on a card: a `title` column and the secondary fields. * Excludes the group_by column (it's the lane itself) and any column hidden * from the table view (visibility modal/list, or `hidden`). * * A model can lay its card out explicitly through the columns' manifest * `display_config`: `card_title: true` picks the title, `card: true` marks each * field the card shows, in column order (`explicit` is then true and ALL the * flagged fields are shown — the card is no longer capped at `maxFields`). * Without any flag the legacy heuristic applies: the first searchable / text * column is the title and the next `maxFields` visible columns are the fields. */ export function selectCardColumns( metadata: TableMetadata, maxFields = 3, ): { title: ColumnDefinition | null; fields: ColumnDefinition[]; explicit: boolean } { const groupBy = metadata.group_by const visible = metadata.columns.filter( (c) => c.key !== groupBy && !c.hidden && isColumnVisibleInTable(c) && c.key !== 'id', ) const flagged = visible.filter((c) => cardFlag(c, 'card')) const flaggedTitle = visible.find((c) => cardFlag(c, 'card_title')) const explicit = flagged.length > 0 || !!flaggedTitle const title = flaggedTitle ?? visible.find((c) => c.searchable) ?? visible.find((c) => c.type === 'text' || c.cellStyle === 'truncate-text') ?? visible[0] ?? null const fields = explicit ? flagged.filter((c) => c.key !== title?.key) : visible.filter((c) => c.key !== title?.key).slice(0, maxFields) return { title, fields, explicit } } /** * Whether a card's date field is past due — the SDK paints it as a warning. * A column opts in with `display_config.overdue: true`; the optional * `display_config.overdue_unless` (`{ : [values…] }`) exempts cards whose * row matches (a work order already delivered is never late). Pure — exported * for unit tests. */ export function isCardOverdue(card: any, col: ColumnDefinition, now: number = Date.now()): boolean { if (!cardFlag(col, 'overdue')) return false const raw = card?.[col.key] if (raw === null || raw === undefined || raw === '') return false const at = typeof raw === 'number' ? raw : Date.parse(String(raw)) if (!Number.isFinite(at) || at >= now) return false const unless = (col.styleConfig as Record | undefined)?.overdue_unless if (unless && typeof unless === 'object') { for (const [field, values] of Object.entries(unless as Record)) { const list = Array.isArray(values) ? values : [values] if (list.map(String).includes(String(card?.[field] ?? ''))) return false } } return true } /** * The metadata regrouped by another column (a nav entry's `group_by`). A model * can serve several boards over the same records — by stage, by technician, by * bay — but the host projects ONE `group_by` per model, so the entry's own wins. * The stage machine (stages, transitions, smart lanes) belongs to the model's * stage column (`stage_field`; older hosts that do not serve it: the served * group_by): grouping by it keeps the machine, grouping by anything else drops * it and the lanes come from the new column. * * The served group_by is the LAST kanban nav entry's, which need not be the * stage column: a board opened without `?group_by=` (the list's view toggle) * groups by the stage column, and a board grouped by the served column keeps * the machine only when that column IS the stage column. Otherwise the stage * lanes were painted over another column (by bay: every card under «Sin etapa» * and a drop wrote `service_bay_id = 'diagnosis'`, 422). Returns the SAME * object when there is nothing to change. Pure — exported for unit tests. */ export function withGroupBy( metadata: TableMetadata | null, groupBy: string | undefined, ): TableMetadata | null { if (!metadata) return metadata const hasMachine = (metadata.stages?.length ?? 0) > 0 const stageColumn = metadata.stage_field ?? (hasMachine ? metadata.group_by : undefined) if (!groupBy) { if (!hasMachine || !stageColumn || stageColumn === metadata.group_by) return metadata return { ...metadata, group_by: stageColumn } } if (groupBy === stageColumn) { return groupBy === metadata.group_by ? metadata : { ...metadata, group_by: groupBy } } if (!metadata.columns.some((c) => c.key === groupBy)) return metadata if ( groupBy === metadata.group_by && !hasMachine && !metadata.transitions && !metadata.smart_lanes ) { return metadata } return { ...metadata, group_by: groupBy, stages: undefined, transitions: undefined, smart_lanes: undefined, } } /** * Whether the board's lanes must come from the group_by column's REFERENCE * (one lane per technician, per bay, per owner…): the metadata declares no * stages, the column has no inline options, but it points at a record list. * Pure — exported for unit tests. */ export function refLaneSource( metadata: TableMetadata | null, ): { ref?: string; endpoint?: string } | null { if (!metadata?.group_by) return null if ((metadata.stages?.length ?? 0) > 0) return null const col = metadata.columns.find((c) => c.key === metadata.group_by) if (!col || (col.options?.length ?? 0) > 0) return null if (col.searchEndpoint) return { endpoint: col.searchEndpoint } if (col.ref) return { ref: col.ref } return null } /** * The metadata with one lane per resolved option of a reference group_by column * (labels ordered alphabetically). No transitions are declared, so a card can * be dragged to any lane — i.e. re-assigned to another technician / bay. * Returns the metadata untouched when there are no options. Pure. */ export function withRefLanes( metadata: TableMetadata, options: { value: string | number; label: string; color?: string | null }[], ): TableMetadata { if (options.length === 0) return metadata const stages: StageMeta[] = [...options] .sort((a, b) => String(a.label).localeCompare(String(b.label))) .map((o, i) => ({ key: String(o.value), label: String(o.label), color: o.color ?? undefined, order: i, })) return { ...metadata, stages } } /** 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 } /** * Plain text of a card cell — what a person reads on the card. A resolved * relation reads as its label/name (never the raw FK uuid), so the lane search * and the drag announcements match what is on screen. Pure — exported for tests. */ export function cardCellText(card: any, col: ColumnDefinition): string { const v = cardCellValue(card, col) if (v === null || v === undefined) return '' if (typeof v === 'object') { if (Array.isArray(v)) { return v .map((x) => (typeof x === 'object' ? objectLabel(x) ?? '' : String(x))) .filter(Boolean) .join(', ') } return objectLabel(v) ?? '' } return String(v) } const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i /** * Whether a card field has nothing worth a row: unset, blank, an empty list, * `false`, or an opaque id that no relation resolved to a name (a soft-ref * uuid column). The card hides those rows instead of painting "Campo: —". * Pure — exported for unit tests. */ export function isEmptyCardValue(card: any, col: ColumnDefinition): boolean { const v = cardCellValue(card, col) if (v === null || v === undefined || v === false) return true if (typeof v === 'string') { const s = v.trim() return s === '' || UUID_RE.test(s) } if (Array.isArray(v)) return v.length === 0 if (typeof v === 'object') return Object.keys(v).length === 0 return false } /** * The fields a card shows: the first `max` candidates that carry a value for * THIS card. Candidates come in metadata order, so a card with an empty * "Asignado a" shows the next useful field instead of a dash. Pure. */ export function visibleCardFields( card: any, candidates: ColumnDefinition[], max = 3, ): ColumnDefinition[] { const out: ColumnDefinition[] = [] for (const col of candidates) { if (out.length >= max) break if (!isEmptyCardValue(card, col)) out.push(col) } return out } /** * Records as the board paints them: each card with an unconfirmed move shows in * its destination lane. The fetched records stay as the server sent them, so a * rejected move just drops its override and the card is back where it was. * Pure — exported for unit tests. */ export function applyStageOverrides( records: any[], overrides: Record | undefined, groupByKey: string, ): any[] { if (!overrides || Object.keys(overrides).length === 0) return records return records.map((r) => { const to = overrides[String(r.id)] if (to === undefined || String(r?.[groupByKey] ?? '') === to) return r return { ...r, [groupByKey]: to } }) } /** Per-lane change an in-flight move makes to the server totals. */ export interface LaneDelta { count: number sums: Record } function numericValue(v: unknown): number | null { if (v === null || v === undefined || v === '') return null const n = typeof v === 'number' ? v : Number(v) return Number.isFinite(n) ? n : null } /** * How the unconfirmed moves shift each lane's server count and aggregate sums: * the source lane loses the card (and its values), the destination gains them. * Lets the header totals follow the drag live and snap back on a rollback * without touching the server figures. Pure — exported for unit tests. */ export function laneDeltasFromOverrides( records: any[], overrides: Record | undefined, groupByKey: string, aggregateKeys: string[] = [], ): Record { const out: Record = {} if (!overrides) return out const bump = (stage: string, card: any, sign: 1 | -1) => { const d = (out[stage] ??= { count: 0, sums: {} }) d.count += sign for (const key of aggregateKeys) { const n = numericValue(card?.[key]) if (n !== null) d.sums[key] = (d.sums[key] ?? 0) + sign * n } } for (const r of records) { const to = overrides[String(r.id)] if (to === undefined) continue const from = String(r?.[groupByKey] ?? '') if (from === to) continue bump(from, r, -1) bump(to, r, 1) } return out } /** * Returns a NEW per-lane aggregate map with a card's numeric values moved from * `fromStage` to `toStage`. Lanes with no known sums yet are left alone. Pure. */ export function applyLaneAggregatesOnMove( aggregates: Record>, card: any, fromStage: string, toStage: string, aggregateKeys: string[], ): Record> { const next = { ...aggregates } const shift = (stage: string, sign: 1 | -1) => { const cur = next[stage] if (!cur) return const updated = { ...cur } for (const key of aggregateKeys) { const n = numericValue(card?.[key]) if (n !== null) updated[key] = (numericValue(updated[key]) ?? 0) + sign * n } next[stage] = updated } shift(fromStage, -1) shift(toStage, 1) return next } /** A lane's on-screen box, as the keyboard navigation reads it. */ export interface LaneRect { id: string left: number top: number width: number } /** * Keyboard drag: where the dragged card goes when the user presses ← or →. * Picks the nearest lane to the left/right of the card's current center and * returns the top-left coordinates that center the card in that lane, just * under its header. Undefined when there is no lane further in that direction. * Pure — exported for unit tests. */ export function nextLaneCoordinates( direction: 'left' | 'right', current: { left: number; top: number; width: number }, lanes: LaneRect[], ): { x: number; y: number } | undefined { const center = current.left + current.width / 2 const sorted = [...lanes].sort((a, b) => a.left - b.left) const target = direction === 'right' ? sorted.find((l) => l.left > center) : [...sorted].reverse().find((l) => l.left + l.width < center) if (!target) return undefined return { x: target.left + (target.width - current.width) / 2, y: Math.max(current.top, target.top + 56), } } /** * 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) => { // Match what the card shows (a relation's name) and the raw value. const shown = cardCellText(card, c).toLowerCase() if (shown.includes(q)) return true const raw = card?.[c.key] return typeof raw !== 'object' && String(raw ?? '').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 /** * Consumer-side per-row gate for row actions, AND-ed with the metadata * gates (`requiresState` + `condition`): it can only hide more. Receives the * full action definition (adapt an `(actionKey, row)` predicate with * `(a, row) => legacy(a.key, row)`). A throw hides the action (fail-closed, * `console.error`). Memoize it (module-level fn or `useCallback`). * Omitted → behaviour unchanged. Not a substitute for backend authorization. */ isRowActionVisible?: RowActionPredicate /** * 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 /** * Column to group the board by, when the host's nav entry asks for one * (`?group_by=`): a model can offer several boards over the same records — * by stage, by technician, by bay. Absent or equal to the served * `group_by` → the model's own stage machine. Otherwise the lanes come from * that column (its options, or its referenced records) with no transitions, * and the per-org stage customisations (custom lanes, overrides, lane order, * automations) are off: they belong to the stage column. */ groupBy?: string /** * Static equality filters always applied to the board (never shown as a * removable chip). Same contract as DynamicTable's `defaultFilters`. */ defaultFilters?: Record } /** * The optimistic card move runs on TanStack Query (useOptimisticMutation). A * host that mounts no QueryClientProvider still gets a working board: the * kanban brings its own client in that case. */ export function DynamicKanban(props: DynamicKanbanProps) { const hostClient = useContext(QueryClientContext) const [ownClient] = useState(() => hostClient ? null : new QueryClient({ defaultOptions: { mutations: { retry: false } } }), ) if (!hostClient && ownClient) { return ( ) } return } /** How long the drop animation takes to land the card in its lane. */ const DROP_ANIMATION: DropAnimation = { duration: 220, easing: 'cubic-bezier(0.2, 0, 0, 1)', sideEffects: defaultDropAnimationSideEffects({ styles: { active: { opacity: '0' } }, }), } /** Card fields a card may pick from; it shows the first few with a value. */ const CARD_FIELD_CANDIDATES = 8 const CARD_FIELDS_SHOWN = 3 /** No card move in flight (a stable empty snapshot). */ const NO_MOVES: Record = Object.freeze({}) as Record /** Server figures for one lane: its aggregate sums, keyed by column. */ type LaneAggregates = Record> /** A card move in flight: which card, and the lanes it leaves and enters. */ interface CardMove { cardId: string from: string to: string } function DynamicKanbanBoard({ model, endpoint, refreshTrigger, realtime: realtimeProp, onCardClick, onAction, isRowActionVisible: rowActionPredicate, pageSize = 50, lanePageSize = 25, timeZone: timeZoneProp, currency: currencyProp, defaultFilters, groupBy: groupByProp, }: DynamicKanbanProps) { const { t, i18n } = useTranslation() const numberLocale = useNumberLocale(i18n.language) // The org's timezone/currency: an explicit prop wins, else the app-wide // OrgRuntimeProvider (without it, money fell back to USD). const orgTimeZone = useTimeZone() const orgCurrency = useCurrency() const timeZone = timeZoneProp ?? orgTimeZone const currency = currencyProp ?? orgCurrency 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 mutationTick = useRecordMutationTick(model) 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 automationsHook = 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 customStagesHook = 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 stageOverridesHook = useStageOverrides(model) const stageLayoutHook = useStageLayout(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 [serverMetadata, setMetadata] = useState(cachedMeta || null) // A reference group_by (technician, bay…) has no declared stages: its lanes // are the referenced records, resolved through the same options endpoint the // form selects use. const routedMetadata = useMemo( () => withGroupBy(serverMetadata, groupByProp), [serverMetadata, groupByProp], ) // Grouped by something other than the stage column: the stage machine is gone. const regrouped = (serverMetadata?.stages?.length ?? 0) > 0 && (routedMetadata?.stages?.length ?? 0) === 0 const laneSource = useMemo(() => refLaneSource(routedMetadata), [routedMetadata]) const { options: laneOptions } = useOptionsResolver({ modelKey: '', fieldKey: 'id', ref: laneSource?.ref, endpoint: laneSource?.endpoint, limit: 200, enabled: !!laneSource, }) const metadata = useMemo( () => (routedMetadata && laneSource ? withRefLanes(routedMetadata, laneOptions) : routedMetadata), [routedMetadata, laneSource, laneOptions], ) // The stage customisations belong to the stage column: off on another grouping. const automations = useMemo( () => (regrouped ? { ...automationsHook, available: false } : automationsHook), [regrouped, automationsHook], ) const customStages = useMemo( () => (regrouped ? { ...customStagesHook, available: false } : customStagesHook), [regrouped, customStagesHook], ) const stageOverrides = useMemo( () => (regrouped ? { ...stageOverridesHook, available: false } : stageOverridesHook), [regrouped, stageOverridesHook], ) const stageLayout = useMemo( () => (regrouped ? { ...stageLayoutHook, available: false } : stageLayoutHook), [regrouped, stageLayoutHook], ) 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) // Lane under the dragged card — drives the "allowed / not allowed" feedback // (ring on the lane, not-allowed cursor on the lifted card). const [overLaneId, setOverLaneId] = 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 [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) // Keyboard drag (a11y): Space/Enter lifts a focused card, ←/→ jump to the // neighbouring lane, Space/Enter drops, Escape cancels. A lane header drag // keeps the sortable's own keyboard coordinates. const keyboardCoordinates = useCallback((event, args) => { const { context } = args if (context.active?.data.current?.type === 'lane') { return sortableKeyboardCoordinates(event, args) } const direction = event.code === 'ArrowRight' ? 'right' : event.code === 'ArrowLeft' ? 'left' : null if (!direction) return undefined event.preventDefault() const rect = context.collisionRect if (!rect) return undefined const lanes: LaneRect[] = [] for (const c of context.droppableContainers.getEnabled()) { const r = context.droppableRects.get(c.id) if (r) lanes.push({ id: String(c.id), left: r.left, top: r.top, width: r.width }) } return nextLaneCoordinates(direction, rect, lanes) }, []) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: keyboardCoordinates }), ) // A card lands in the lane under the pointer (a column is a tall target, so // "where the pointer is" beats "what the card overlaps"); keyboard drags have // no pointer and fall back to overlap. A lane header drag snaps to the // nearest column. const collisionDetection = useCallback((args) => { if (args.active.data.current?.type === 'lane') return closestCenter(args) const hits = pointerWithin(args) return hits.length > 0 ? hits : rectIntersection(args) }, []) // ---- 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 }) 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 + lane-summary 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]) // Columns the model declares a total for (`display_config.aggregate` — the // same flag the table footer reads). Each lane header shows their sum over // the lane, from the same `/aggregate` endpoint the footer uses. const aggregateCols = useMemo( () => (metadata?.columns ?? []).filter((c) => aggregateOf(c)), [metadata], ) const aggregateKeys = useMemo(() => aggregateCols.map((c) => c.key), [aggregateCols]) const [laneAggregates, setLaneAggregates] = useState({}) // Asks the server for one lane's figures, scoped by the active filters and // the lane's own conditions: the real card count (a `per_page=1` list read, // only `meta.total` is used) and, when the model declares totals, the lane's // sums. `overwrite` replaces a total already known — used after a move, when // the server is the one to settle what an automation may have changed. const probeLane = useCallback( async (stageKey: string, gen: number, overwrite: boolean) => { const gb = metadata?.group_by if (!gb) return const base = endpoint || `/data/${model}` const scope = { ...filterParams, ...smartLaneParams(stageExtraFilters.get(stageKey)), } const count = (async () => { try { const r = (await api.get(base, { params: { ...scope, page: 1, per_page: 1, [`f_${gb}`]: stageKey }, })) 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[stageKey] // A real page fetch already learned this lane's total. if (!overwrite && existing?.total != null) return p return { ...p, [stageKey]: { nextPage: existing?.nextPage ?? 1, total, loading: existing?.loading ?? false, done: existing?.done ?? total === 0, }, } }) } catch { // A failed total just leaves the header on the loaded count. } })() const sums = (async () => { if (aggregateKeys.length === 0) return try { const r = (await api.get(`${base}/aggregate`, { params: { ...scope, [`f_${gb}`]: stageKey }, })) as { data: ApiResponse> } const data = r.data?.success ? r.data.data : null if (!data || typeof data !== 'object' || Array.isArray(data)) return if (gen !== fetchGenRef.current) return const row: Record = {} for (const key of aggregateKeys) { const n = Number(data[key]) row[key] = Number.isFinite(n) ? n : 0 } setLaneAggregates((prev) => ({ ...prev, [stageKey]: row })) } catch { // No sums for this lane: the header just shows the count. } })() await Promise.all([count, sums]) }, [api, endpoint, model, metadata?.group_by, filterParams, stageExtraFilters, aggregateKeys], ) // ---- 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({}) setLaneAggregates({}) // Eager per-lane figures: so every lane header shows the REAL stage count // (and its declared sums) on first render — not just what the global page // happened to load — probe each declared stage in parallel, scoped by the // SAME active filters/search. The "unassigned" lane can't be // stage-scoped, so it keeps its loaded count. const declared = deriveStages(metadata) if (metadata.group_by && declared.length > 0) { void Promise.all(declared.map((stage) => probeLane(stage.key, gen, false))) } }, [api, endpoint, model, metadata, pageSize, filterParams, probeLane]) // Re-reads the server figures of the lanes a move touched. const refreshLanes = useCallback( (stageKeys: string[]) => { const gen = fetchGenRef.current for (const key of new Set(stageKeys)) { if (key && key !== UNASSIGNED_LANE) void probeLane(key, gen, true) } }, [probeLane], ) // 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). // // The page is derived from how many of the stage's cards are loaded, not // from a counter: once a card leaves the lane the server's pages shift by // one, and a counter would skip the card that slid into the gap. Reading // from the loaded count re-requests that page instead (dedupe drops the // repeats). const recordsRef = useRef(records) recordsRef.current = records const loadMoreLane = useCallback( async (stageKey: string) => { if (!metadata || !groupByKey) return const current = lanePagination[stageKey] if (current?.loading || current?.done) return const loaded = recordsRef.current.filter( (r) => String(r?.[groupByKey] ?? '') === stageKey, ).length const nextPage = Math.floor(loaded / lanePageSize) + 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, mutationTick]) // 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], ) // ---- optimistic card moves ---- // Unconfirmed moves live in their own small cache entry (`cardId → lane`), // layered over the fetched records at paint time. The records themselves // only change once the server confirms, so a rollback is just dropping the // override — it can never clobber cards a lane loaded meanwhile. const qc = useQueryClient() const boardInstance = useId() const movesKey = useMemo( () => ['metacore', 'kanban-moves', model, boardInstance] as const, [model, boardInstance], ) // Read synchronously (not through useQuery, whose notifications land a tick // later): the override must paint in the SAME commit as the drop, so the // drag overlay's drop animation flies to the card's new lane, not its old one. const moveOverrides = useSyncExternalStore( useCallback((onChange: () => void) => qc.getQueryCache().subscribe(onChange), [qc]), () => qc.getQueryData>(movesKey) ?? NO_MOVES, () => NO_MOVES, ) useEffect(() => () => qc.removeQueries({ queryKey: movesKey, exact: true }), [qc, movesKey]) const displayRecords = useMemo( () => applyStageOverrides(records, moveOverrides, groupByKey), [records, moveOverrides, groupByKey], ) // How the moves in flight shift each lane's server count and sums. const laneDeltas = useMemo( () => laneDeltasFromOverrides(records, moveOverrides, groupByKey, aggregateKeys), [records, moveOverrides, groupByKey, aggregateKeys], ) const grouped = useMemo( () => groupByStage(displayRecords, groupByKey, stages), [displayRecords, groupByKey, stages], ) // Card fields: a few more candidates than a card shows, so a card whose // first fields are empty shows the next ones that have a value. const { title: titleCol, fields: fieldCols, explicit: explicitCard, } = useMemo( () => metadata ? selectCardColumns(metadata, CARD_FIELD_CANDIDATES) : { title: null, fields: [], explicit: false }, [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 roleGate = useRoleGate() const rowActions = useMemo( () => metadata ? resolveRowActions( metadata, model, can, permissionsActive, (k, fb) => t(k, { defaultValue: fb }), roleGate, ) : [], [metadata, model, can, permissionsActive, t, roleGate], ) // 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 displayRecords) m.set(String(r.id), r) return m }, [displayRecords]) const stageOfCard = useCallback( (id: string): string => { const card = cardById.get(id) const raw = card?.[groupByKey] return raw === null || raw === undefined ? '' : String(raw) }, [cardById, groupByKey], ) // FLIP: cards glide to their new place when a move lands or rolls back. // The card just dropped is skipped — the drag overlay already carries it. const boardRef = useRef(null) const droppedCardRef = useRef(null) const flipShouldAnimate = useCallback( (_el: HTMLElement, key: string) => key !== droppedCardRef.current, [], ) useFlipAnimation(boardRef, displayRecords, { shouldAnimate: flipShouldAnimate }) // The skip covers only the drop's own paint: a rollback later must glide the // card back. (Runs after the FLIP effect above — same component, in order.) useLayoutEffect(() => { droppedCardRef.current = null }, [displayRecords]) const cardText = useCallback( (card: any): string => (titleCol ? cardCellText(card, titleCol) : '') || String(card?.id ?? ''), [titleCol], ) const laneLabel = useCallback( (key: string): string => { if (key === UNASSIGNED_LANE) return t('kanban.unassigned', { defaultValue: 'Sin etapa' }) const st = stages.find((x) => x.key === key) return st ? t(st.label, { defaultValue: st.label }) : key }, [stages, t], ) const base = endpoint || `/data/${model}` const moveCard = useOptimisticMutation>({ queryKey: movesKey, mutationFn: async (move) => { try { // `base` is the org-scoped list endpoint (e.g. `/data//me`), // so the per-record update is just `/` — same convention // as DynamicTable/DynamicRelation. const res = (await api.put(`${base}/${move.cardId}`, { [groupByKey]: move.to, })) as { data?: ApiResponse } if (res?.data && res.data.success === false) { throw Object.assign(new Error(res.data.message || 'update_failed'), { serverMessage: res.data.message, }) } // Confirmed: the record takes the new lane (plus whatever the server // wrote with it — a transition's `set`, an automation), and the // lane figures follow in the same paint as the override going away. const saved = res?.data?.data const card = recordsRef.current.find((r) => String(r.id) === move.cardId) setRecords((rs) => rs.map((r) => String(r.id) === move.cardId ? { ...r, ...(saved && typeof saved === 'object' && !Array.isArray(saved) ? saved : {}), [groupByKey]: move.to, } : r, ), ) setLanePagination((p) => applyLaneTotalsOnMove(p, move.from, move.to)) if (card) { setLaneAggregates((a) => applyLaneAggregatesOnMove(a, card, move.from, move.to, aggregateKeys), ) } return saved } catch (err: any) { // Rejected: drop this card's override so it glides back to its lane. // Done here (not in onError) so a rejected move that a newer drag // superseded still returns home and still tells the user why. qc.setQueryData>(movesKey, (cur) => { if (!cur || cur[move.cardId] !== move.to) return cur const { [move.cardId]: _dropped, ...rest } = cur return rest }) const reason = err?.response?.data?.message ?? err?.serverMessage toast.error( t('kanban.moveFailed', { defaultValue: 'No se pudo mover la tarjeta', }) + (reason ? `: ${reason}` : ''), ) throw err } }, optimistic: (current, move) => ({ ...(current ?? {}), [move.cardId]: move.to }), // The record already carries the confirmed lane: the override goes away. reconcile: (_data, move, current) => { if (!current || !(move.cardId in current)) return current ?? {} const { [move.cardId]: _confirmed, ...rest } = current return rest }, isEqual: (a, b) => a.cardId === b.cardId && a.to === b.to, onSuccess: (_data, move) => refreshLanes([move.from, move.to]), }) const onDragStart = useCallback( (e: DragStartEvent) => { if (e.active.data.current?.type === 'lane') { setActiveLaneId(String(e.active.id)) } else { droppedCardRef.current = null setActiveId(String(e.active.id)) } }, [], ) const onDragOver = useCallback((e: DragOverEvent) => { setOverLaneId(e.over ? String(e.over.id) : null) }, []) const onDragCancel = useCallback(() => { setActiveId(null) setActiveLaneId(null) setOverLaneId(null) }, []) // 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) setOverLaneId(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) // Only a real stage takes a card: never a smart lane (a saved view) // nor the synthetic "Sin etapa" lane (not a value the record can hold). if (!realStageKeys.has(destStage)) return const srcStage = stageOfCard(cardId) if (srcStage === destStage) return // A lane the stage machine doesn't reach was shown as "no permitido" // the whole drag; dropping there just sends the card home. if (!isTransitionAllowed(transitions, srcStage, destStage, realStageKeys)) return droppedCardRef.current = cardId moveCard.mutate({ cardId, from: srcStage, to: destStage }) }, [moveCard, realStageKeys, stageOfCard, transitions, reorderLanes], ) // Screen-reader narration of a card drag (dnd-kit reads these aloud). const announcements = useMemo(() => { const cardName = (id: string | number) => { const card = cardById.get(String(id)) return card ? cardText(card) : String(id) } const isCard = (a: { data: { current?: Record } }) => a.data.current?.type !== 'lane' return { onDragStart: ({ active }) => isCard(active) ? t('kanban.a11y.picked', { defaultValue: 'Tarjeta {{card}} levantada, en {{lane}}.', card: cardName(active.id), lane: laneLabel(stageOfCard(String(active.id))), }) : t('kanban.a11y.lanePicked', { defaultValue: 'Columna {{lane}} levantada.', lane: laneLabel(String(active.id)), }), onDragOver: ({ active, over }) => { if (!over) return undefined if (!isCard(active)) { return t('kanban.a11y.laneOver', { defaultValue: 'Sobre la columna {{lane}}.', lane: laneLabel(String(over.id)), }) } const from = stageOfCard(String(active.id)) const to = String(over.id) return realStageKeys.has(to) && isTransitionAllowed(transitions, from, to, realStageKeys) ? t('kanban.a11y.over', { defaultValue: 'Sobre {{lane}}.', lane: laneLabel(to), }) : t('kanban.a11y.overBlocked', { defaultValue: '{{lane}}: no se puede mover aquí.', lane: laneLabel(to), }) }, onDragEnd: ({ active, over }) => { if (!isCard(active)) { return t('kanban.a11y.laneDropped', { defaultValue: 'Columna {{lane}} soltada.', lane: laneLabel(String(active.id)), }) } const from = stageOfCard(String(active.id)) const to = over ? String(over.id) : '' if (!over || to === from) { return t('kanban.a11y.droppedHome', { defaultValue: 'Tarjeta {{card}} se queda en {{lane}}.', card: cardName(active.id), lane: laneLabel(from), }) } return realStageKeys.has(to) && isTransitionAllowed(transitions, from, to, realStageKeys) ? t('kanban.a11y.dropped', { defaultValue: 'Tarjeta {{card}} movida a {{lane}}.', card: cardName(active.id), lane: laneLabel(to), }) : t('kanban.a11y.droppedBlocked', { defaultValue: 'No se puede mover de {{from}} a {{lane}}. La tarjeta vuelve a {{from}}.', from: laneLabel(from), lane: laneLabel(to), }) }, onDragCancel: ({ active }) => t('kanban.a11y.cancelled', { defaultValue: 'Movimiento cancelado. {{card}} vuelve a su lugar.', card: isCard(active) ? cardName(active.id) : laneLabel(String(active.id)), }), } }, [cardById, cardText, laneLabel, realStageKeys, stageOfCard, t, transitions]) const screenReaderInstructions = useMemo( () => ({ draggable: t('kanban.a11y.instructions', { defaultValue: 'Para mover una tarjeta, presiona Espacio o Enter. Usa las flechas izquierda y derecha para cambiar de columna, Espacio o Enter para soltarla y Escape para cancelar.', }), }), [t], ) // 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 || (realStageKeys.has(stageKey) && isTransitionAllowed(transitions, activeStage, stageKey, realStageKeys)) // The lifted card is over a lane that won't take it → not-allowed cursor. const overBlocked = !!activeId && !!overLaneId && !droppableAllowedFor(overLaneId) // 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 // Server figures, shifted by the moves still in flight. const delta = laneDeltas[stage.key] const serverTotal = laneState?.total != null ? Math.max(0, laneState.total + (delta?.count ?? 0)) : null const laneSums = laneAggregates[stage.key] const aggregates = laneSums && aggregateCols.length > 0 ? aggregateCols.map((col) => ({ key: col.key, label: t(col.label, { defaultValue: col.label }), value: formatAggregateTotal( col, (laneSums[col.key] ?? 0) + (delta?.sums[col.key] ?? 0), currency, numberLocale, ), })) : [] // Nothing left to fetch once every card the server counts is loaded // (a lane the first page already filled needs no top-up request). const laneHasMore = !isUnassigned && !laneState?.done && !(serverTotal != null && allCards.length >= serverTotal) const blocked = !!activeId && !droppableAllowedFor(stage.key) return { stage, count: cards.length, totalCount: allCards.length, serverTotal, aggregates, 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: blocked, blockedOver: blocked && overLaneId === 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 && ( )}
{/* In : a glass panel around the board would otherwise shift the fixed overlay away from the pointer (see PortalDragOverlay). */} {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 /** The lane's declared totals (sum of a money/number column), formatted. */ aggregates: { key: string; label: string; value: string }[] /** 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 /** A card is being dragged and this lane can't take it. */ dimmed: boolean /** …and the card is right over this lane. */ blockedOver: 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, aggregates, hasMore, loadingMore, onLoadMore, filterFields, laneFilter, onFunnelChange, onQueryChange, isDark, dimmed, blockedOver, 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 && ( )}
{(aggregates.length > 0 || blockedOver) && (
{blockedOver ? ( {t('kanban.dropNotAllowed', { defaultValue: 'No se puede mover aquí', })} ) : ( aggregates.map((a) => ( {aggregates.length > 1 && ( {a.label}: )} {a.value} )) )}
)} {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 /** Candidate fields; the card shows the first few that have a value. */ fieldCols: ColumnDefinition[] /** The model laid its card out explicitly: show every field with a value. */ showAllFields?: boolean actions: ActionDefinition[] /** Column the actions' requiresState reads (the model's stage_field). */ stageField?: string /** Consumer row-action predicate (`DynamicKanban.isRowActionVisible`). */ rowActionPredicate?: RowActionPredicate 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 /** Its move is waiting for the server. */ pending?: boolean } // The title + the card's non-empty fields. Shared by the card and the drag // preview so the lifted card looks exactly like the one on the board. function CardBody({ card, titleCol, fieldCols, showAllFields, locale, timeZone, currency, menu, }: Pick & { menu?: React.ReactNode }) { const fields = visibleCardFields(card, fieldCols, showAllFields ? fieldCols.length : CARD_FIELDS_SHOWN) return (
{titleCol && !isEmptyCardValue(card, titleCol) ? ( ) : ( #{String(card.id)} )}
{menu}
{fields.map((col) => (
{col.label}:
))}
) } function KanbanCard({ card, titleCol, fieldCols, showAllFields, actions, stageField, rowActionPredicate, locale, timeZone, currency, onClick, onAction, draggable = true, pending = false, }: KanbanCardProps) { const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: String(card.id), data: { type: 'card' }, disabled: !draggable, }) const visibleActions = actions.filter((a) => isRowActionVisible(a, card, stageField, rowActionPredicate)) const menu = visibleActions.length > 0 ? ( e.stopPropagation()}> {visibleActions.map((a) => ( { e.stopPropagation() onAction(a.key, card) }} /> ))} ) : null return ( onClick?.(card)} data-card-id={String(card.id)} data-flip-key={draggable ? String(card.id) : undefined} aria-busy={pending || undefined} > ) } // Static preview rendered inside the DragOverlay (no dnd hooks, no menu). It // fills the overlay box, which dnd-kit sizes to the card being dragged. function CardPreview({ card, titleCol, fieldCols, showAllFields, locale, timeZone, currency, blocked = false, }: Omit & { blocked?: boolean }) { return ( ) }