@asteby/metacore-runtime-react 18.28.2 → 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.
- package/CHANGELOG.md +15 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +35 -6
- package/dist/dynamic-kanban.d.ts +66 -0
- package/dist/dynamic-kanban.d.ts.map +1 -0
- package/dist/dynamic-kanban.js +341 -0
- package/dist/dynamic-view.d.ts +18 -0
- package/dist/dynamic-view.d.ts.map +1 -0
- package/dist/dynamic-view.js +75 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/types.d.ts +46 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +8 -5
- package/src/__tests__/dynamic-kanban.test.tsx +268 -0
- package/src/action-modal-dispatcher.tsx +32 -0
- package/src/dynamic-kanban.tsx +767 -0
- package/src/dynamic-view.tsx +99 -0
- package/src/index.ts +15 -0
- package/src/types.ts +48 -0
|
@@ -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
|
+
}
|