@asteby/metacore-runtime-react 18.28.3 → 19.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,767 @@
1
+ // DynamicKanban — the `view_type: "kanban"` renderer, a sibling of
2
+ // `DynamicTable`. Given the SAME contract (model + endpoint + injected
3
+ // ApiProvider), it fetches the model's metadata + records, groups the records
4
+ // into board lanes by the `group_by` stage column, and lets the user drag a
5
+ // card between lanes.
6
+ //
7
+ // Reuse, not reinvention:
8
+ // - Metadata + records come through the same `useApi()` client and the same
9
+ // `/metadata/table/:model` + `/data/:model` endpoints as DynamicTable.
10
+ // - Card fields render through `ActivityValueRenderer`, the existing pure
11
+ // single-value renderer that mirrors `defaultGetDynamicColumns`' display
12
+ // logic (currency, status, date, relation chip, …) — so a card cell and a
13
+ // table cell look identical.
14
+ // - Per-card actions reuse `useModelActions(model, ['row'])` +
15
+ // `ActionModalDispatcher`, the exact plumbing DynamicTable's row menu uses.
16
+ //
17
+ // The one thing it owns that the table doesn't: an OPTIMISTIC drag-to-move.
18
+ // Dropping a card into another lane mutates local state immediately and fires
19
+ // `PUT /data/:model/me/:id { <group_by>: <destStage> }`; if the request fails
20
+ // the move is reverted and a toast surfaces. This sidesteps the "refetch loses
21
+ // scroll/selection" gap a naive re-query would introduce.
22
+ //
23
+ // Transitions: when the metadata carries `transitions[]`, a card may only be
24
+ // dropped into a lane reachable from its current stage. Disallowed lanes dim
25
+ // while dragging and reject the drop.
26
+ import * as React from 'react'
27
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
28
+ import { useTranslation } from 'react-i18next'
29
+ import {
30
+ DndContext,
31
+ DragOverlay,
32
+ PointerSensor,
33
+ useSensor,
34
+ useSensors,
35
+ useDraggable,
36
+ useDroppable,
37
+ type DragStartEvent,
38
+ type DragEndEvent,
39
+ } from '@dnd-kit/core'
40
+ import { MoreHorizontal } from 'lucide-react'
41
+ import { toast } from 'sonner'
42
+ import {
43
+ Badge,
44
+ Button,
45
+ Card,
46
+ CardContent,
47
+ DropdownMenu,
48
+ DropdownMenuContent,
49
+ DropdownMenuItem,
50
+ DropdownMenuTrigger,
51
+ ScrollArea,
52
+ Skeleton,
53
+ } from '@asteby/metacore-ui/primitives'
54
+ import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib'
55
+ import { useApi } from './api-context'
56
+ import { useMetadataCache } from './metadata-cache'
57
+ import { ActivityValueRenderer } from './activity-value-renderer'
58
+ import { ActionModalDispatcher } from './action-modal-dispatcher'
59
+ import { useModelActions } from './model-action-toolbar'
60
+ import { DynamicIcon } from './dynamic-icon'
61
+ import { isColumnVisibleInTable } from './column-visibility'
62
+ import { isActionAllowedForRowState } from './dynamic-columns'
63
+ import type {
64
+ TableMetadata,
65
+ ColumnDefinition,
66
+ ApiResponse,
67
+ ActionMetadata,
68
+ StageMeta,
69
+ StageTransition,
70
+ } from './types'
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Pure helpers (exported for unit tests — no React, no transport)
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Resolves the board lanes for a kanban view. Prefers the model-level
78
+ * `metadata.stages` (the kernel's `stages[]`); falls back to the `group_by`
79
+ * column's `options` (the kernel projects the stage machine onto the status
80
+ * display). Returns lanes sorted by `order` (then declared order). Empty when
81
+ * neither source is present — the caller renders a "no stages" notice.
82
+ */
83
+ export function deriveStages(metadata: TableMetadata): StageMeta[] {
84
+ const fromMeta = metadata.stages
85
+ if (fromMeta && fromMeta.length > 0) {
86
+ return [...fromMeta].sort(sortByOrder)
87
+ }
88
+ const groupBy = metadata.group_by
89
+ if (!groupBy) return []
90
+ const col = metadata.columns.find((c) => c.key === groupBy)
91
+ const opts = col?.options ?? []
92
+ return opts.map((o, i) => ({
93
+ key: String(o.value),
94
+ label: o.label,
95
+ color: o.color,
96
+ order: i,
97
+ }))
98
+ }
99
+
100
+ function sortByOrder(a: StageMeta, b: StageMeta): number {
101
+ const ao = a.order ?? Number.MAX_SAFE_INTEGER
102
+ const bo = b.order ?? Number.MAX_SAFE_INTEGER
103
+ return ao - bo
104
+ }
105
+
106
+ /**
107
+ * Buckets records into a `stageKey → rows[]` map, one entry per declared stage
108
+ * (in stage order), plus a trailing `__unassigned__` bucket for rows whose
109
+ * stage value matches no declared lane (so nothing silently vanishes). Empty
110
+ * lanes are kept so the board always shows every stage.
111
+ */
112
+ export const UNASSIGNED_LANE = '__unassigned__'
113
+
114
+ export function groupByStage(
115
+ records: any[],
116
+ groupByKey: string,
117
+ stages: StageMeta[],
118
+ ): Map<string, any[]> {
119
+ const map = new Map<string, any[]>()
120
+ for (const s of stages) map.set(s.key, [])
121
+ const known = new Set(stages.map((s) => s.key))
122
+ for (const row of records) {
123
+ const raw = row?.[groupByKey]
124
+ const key = raw === null || raw === undefined ? '' : String(raw)
125
+ if (known.has(key)) {
126
+ map.get(key)!.push(row)
127
+ } else {
128
+ if (!map.has(UNASSIGNED_LANE)) map.set(UNASSIGNED_LANE, [])
129
+ map.get(UNASSIGNED_LANE)!.push(row)
130
+ }
131
+ }
132
+ return map
133
+ }
134
+
135
+ /**
136
+ * Whether a card may move `from → to` given the declared transitions. No
137
+ * transitions declared → unrestricted (the kernel still validates server-side).
138
+ * A move to the same stage is always a no-op "allowed". `'*'` is a wildcard on
139
+ * either side.
140
+ */
141
+ export function isTransitionAllowed(
142
+ transitions: StageTransition[] | undefined,
143
+ from: string,
144
+ to: string,
145
+ ): boolean {
146
+ if (from === to) return true
147
+ if (!transitions || transitions.length === 0) return true
148
+ return transitions.some(
149
+ (t) =>
150
+ (t.from === from || t.from === '*') && (t.to === to || t.to === '*'),
151
+ )
152
+ }
153
+
154
+ /**
155
+ * Returns a NEW grouping with `cardId` moved from `fromStage` to `toStage`
156
+ * (appended to the destination lane). Pure — does not mutate the input map.
157
+ * Used by the optimistic drop handler so the board updates before the PUT
158
+ * resolves, and so the previous grouping can be restored on failure.
159
+ */
160
+ export function applyOptimisticMove(
161
+ grouped: Map<string, any[]>,
162
+ cardId: string | number,
163
+ fromStage: string,
164
+ toStage: string,
165
+ groupByKey: string,
166
+ ): Map<string, any[]> {
167
+ const next = new Map<string, any[]>()
168
+ for (const [k, rows] of grouped) next.set(k, [...rows])
169
+ const fromRows = next.get(fromStage) ?? []
170
+ const idx = fromRows.findIndex((r) => String(r.id) === String(cardId))
171
+ if (idx === -1) return next
172
+ const [moved] = fromRows.splice(idx, 1)
173
+ const updated = { ...moved, [groupByKey]: toStage }
174
+ const toRows = next.get(toStage) ?? []
175
+ toRows.push(updated)
176
+ next.set(toStage, toRows)
177
+ return next
178
+ }
179
+
180
+ /**
181
+ * Picks the columns shown on a card: a `title` column (first searchable column,
182
+ * else first text-ish column) and up to `maxFields` secondary columns. Excludes
183
+ * the group_by column (it's the lane itself) and any column hidden from the
184
+ * table view (visibility modal/list, or `hidden`).
185
+ */
186
+ export function selectCardColumns(
187
+ metadata: TableMetadata,
188
+ maxFields = 3,
189
+ ): { title: ColumnDefinition | null; fields: ColumnDefinition[] } {
190
+ const groupBy = metadata.group_by
191
+ const visible = metadata.columns.filter(
192
+ (c) =>
193
+ c.key !== groupBy &&
194
+ !c.hidden &&
195
+ isColumnVisibleInTable(c) &&
196
+ c.key !== 'id',
197
+ )
198
+ const title =
199
+ visible.find((c) => c.searchable) ??
200
+ visible.find((c) => c.type === 'text' || c.cellStyle === 'truncate-text') ??
201
+ visible[0] ??
202
+ null
203
+ const fields = visible
204
+ .filter((c) => c.key !== title?.key)
205
+ .slice(0, maxFields)
206
+ return { title, fields }
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Theme hook (mirrors the private one in dynamic-columns / activity-renderer)
211
+ // ---------------------------------------------------------------------------
212
+
213
+ function useIsDarkTheme(): boolean {
214
+ const [isDark, setIsDark] = useState(
215
+ () =>
216
+ typeof document !== 'undefined' &&
217
+ document.documentElement.classList.contains('dark'),
218
+ )
219
+ useEffect(() => {
220
+ if (typeof document === 'undefined') return
221
+ const sync = () =>
222
+ setIsDark(document.documentElement.classList.contains('dark'))
223
+ const observer = new MutationObserver(sync)
224
+ observer.observe(document.documentElement, {
225
+ attributes: true,
226
+ attributeFilter: ['class'],
227
+ })
228
+ return () => observer.disconnect()
229
+ }, [])
230
+ return isDark
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Component
235
+ // ---------------------------------------------------------------------------
236
+
237
+ export interface DynamicKanbanProps {
238
+ /** Model key as registered on the backend (e.g. "issue"). */
239
+ model: string
240
+ /**
241
+ * Data endpoint base. Defaults to `/data/<model>`. The optimistic update
242
+ * PUTs to `<base>/me/<id>`.
243
+ */
244
+ endpoint?: string
245
+ /** Bump to force a metadata + records refetch (same contract as DynamicTable). */
246
+ refreshTrigger?: any
247
+ /** Called when a card is clicked (outside its action menu). */
248
+ onCardClick?: (row: any) => void
249
+ /**
250
+ * Max cards fetched per lane render. Kanban shows all cards at once (no
251
+ * pagination UI), so it requests a single large page. Defaults to 200.
252
+ */
253
+ pageSize?: number
254
+ /** IANA timezone for datetime card fields (org config). */
255
+ timeZone?: string
256
+ /** ISO 4217 currency for money card fields (org config). */
257
+ currency?: string
258
+ }
259
+
260
+ export function DynamicKanban({
261
+ model,
262
+ endpoint,
263
+ refreshTrigger,
264
+ onCardClick,
265
+ pageSize = 200,
266
+ timeZone,
267
+ currency,
268
+ }: DynamicKanbanProps) {
269
+ const { t, i18n } = useTranslation()
270
+ const api = useApi()
271
+ const isDark = useIsDarkTheme()
272
+
273
+ const { getMetadata, setMetadata: cacheMetadata } = useMetadataCache()
274
+ const cachedMeta = getMetadata(model)
275
+
276
+ const [metadata, setMetadata] = useState<TableMetadata | null>(cachedMeta || null)
277
+ const [records, setRecords] = useState<any[]>([])
278
+ const [loading, setLoading] = useState(!cachedMeta)
279
+ const [loadingData, setLoadingData] = useState(true)
280
+
281
+ // Active drag card id (for the DragOverlay + drop-zone highlighting).
282
+ const [activeId, setActiveId] = useState<string | null>(null)
283
+
284
+ const [actionModal, setActionModal] = useState<{
285
+ action: ActionMetadata | null
286
+ record: any | null
287
+ }>({ action: null, record: null })
288
+
289
+ const sensors = useSensors(
290
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
291
+ )
292
+
293
+ // ---- metadata fetch (same path as DynamicTable) ----
294
+ useEffect(() => {
295
+ let cancelled = false
296
+ const cached = getMetadata(model)
297
+ if (cached) {
298
+ setMetadata(cached)
299
+ setLoading(false)
300
+ } else {
301
+ setLoading(true)
302
+ }
303
+ api
304
+ .get(`/metadata/table/${model}`)
305
+ .then((res) => {
306
+ if (cancelled) return
307
+ const body = res.data as ApiResponse<TableMetadata>
308
+ if (body.success) {
309
+ setMetadata(body.data)
310
+ cacheMetadata(model, body.data)
311
+ }
312
+ })
313
+ .catch((err) => {
314
+ if (!cancelled && !cached)
315
+ console.error('Error al cargar la configuración del tablero', err)
316
+ })
317
+ .finally(() => {
318
+ if (!cancelled) setLoading(false)
319
+ })
320
+ return () => {
321
+ cancelled = true
322
+ }
323
+ // eslint-disable-next-line react-hooks/exhaustive-deps
324
+ }, [model])
325
+
326
+ // ---- records fetch (same path as DynamicTable, single large page) ----
327
+ const fetchData = useCallback(async () => {
328
+ if (!metadata) return
329
+ setLoadingData(true)
330
+ try {
331
+ const res = (await api.get(endpoint || `/data/${model}`, {
332
+ params: { page: 1, per_page: pageSize },
333
+ })) as { data: ApiResponse<any[]> }
334
+ if (res.data.success) setRecords(res.data.data || [])
335
+ } catch (err) {
336
+ console.error('Error al cargar las tarjetas', err)
337
+ } finally {
338
+ setLoadingData(false)
339
+ }
340
+ }, [api, endpoint, model, metadata, pageSize])
341
+
342
+ useEffect(() => {
343
+ if (metadata) void fetchData()
344
+ // eslint-disable-next-line react-hooks/exhaustive-deps
345
+ }, [metadata, refreshTrigger])
346
+
347
+ const stages = useMemo(
348
+ () => (metadata ? deriveStages(metadata) : []),
349
+ [metadata],
350
+ )
351
+ const groupByKey = metadata?.group_by || ''
352
+ const transitions = metadata?.transitions
353
+
354
+ const grouped = useMemo(
355
+ () => groupByStage(records, groupByKey, stages),
356
+ [records, groupByKey, stages],
357
+ )
358
+
359
+ const { title: titleCol, fields: fieldCols } = useMemo(
360
+ () => (metadata ? selectCardColumns(metadata) : { title: null, fields: [] }),
361
+ [metadata],
362
+ )
363
+
364
+ // Row-placement actions reused verbatim from the table's plumbing.
365
+ const rowActions = useModelActions(model, ['row'], metadata?.actions)
366
+
367
+ const cardById = useMemo(() => {
368
+ const m = new Map<string, any>()
369
+ for (const r of records) m.set(String(r.id), r)
370
+ return m
371
+ }, [records])
372
+
373
+ const stageOfCard = useCallback(
374
+ (id: string): string => {
375
+ const card = cardById.get(id)
376
+ const raw = card?.[groupByKey]
377
+ return raw === null || raw === undefined ? '' : String(raw)
378
+ },
379
+ [cardById, groupByKey],
380
+ )
381
+
382
+ const onDragStart = useCallback((e: DragStartEvent) => {
383
+ setActiveId(String(e.active.id))
384
+ }, [])
385
+
386
+ const onDragEnd = useCallback(
387
+ async (e: DragEndEvent) => {
388
+ setActiveId(null)
389
+ const { active, over } = e
390
+ if (!over) return
391
+ const cardId = String(active.id)
392
+ const destStage = String(over.id)
393
+ const srcStage = stageOfCard(cardId)
394
+ if (srcStage === destStage) return
395
+ if (!isTransitionAllowed(transitions, srcStage, destStage)) {
396
+ toast.error(
397
+ t('kanban.invalidTransition', {
398
+ defaultValue: 'Movimiento no permitido entre estas etapas',
399
+ }),
400
+ )
401
+ return
402
+ }
403
+
404
+ // OPTIMISTIC: move the card in local state immediately.
405
+ const prevRecords = records
406
+ setRecords((rs) =>
407
+ rs.map((r) =>
408
+ String(r.id) === cardId ? { ...r, [groupByKey]: destStage } : r,
409
+ ),
410
+ )
411
+
412
+ try {
413
+ const base = endpoint || `/data/${model}`
414
+ const res = (await api.put(`${base}/me/${cardId}`, {
415
+ [groupByKey]: destStage,
416
+ })) as { data?: ApiResponse<any> }
417
+ if (res?.data && res.data.success === false) {
418
+ throw new Error(res.data.message || 'update_failed')
419
+ }
420
+ } catch (err: any) {
421
+ // REVERT + toast on failure.
422
+ setRecords(prevRecords)
423
+ toast.error(
424
+ t('kanban.moveFailed', {
425
+ defaultValue: 'No se pudo mover la tarjeta',
426
+ }) +
427
+ (err?.response?.data?.message
428
+ ? `: ${err.response.data.message}`
429
+ : ''),
430
+ )
431
+ }
432
+ },
433
+ [api, endpoint, groupByKey, model, records, stageOfCard, t, transitions],
434
+ )
435
+
436
+ if (loading) {
437
+ return (
438
+ <div className="flex gap-4 overflow-x-auto p-1">
439
+ {[0, 1, 2, 3].map((i) => (
440
+ <div key={i} className="w-72 shrink-0 space-y-3">
441
+ <Skeleton className="h-8 w-full" />
442
+ <Skeleton className="h-24 w-full" />
443
+ <Skeleton className="h-24 w-full" />
444
+ </div>
445
+ ))}
446
+ </div>
447
+ )
448
+ }
449
+
450
+ if (!metadata || !groupByKey || stages.length === 0) {
451
+ return (
452
+ <div className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
453
+ {t('kanban.noStages', {
454
+ defaultValue:
455
+ 'Este modelo no declara etapas para la vista de tablero.',
456
+ })}
457
+ </div>
458
+ )
459
+ }
460
+
461
+ const activeCard = activeId ? cardById.get(activeId) : null
462
+ const activeStage = activeId ? stageOfCard(activeId) : ''
463
+
464
+ const lanes: StageMeta[] = [...stages]
465
+ if (grouped.has(UNASSIGNED_LANE)) {
466
+ lanes.push({
467
+ key: UNASSIGNED_LANE,
468
+ label: t('kanban.unassigned', { defaultValue: 'Sin etapa' }),
469
+ color: 'slate',
470
+ order: Number.MAX_SAFE_INTEGER,
471
+ })
472
+ }
473
+
474
+ return (
475
+ <DndContext sensors={sensors} onDragStart={onDragStart} onDragEnd={onDragEnd}>
476
+ <div className="flex gap-4 overflow-x-auto p-1" data-testid="kanban-board">
477
+ {lanes.map((stage) => {
478
+ const cards = grouped.get(stage.key) ?? []
479
+ const droppableAllowed =
480
+ !activeId ||
481
+ stage.key === activeStage ||
482
+ isTransitionAllowed(transitions, activeStage, stage.key)
483
+ return (
484
+ <KanbanLane
485
+ key={stage.key}
486
+ stage={stage}
487
+ count={cards.length}
488
+ isDark={isDark}
489
+ dimmed={!!activeId && !droppableAllowed}
490
+ disabled={!!activeId && !droppableAllowed}
491
+ >
492
+ {loadingData && cards.length === 0 ? (
493
+ <>
494
+ <Skeleton className="h-20 w-full" />
495
+ <Skeleton className="h-20 w-full" />
496
+ </>
497
+ ) : cards.length === 0 ? (
498
+ <p className="px-1 py-6 text-center text-xs text-muted-foreground">
499
+ {t('kanban.emptyLane', { defaultValue: 'Sin tarjetas' })}
500
+ </p>
501
+ ) : (
502
+ cards.map((card) => (
503
+ <KanbanCard
504
+ key={String(card.id)}
505
+ card={card}
506
+ titleCol={titleCol}
507
+ fieldCols={fieldCols}
508
+ actions={rowActions}
509
+ locale={i18n.language}
510
+ timeZone={timeZone}
511
+ currency={currency}
512
+ onClick={onCardClick}
513
+ onAction={(action, record) =>
514
+ setActionModal({ action, record })
515
+ }
516
+ />
517
+ ))
518
+ )}
519
+ </KanbanLane>
520
+ )
521
+ })}
522
+ </div>
523
+
524
+ <DragOverlay>
525
+ {activeCard ? (
526
+ <CardPreview
527
+ card={activeCard}
528
+ titleCol={titleCol}
529
+ fieldCols={fieldCols}
530
+ locale={i18n.language}
531
+ timeZone={timeZone}
532
+ currency={currency}
533
+ />
534
+ ) : null}
535
+ </DragOverlay>
536
+
537
+ {actionModal.action && (
538
+ <ActionModalDispatcher
539
+ open={!!actionModal.action}
540
+ onOpenChange={(open) => {
541
+ if (!open) setActionModal({ action: null, record: null })
542
+ }}
543
+ action={actionModal.action}
544
+ model={model}
545
+ record={actionModal.record ?? {}}
546
+ endpoint={endpoint ?? `/data/${model}/me`}
547
+ onSuccess={() => {
548
+ setActionModal({ action: null, record: null })
549
+ void fetchData()
550
+ }}
551
+ />
552
+ )}
553
+ </DndContext>
554
+ )
555
+ }
556
+
557
+ // ---------------------------------------------------------------------------
558
+ // Lane (droppable column)
559
+ // ---------------------------------------------------------------------------
560
+
561
+ interface KanbanLaneProps {
562
+ stage: StageMeta
563
+ count: number
564
+ isDark: boolean
565
+ dimmed: boolean
566
+ disabled: boolean
567
+ children: React.ReactNode
568
+ }
569
+
570
+ function KanbanLane({ stage, count, isDark, dimmed, disabled, children }: KanbanLaneProps) {
571
+ const { setNodeRef, isOver } = useDroppable({ id: stage.key, disabled })
572
+ const headerStyle = generateBadgeStyles(stage.color || optionColor(stage.key), {
573
+ isDark,
574
+ })
575
+ return (
576
+ <div
577
+ ref={setNodeRef}
578
+ className="flex w-72 shrink-0 flex-col rounded-lg border bg-muted/30 transition-opacity"
579
+ style={{
580
+ opacity: dimmed ? 0.45 : 1,
581
+ outline: isOver && !disabled ? '2px solid var(--ring, #3b82f6)' : 'none',
582
+ outlineOffset: 2,
583
+ }}
584
+ data-stage={stage.key}
585
+ data-disabled={disabled || undefined}
586
+ >
587
+ <div className="flex items-center justify-between gap-2 px-3 py-2.5">
588
+ <Badge
589
+ variant="outline"
590
+ className="border-0 text-xs font-semibold"
591
+ style={headerStyle}
592
+ >
593
+ {stage.label}
594
+ </Badge>
595
+ <span className="text-xs font-medium tabular-nums text-muted-foreground">
596
+ {count}
597
+ </span>
598
+ </div>
599
+ <ScrollArea className="max-h-[70vh]">
600
+ <div className="flex flex-col gap-2 px-2 pb-3">{children}</div>
601
+ </ScrollArea>
602
+ </div>
603
+ )
604
+ }
605
+
606
+ // ---------------------------------------------------------------------------
607
+ // Card (draggable)
608
+ // ---------------------------------------------------------------------------
609
+
610
+ interface KanbanCardProps {
611
+ card: any
612
+ titleCol: ColumnDefinition | null
613
+ fieldCols: ColumnDefinition[]
614
+ actions: ActionMetadata[] | any[]
615
+ locale: string
616
+ timeZone?: string
617
+ currency?: string
618
+ onClick?: (row: any) => void
619
+ onAction: (action: ActionMetadata, record: any) => void
620
+ }
621
+
622
+ function KanbanCard({
623
+ card,
624
+ titleCol,
625
+ fieldCols,
626
+ actions,
627
+ locale,
628
+ timeZone,
629
+ currency,
630
+ onClick,
631
+ onAction,
632
+ }: KanbanCardProps) {
633
+ const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
634
+ id: String(card.id),
635
+ })
636
+
637
+ const visibleActions = (actions as any[]).filter((a) =>
638
+ isActionAllowedForRowState(a, card),
639
+ )
640
+
641
+ return (
642
+ <Card
643
+ ref={setNodeRef}
644
+ {...attributes}
645
+ {...listeners}
646
+ className="cursor-grab active:cursor-grabbing border-border/70 shadow-sm"
647
+ style={{ opacity: isDragging ? 0.4 : 1 }}
648
+ onClick={() => onClick?.(card)}
649
+ data-card-id={String(card.id)}
650
+ >
651
+ <CardContent className="space-y-1.5 p-3">
652
+ <div className="flex items-start justify-between gap-2">
653
+ <div className="min-w-0 flex-1 text-sm font-medium leading-snug">
654
+ {titleCol ? (
655
+ <ActivityValueRenderer
656
+ value={card[titleCol.key]}
657
+ col={titleCol}
658
+ locale={locale}
659
+ timeZone={timeZone}
660
+ currency={currency}
661
+ />
662
+ ) : (
663
+ <span className="truncate">{String(card.id)}</span>
664
+ )}
665
+ </div>
666
+ {visibleActions.length > 0 && (
667
+ <DropdownMenu>
668
+ <DropdownMenuTrigger asChild>
669
+ <Button
670
+ variant="ghost"
671
+ size="icon"
672
+ className="h-6 w-6 shrink-0 -mr-1 -mt-1"
673
+ // Don't start a drag / card click from the menu button.
674
+ onPointerDown={(e) => e.stopPropagation()}
675
+ onClick={(e) => e.stopPropagation()}
676
+ >
677
+ <MoreHorizontal className="h-4 w-4" />
678
+ </Button>
679
+ </DropdownMenuTrigger>
680
+ <DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
681
+ {visibleActions.map((a) => (
682
+ <DropdownMenuItem
683
+ key={a.key}
684
+ onClick={(e) => {
685
+ e.stopPropagation()
686
+ onAction(a as ActionMetadata, card)
687
+ }}
688
+ >
689
+ <DynamicIcon
690
+ name={a.icon || 'Zap'}
691
+ className="mr-2 h-4 w-4"
692
+ />
693
+ {a.label}
694
+ </DropdownMenuItem>
695
+ ))}
696
+ </DropdownMenuContent>
697
+ </DropdownMenu>
698
+ )}
699
+ </div>
700
+ {fieldCols.map((col) => (
701
+ <div
702
+ key={col.key}
703
+ className="flex items-center gap-1.5 text-xs text-muted-foreground"
704
+ >
705
+ <span className="shrink-0 opacity-70">{col.label}:</span>
706
+ <span className="min-w-0 truncate">
707
+ <ActivityValueRenderer
708
+ value={card[col.key]}
709
+ col={col}
710
+ locale={locale}
711
+ timeZone={timeZone}
712
+ currency={currency}
713
+ />
714
+ </span>
715
+ </div>
716
+ ))}
717
+ </CardContent>
718
+ </Card>
719
+ )
720
+ }
721
+
722
+ // Static preview rendered inside the DragOverlay (no dnd hooks, no menu).
723
+ function CardPreview({
724
+ card,
725
+ titleCol,
726
+ fieldCols,
727
+ locale,
728
+ timeZone,
729
+ currency,
730
+ }: Omit<KanbanCardProps, 'actions' | 'onClick' | 'onAction'>) {
731
+ return (
732
+ <Card className="w-72 cursor-grabbing border-primary/40 shadow-lg">
733
+ <CardContent className="space-y-1.5 p-3">
734
+ <div className="text-sm font-medium leading-snug">
735
+ {titleCol ? (
736
+ <ActivityValueRenderer
737
+ value={card[titleCol.key]}
738
+ col={titleCol}
739
+ locale={locale}
740
+ timeZone={timeZone}
741
+ currency={currency}
742
+ />
743
+ ) : (
744
+ String(card.id)
745
+ )}
746
+ </div>
747
+ {fieldCols.map((col) => (
748
+ <div
749
+ key={col.key}
750
+ className="flex items-center gap-1.5 text-xs text-muted-foreground"
751
+ >
752
+ <span className="shrink-0 opacity-70">{col.label}:</span>
753
+ <span className="min-w-0 truncate">
754
+ <ActivityValueRenderer
755
+ value={card[col.key]}
756
+ col={col}
757
+ locale={locale}
758
+ timeZone={timeZone}
759
+ currency={currency}
760
+ />
761
+ </span>
762
+ </div>
763
+ ))}
764
+ </CardContent>
765
+ </Card>
766
+ )
767
+ }