@asteby/metacore-runtime-react 23.6.0 → 23.7.1

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,1113 @@
1
+ // Custom stages — a Bitrix-style "add your own column" affordance for
2
+ // DynamicKanban, generic over any model. Two flavors:
3
+ //
4
+ // - type: "stage" → a REAL lane. The backend persists it and also surfaces
5
+ // it in the model's `metadata.stages`, so cards drag in/out of it exactly
6
+ // like a declared stage. We only add the create/edit/delete UI + merge any
7
+ // custom stage the metadata hasn't caught up to yet (tolerant).
8
+ //
9
+ // - type: "smart" → a VIRTUAL lane. Nothing is stored on the card; the lane
10
+ // is defined by a set of `filters` and is populated by querying the list
11
+ // with those filters (the same `f_<field>=value` params the board already
12
+ // speaks). Cards in a smart lane are read-only (no drag), and the header
13
+ // shows a funnel icon.
14
+ //
15
+ // Non-intrusive by design: the CRUD lives server-side behind `/custom-stages`
16
+ // (same api client as the rest of the dynamic runtime). If the endpoint 404s
17
+ // or errors the hook reports `available: false`, the "+ Agregar etapa" column
18
+ // and the lane context menus simply don't render, and the board keeps working.
19
+ // All UI text goes through t() with a Spanish defaultValue.
20
+ import React, { useCallback, useEffect, useMemo, useState } from 'react'
21
+ import { useTranslation } from 'react-i18next'
22
+ import { Plus, Trash2, Pencil, MoreVertical, Filter, X } from 'lucide-react'
23
+ import { toast } from 'sonner'
24
+ import {
25
+ Badge,
26
+ Button,
27
+ Dialog,
28
+ DialogContent,
29
+ DialogDescription,
30
+ DialogFooter,
31
+ DialogHeader,
32
+ DialogTitle,
33
+ DropdownMenu,
34
+ DropdownMenuContent,
35
+ DropdownMenuItem,
36
+ DropdownMenuTrigger,
37
+ Input,
38
+ Label,
39
+ Select,
40
+ SelectContent,
41
+ SelectItem,
42
+ SelectTrigger,
43
+ SelectValue,
44
+ Skeleton,
45
+ } from '@asteby/metacore-ui/primitives'
46
+ import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib'
47
+ import { useApi } from './api-context'
48
+ import type { ApiClient } from './api-context'
49
+ import type {
50
+ ColumnDefinition,
51
+ StageMeta,
52
+ SmartLaneMeta,
53
+ ApiResponse,
54
+ } from './types'
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Contract (matches the ops backend; envelope is {success, data} → read .data)
58
+ // ---------------------------------------------------------------------------
59
+
60
+ export type CustomStageType = 'stage' | 'smart'
61
+ export type CustomStageFilterOp = 'eq' | 'neq' | 'contains' | 'in'
62
+
63
+ export interface CustomStageFilter {
64
+ field: string
65
+ op: CustomStageFilterOp
66
+ value: string
67
+ }
68
+
69
+ export interface CustomStage {
70
+ id: string | number
71
+ model: string
72
+ /** Stable lane key (also the value stored on a card for `type: "stage"`). */
73
+ key: string
74
+ label: string
75
+ /** Semantic palette name ('slate', 'blue', …) or a hex literal. */
76
+ color: string
77
+ /** Board position (lane order). */
78
+ position: number
79
+ type: CustomStageType
80
+ /** Only meaningful for `type: "smart"` — the virtual lane's funnel. */
81
+ filters: CustomStageFilter[]
82
+ enabled: boolean
83
+ }
84
+
85
+ /** Draft for a new custom stage (no server id yet). */
86
+ export type NewCustomStage = Omit<CustomStage, 'id'>
87
+
88
+ // ~8 preset palette names understood by `generateBadgeStyles`.
89
+ export const CUSTOM_STAGE_COLORS = [
90
+ 'slate',
91
+ 'blue',
92
+ 'green',
93
+ 'amber',
94
+ 'red',
95
+ 'purple',
96
+ 'pink',
97
+ 'cyan',
98
+ ] as const
99
+
100
+ export const CUSTOM_STAGE_FILTER_OPS: CustomStageFilterOp[] = [
101
+ 'eq',
102
+ 'neq',
103
+ 'contains',
104
+ 'in',
105
+ ]
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Pure helpers (exported for unit tests — no React, no transport)
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /** A blank filter row for the smart-lane condition builder. */
112
+ export function emptyCustomStageFilter(field = ''): CustomStageFilter {
113
+ return { field, op: 'eq', value: '' }
114
+ }
115
+
116
+ /** Enabled custom stages split by flavor (disabled ones are dropped). */
117
+ export function splitCustomStages(stages: CustomStage[] | undefined): {
118
+ laneStages: CustomStage[]
119
+ smartStages: CustomStage[]
120
+ } {
121
+ const laneStages: CustomStage[] = []
122
+ const smartStages: CustomStage[] = []
123
+ for (const s of stages ?? []) {
124
+ if (s.enabled === false) continue
125
+ if (s.type === 'smart') smartStages.push(s)
126
+ else laneStages.push(s)
127
+ }
128
+ return { laneStages, smartStages }
129
+ }
130
+
131
+ /**
132
+ * Merges `type: "stage"` custom stages into the model's declared lanes. A
133
+ * custom stage whose key the metadata ALREADY carries (the backend surfaced it
134
+ * in `metadata.stages`) is not duplicated — it's just tagged as custom so the
135
+ * lane grows an edit/delete menu. Unknown-key custom stages are appended.
136
+ * Returns the merged lanes (sorted by order) plus a `key → CustomStage` map so
137
+ * the board can decorate the custom lanes.
138
+ */
139
+ export function mergeLaneStages(
140
+ declared: StageMeta[],
141
+ customLaneStages: CustomStage[],
142
+ ): { lanes: StageMeta[]; customByKey: Map<string, CustomStage> } {
143
+ const customByKey = new Map<string, CustomStage>()
144
+ for (const cs of customLaneStages) customByKey.set(cs.key, cs)
145
+ const present = new Set(declared.map((s) => s.key))
146
+ const lanes: StageMeta[] = [...declared]
147
+ for (const cs of customLaneStages) {
148
+ if (present.has(cs.key)) continue
149
+ lanes.push({
150
+ key: cs.key,
151
+ label: cs.label,
152
+ color: cs.color,
153
+ order: cs.position,
154
+ })
155
+ }
156
+ lanes.sort((a, b) => {
157
+ const ao = a.order ?? Number.MAX_SAFE_INTEGER
158
+ const bo = b.order ?? Number.MAX_SAFE_INTEGER
159
+ return ao - bo
160
+ })
161
+ return { lanes, customByKey }
162
+ }
163
+
164
+ /**
165
+ * Serializes a smart lane's filters into the board's list query params. The ops
166
+ * list endpoint (ops #704) takes a SINGLE `f_<field>` param whose value carries
167
+ * the operator as a `OP:value` prefix:
168
+ * - eq → `f_<field>=EQ:value`
169
+ * - neq → `f_<field>=NEQ:value`
170
+ * - contains → `f_<field>=HAS:value` (membership in a jsonb array — NOT the
171
+ * text-substring ILIKE; that's why it's HAS, not CONTAINS)
172
+ * - in → `f_<field>=IN:v1,v2,...` (comma-separated after `IN:`)
173
+ * Empty fields/values are skipped.
174
+ */
175
+ export function smartLaneParams(
176
+ filters: CustomStageFilter[] | undefined,
177
+ ): Record<string, string> {
178
+ const params: Record<string, string> = {}
179
+ for (const f of filters ?? []) {
180
+ if (!f.field || f.value == null || String(f.value).trim() === '') continue
181
+ const v = String(f.value).trim()
182
+ switch (f.op) {
183
+ case 'neq':
184
+ params[`f_${f.field}`] = `NEQ:${v}`
185
+ break
186
+ case 'contains':
187
+ params[`f_${f.field}`] = `HAS:${v}`
188
+ break
189
+ case 'in':
190
+ params[`f_${f.field}`] = `IN:${v}`
191
+ break
192
+ case 'eq':
193
+ default:
194
+ params[`f_${f.field}`] = `EQ:${v}`
195
+ }
196
+ }
197
+ return params
198
+ }
199
+
200
+ /**
201
+ * Resolves the smart lanes to paint. The kernel's `metadata.smart_lanes` is the
202
+ * source of truth for rendering (ops #704); the CRUD list only backs the
203
+ * management dialog. So when metadata carries smart lanes we map those, folding
204
+ * in the CRUD entry's `id` (matched by key) so the Editar menu can PUT/DELETE.
205
+ * When metadata omits them (older host / metadata lag) we tolerate the gap and
206
+ * fall back to the CRUD smart stages. Returns `CustomStage[]` either way.
207
+ */
208
+ export function resolveSmartLanes(
209
+ metaSmartLanes: SmartLaneMeta[] | undefined,
210
+ crudSmartStages: CustomStage[],
211
+ model: string,
212
+ ): CustomStage[] {
213
+ if (!metaSmartLanes?.length) return crudSmartStages
214
+ const crudByKey = new Map(crudSmartStages.map((s) => [s.key, s]))
215
+ return metaSmartLanes.map((lane, i) => {
216
+ const match = crudByKey.get(lane.key)
217
+ return {
218
+ id: match?.id ?? lane.key,
219
+ model,
220
+ key: lane.key,
221
+ label: lane.label,
222
+ color: lane.color ?? match?.color ?? 'slate',
223
+ position: lane.order ?? match?.position ?? i,
224
+ type: 'smart',
225
+ filters: (lane.filters ?? []).map((f) => ({
226
+ field: f.field,
227
+ op: f.op as CustomStageFilterOp,
228
+ value: f.value,
229
+ })),
230
+ enabled: true,
231
+ }
232
+ })
233
+ }
234
+
235
+ /** Columns offered in the condition builder: visible, non-id model columns. */
236
+ export function customStageFilterFields(
237
+ columns: ColumnDefinition[],
238
+ ): ColumnDefinition[] {
239
+ return columns.filter((c) => !c.hidden && c.key !== 'id')
240
+ }
241
+
242
+ /** Whether a draft is complete enough to save. */
243
+ export function isCustomStageDraftValid(draft: {
244
+ label: string
245
+ type: CustomStageType
246
+ filters: CustomStageFilter[]
247
+ }): boolean {
248
+ if (!draft.label.trim()) return false
249
+ if (draft.type === 'smart') {
250
+ return draft.filters.some(
251
+ (f) => f.field && String(f.value).trim() !== '',
252
+ )
253
+ }
254
+ return true
255
+ }
256
+
257
+ /** Slugify a label into a stable-ish lane key (used only when creating). */
258
+ export function slugifyStageKey(label: string): string {
259
+ return (
260
+ label
261
+ .toLowerCase()
262
+ .normalize('NFD')
263
+ .replace(/[̀-ͯ]/g, '')
264
+ .replace(/[^a-z0-9]+/g, '_')
265
+ .replace(/^_+|_+$/g, '')
266
+ .slice(0, 48) || `stage_${Date.now().toString(36)}`
267
+ )
268
+ }
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // Data hook
272
+ // ---------------------------------------------------------------------------
273
+
274
+ function unwrap(res: { data: any }): any {
275
+ const body = res?.data
276
+ if (body && typeof body === 'object' && 'data' in body) return body.data
277
+ return body
278
+ }
279
+
280
+ export interface UseCustomStagesResult {
281
+ /** False when the endpoint is missing/errored — hide the whole feature. */
282
+ available: boolean
283
+ loading: boolean
284
+ stages: CustomStage[]
285
+ create: (draft: NewCustomStage) => Promise<void>
286
+ update: (id: CustomStage['id'], patch: Partial<CustomStage>) => Promise<void>
287
+ /**
288
+ * Deletes a stage. Pass `reassignTo` (a target lane key) to move a real
289
+ * stage's cards first. Throws on 409 (cards still present, no reassign) so
290
+ * the caller can read `meta.cards` and offer a reassignment target.
291
+ */
292
+ remove: (id: CustomStage['id'], reassignTo?: string) => Promise<void>
293
+ }
294
+
295
+ /**
296
+ * Loads a model's custom stages and exposes CRUD. A missing endpoint (404 /
297
+ * network error) degrades to `available: false` so the kanban never breaks;
298
+ * real mutation failures surface a toast and re-throw so the dialog keeps its
299
+ * draft. A 409 on delete (existing cards) is re-thrown WITHOUT a generic toast
300
+ * so the delete flow can show a targeted message.
301
+ */
302
+ export function useCustomStages(model: string): UseCustomStagesResult {
303
+ const api = useApi()
304
+ const { t } = useTranslation()
305
+ const [available, setAvailable] = useState(true)
306
+ const [loading, setLoading] = useState(true)
307
+ const [stages, setStages] = useState<CustomStage[]>([])
308
+
309
+ const load = useCallback(async () => {
310
+ setLoading(true)
311
+ try {
312
+ const res = await api.get(
313
+ `/custom-stages?model=${encodeURIComponent(model)}`,
314
+ )
315
+ const data = unwrap(res)
316
+ setStages(Array.isArray(data) ? data : [])
317
+ setAvailable(true)
318
+ } catch {
319
+ setStages([])
320
+ setAvailable(false)
321
+ } finally {
322
+ setLoading(false)
323
+ }
324
+ }, [api, model])
325
+
326
+ useEffect(() => {
327
+ void load()
328
+ }, [load])
329
+
330
+ const create = useCallback(
331
+ async (draft: NewCustomStage) => {
332
+ try {
333
+ await api.post('/custom-stages', draft)
334
+ await load()
335
+ } catch (e) {
336
+ toast.error(
337
+ t('dynamic.custom_stages.save_error', {
338
+ defaultValue: 'No se pudo guardar la etapa',
339
+ }),
340
+ )
341
+ throw e
342
+ }
343
+ },
344
+ [api, load, t],
345
+ )
346
+
347
+ const update = useCallback(
348
+ async (id: CustomStage['id'], patch: Partial<CustomStage>) => {
349
+ try {
350
+ await api.put(`/custom-stages/${id}`, patch)
351
+ await load()
352
+ } catch (e) {
353
+ toast.error(
354
+ t('dynamic.custom_stages.save_error', {
355
+ defaultValue: 'No se pudo guardar la etapa',
356
+ }),
357
+ )
358
+ throw e
359
+ }
360
+ },
361
+ [api, load, t],
362
+ )
363
+
364
+ const remove = useCallback(
365
+ async (id: CustomStage['id'], reassignTo?: string) => {
366
+ try {
367
+ await api.delete(
368
+ `/custom-stages/${id}`,
369
+ reassignTo
370
+ ? { params: { reassign_to: reassignTo } }
371
+ : undefined,
372
+ )
373
+ await load()
374
+ } catch (e: any) {
375
+ // 409 = the stage still holds cards; let the caller decide how to
376
+ // surface it (reassign prompt) instead of a generic error toast.
377
+ if (e?.response?.status === 409) throw e
378
+ toast.error(
379
+ t('dynamic.custom_stages.delete_error', {
380
+ defaultValue: 'No se pudo eliminar la etapa',
381
+ }),
382
+ )
383
+ throw e
384
+ }
385
+ },
386
+ [api, load, t],
387
+ )
388
+
389
+ return { available, loading, stages, create, update, remove }
390
+ }
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // "+ Agregar etapa" ghost column
394
+ // ---------------------------------------------------------------------------
395
+
396
+ export interface AddStageColumnProps {
397
+ onClick: () => void
398
+ }
399
+
400
+ /** The dotted phantom lane at the end of the board (Bitrix/Trello pattern). */
401
+ export function AddStageColumn({ onClick }: AddStageColumnProps) {
402
+ const { t } = useTranslation()
403
+ return (
404
+ <button
405
+ type="button"
406
+ onClick={onClick}
407
+ className="group/add flex min-h-[55vh] min-w-[220px] max-w-[280px] shrink-0 flex-col items-center justify-center gap-2 rounded-xl border border-dashed bg-transparent p-4 text-sm text-muted-foreground transition-colors hover:border-primary/50 hover:bg-muted/40 hover:text-foreground"
408
+ data-testid="kanban-add-stage"
409
+ >
410
+ <span className="flex size-9 items-center justify-center rounded-full border border-dashed transition-colors group-hover/add:border-primary/50">
411
+ <Plus className="h-4 w-4" />
412
+ </span>
413
+ {t('dynamic.custom_stages.add', { defaultValue: 'Agregar etapa' })}
414
+ </button>
415
+ )
416
+ }
417
+
418
+ // ---------------------------------------------------------------------------
419
+ // Lane context menu (Editar / Eliminar) for custom lanes
420
+ // ---------------------------------------------------------------------------
421
+
422
+ export interface CustomStageLaneMenuProps {
423
+ stage: CustomStage
424
+ onEdit: (stage: CustomStage) => void
425
+ onDelete: (stage: CustomStage) => void
426
+ }
427
+
428
+ /** The ⋮ menu shown in a custom lane's header. */
429
+ export function CustomStageLaneMenu({
430
+ stage,
431
+ onEdit,
432
+ onDelete,
433
+ }: CustomStageLaneMenuProps) {
434
+ const { t } = useTranslation()
435
+ return (
436
+ <DropdownMenu>
437
+ <DropdownMenuTrigger asChild>
438
+ <button
439
+ type="button"
440
+ className="flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
441
+ aria-label={t('dynamic.custom_stages.menu', {
442
+ defaultValue: 'Opciones de la etapa',
443
+ })}
444
+ data-testid={`custom-stage-menu-${stage.key}`}
445
+ >
446
+ <MoreVertical className="h-3.5 w-3.5" />
447
+ </button>
448
+ </DropdownMenuTrigger>
449
+ <DropdownMenuContent align="end">
450
+ <DropdownMenuItem onClick={() => onEdit(stage)}>
451
+ <Pencil className="mr-2 h-4 w-4" />
452
+ {t('dynamic.custom_stages.edit', { defaultValue: 'Editar' })}
453
+ </DropdownMenuItem>
454
+ <DropdownMenuItem
455
+ className="text-destructive focus:text-destructive"
456
+ onClick={() => onDelete(stage)}
457
+ >
458
+ <Trash2 className="mr-2 h-4 w-4" />
459
+ {t('dynamic.custom_stages.delete', { defaultValue: 'Eliminar' })}
460
+ </DropdownMenuItem>
461
+ </DropdownMenuContent>
462
+ </DropdownMenu>
463
+ )
464
+ }
465
+
466
+ // ---------------------------------------------------------------------------
467
+ // Create / edit dialog
468
+ // ---------------------------------------------------------------------------
469
+
470
+ export interface CustomStageDialogProps {
471
+ open: boolean
472
+ onOpenChange: (open: boolean) => void
473
+ model: string
474
+ /** Columns for the smart-lane condition builder. */
475
+ columns: ColumnDefinition[]
476
+ /** Editing an existing stage, or null to create a new one. */
477
+ initial: CustomStage | null
478
+ /** Next board position for a newly created stage (appended at the end). */
479
+ nextPosition: number
480
+ onCreate: (draft: NewCustomStage) => Promise<void>
481
+ onUpdate: (id: CustomStage['id'], patch: Partial<CustomStage>) => Promise<void>
482
+ }
483
+
484
+ export function CustomStageDialog({
485
+ open,
486
+ onOpenChange,
487
+ model,
488
+ columns,
489
+ initial,
490
+ nextPosition,
491
+ onCreate,
492
+ onUpdate,
493
+ }: CustomStageDialogProps) {
494
+ const { t } = useTranslation()
495
+ const isDark =
496
+ typeof document !== 'undefined' &&
497
+ document.documentElement.classList.contains('dark')
498
+ const fieldChoices = useMemo(
499
+ () => customStageFilterFields(columns),
500
+ [columns],
501
+ )
502
+
503
+ const [label, setLabel] = useState('')
504
+ const [color, setColor] = useState<string>(CUSTOM_STAGE_COLORS[0])
505
+ const [type, setType] = useState<CustomStageType>('stage')
506
+ const [filters, setFilters] = useState<CustomStageFilter[]>([])
507
+ const [saving, setSaving] = useState(false)
508
+
509
+ // Re-seed the form each time the dialog opens (create vs edit).
510
+ useEffect(() => {
511
+ if (!open) return
512
+ if (initial) {
513
+ setLabel(initial.label)
514
+ setColor(initial.color || CUSTOM_STAGE_COLORS[0])
515
+ setType(initial.type)
516
+ setFilters(
517
+ initial.filters?.length
518
+ ? initial.filters.map((f) => ({ ...f }))
519
+ : [emptyCustomStageFilter(fieldChoices[0]?.key ?? '')],
520
+ )
521
+ } else {
522
+ setLabel('')
523
+ setColor(CUSTOM_STAGE_COLORS[0])
524
+ setType('stage')
525
+ setFilters([emptyCustomStageFilter(fieldChoices[0]?.key ?? '')])
526
+ }
527
+ }, [open, initial, fieldChoices])
528
+
529
+ const patchFilter = (i: number, patch: Partial<CustomStageFilter>) =>
530
+ setFilters((prev) =>
531
+ prev.map((f, idx) => (idx === i ? { ...f, ...patch } : f)),
532
+ )
533
+ const addFilter = () =>
534
+ setFilters((prev) => [
535
+ ...prev,
536
+ emptyCustomStageFilter(fieldChoices[0]?.key ?? ''),
537
+ ])
538
+ const removeFilter = (i: number) =>
539
+ setFilters((prev) => prev.filter((_, idx) => idx !== i))
540
+
541
+ const valid = isCustomStageDraftValid({ label, type, filters })
542
+
543
+ const submit = async () => {
544
+ if (!valid || saving) return
545
+ setSaving(true)
546
+ // Only smart lanes carry filters; a normal stage sends an empty set.
547
+ const cleanFilters =
548
+ type === 'smart'
549
+ ? filters.filter((f) => f.field && String(f.value).trim() !== '')
550
+ : []
551
+ try {
552
+ if (initial) {
553
+ // PUT only accepts label/color/position/filters/enabled — model,
554
+ // type and key are immutable (ops #704), so we never send them.
555
+ await onUpdate(initial.id, {
556
+ label: label.trim(),
557
+ color,
558
+ filters: cleanFilters,
559
+ })
560
+ toast.success(
561
+ t('dynamic.custom_stages.updated', {
562
+ defaultValue: 'Etapa actualizada',
563
+ }),
564
+ )
565
+ } else {
566
+ await onCreate({
567
+ model,
568
+ key: slugifyStageKey(label),
569
+ label: label.trim(),
570
+ color,
571
+ position: nextPosition,
572
+ type,
573
+ filters: cleanFilters,
574
+ enabled: true,
575
+ })
576
+ toast.success(
577
+ t('dynamic.custom_stages.created', {
578
+ defaultValue: 'Etapa creada',
579
+ }),
580
+ )
581
+ }
582
+ onOpenChange(false)
583
+ } catch {
584
+ // toast already surfaced by the hook; keep the draft open.
585
+ } finally {
586
+ setSaving(false)
587
+ }
588
+ }
589
+
590
+ return (
591
+ <Dialog open={open} onOpenChange={onOpenChange}>
592
+ <DialogContent className="sm:max-w-lg">
593
+ <DialogHeader>
594
+ <DialogTitle>
595
+ {initial
596
+ ? t('dynamic.custom_stages.edit_title', {
597
+ defaultValue: 'Editar etapa',
598
+ })
599
+ : t('dynamic.custom_stages.new_title', {
600
+ defaultValue: 'Nueva etapa',
601
+ })}
602
+ </DialogTitle>
603
+ <DialogDescription>
604
+ {t('dynamic.custom_stages.dialog_description', {
605
+ defaultValue:
606
+ 'Agrega una columna al tablero. Una etapa normal recibe tarjetas al arrastrarlas; una etapa inteligente muestra las tarjetas que cumplen condiciones.',
607
+ })}
608
+ </DialogDescription>
609
+ </DialogHeader>
610
+
611
+ <div className="flex flex-col gap-4">
612
+ {/* Name */}
613
+ <div className="flex flex-col gap-1.5">
614
+ <Label className="text-xs">
615
+ {t('dynamic.custom_stages.name_label', {
616
+ defaultValue: 'Nombre',
617
+ })}
618
+ </Label>
619
+ <Input
620
+ value={label}
621
+ onChange={(e) => setLabel(e.target.value)}
622
+ placeholder={t('dynamic.custom_stages.name_placeholder', {
623
+ defaultValue: 'Nombre de la etapa',
624
+ })}
625
+ data-testid="custom-stage-name"
626
+ autoFocus
627
+ />
628
+ </div>
629
+
630
+ {/* Color palette */}
631
+ <div className="flex flex-col gap-1.5">
632
+ <Label className="text-xs">
633
+ {t('dynamic.custom_stages.color_label', {
634
+ defaultValue: 'Color',
635
+ })}
636
+ </Label>
637
+ <div className="flex flex-wrap gap-1.5" data-testid="custom-stage-colors">
638
+ {CUSTOM_STAGE_COLORS.map((c) => {
639
+ const style = generateBadgeStyles(c, { isDark })
640
+ const selected = c === color
641
+ return (
642
+ <button
643
+ key={c}
644
+ type="button"
645
+ onClick={() => setColor(c)}
646
+ className={`size-6 rounded-full border-2 transition-transform hover:scale-110 ${
647
+ selected
648
+ ? 'border-foreground'
649
+ : 'border-transparent'
650
+ }`}
651
+ style={{
652
+ backgroundColor:
653
+ style.backgroundColor ||
654
+ (style as any).background,
655
+ }}
656
+ aria-label={c}
657
+ aria-pressed={selected}
658
+ data-testid={`custom-stage-color-${c}`}
659
+ />
660
+ )
661
+ })}
662
+ </div>
663
+ </div>
664
+
665
+ {/* Type */}
666
+ <div className="flex flex-col gap-1.5">
667
+ <Label className="text-xs">
668
+ {t('dynamic.custom_stages.type_label', {
669
+ defaultValue: 'Tipo',
670
+ })}
671
+ </Label>
672
+ <Select
673
+ value={type}
674
+ onValueChange={(v) => setType(v as CustomStageType)}
675
+ disabled={!!initial}
676
+ >
677
+ <SelectTrigger
678
+ data-testid="custom-stage-type"
679
+ disabled={!!initial}
680
+ >
681
+ <SelectValue />
682
+ </SelectTrigger>
683
+ <SelectContent>
684
+ <SelectItem value="stage">
685
+ {t('dynamic.custom_stages.type_stage', {
686
+ defaultValue: 'Etapa normal',
687
+ })}
688
+ </SelectItem>
689
+ <SelectItem value="smart">
690
+ {t('dynamic.custom_stages.type_smart', {
691
+ defaultValue: 'Etapa inteligente',
692
+ })}
693
+ </SelectItem>
694
+ </SelectContent>
695
+ </Select>
696
+ </div>
697
+
698
+ {/* Condition builder — smart lanes only */}
699
+ {type === 'smart' && (
700
+ <div className="flex flex-col gap-2 rounded-md border bg-muted/30 p-3">
701
+ <div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
702
+ <Filter className="h-3.5 w-3.5" />
703
+ {t('dynamic.custom_stages.conditions_label', {
704
+ defaultValue: 'Condiciones',
705
+ })}
706
+ </div>
707
+ {filters.map((f, i) => (
708
+ <div
709
+ key={i}
710
+ className="grid grid-cols-[1fr_auto_1fr_auto] items-center gap-1.5"
711
+ data-testid={`custom-stage-condition-${i}`}
712
+ >
713
+ <Select
714
+ value={f.field}
715
+ onValueChange={(v) => patchFilter(i, { field: v })}
716
+ >
717
+ <SelectTrigger className="h-8 text-xs" data-testid={`custom-stage-condition-field-${i}`}>
718
+ <SelectValue
719
+ placeholder={t(
720
+ 'dynamic.custom_stages.field_placeholder',
721
+ { defaultValue: 'Campo' },
722
+ )}
723
+ />
724
+ </SelectTrigger>
725
+ <SelectContent>
726
+ {fieldChoices.map((c) => (
727
+ <SelectItem key={c.key} value={c.key} className="text-xs">
728
+ {t(c.label, { defaultValue: c.label })}
729
+ </SelectItem>
730
+ ))}
731
+ </SelectContent>
732
+ </Select>
733
+ <Select
734
+ value={f.op}
735
+ onValueChange={(v) =>
736
+ patchFilter(i, { op: v as CustomStageFilterOp })
737
+ }
738
+ >
739
+ <SelectTrigger className="h-8 w-[104px] text-xs" data-testid={`custom-stage-condition-op-${i}`}>
740
+ <SelectValue />
741
+ </SelectTrigger>
742
+ <SelectContent>
743
+ {CUSTOM_STAGE_FILTER_OPS.map((op) => (
744
+ <SelectItem key={op} value={op} className="text-xs">
745
+ {t(`dynamic.custom_stages.op.${op}`, {
746
+ defaultValue:
747
+ op === 'eq'
748
+ ? 'es igual'
749
+ : op === 'neq'
750
+ ? 'distinto'
751
+ : op === 'contains'
752
+ ? 'contiene'
753
+ : 'en lista',
754
+ })}
755
+ </SelectItem>
756
+ ))}
757
+ </SelectContent>
758
+ </Select>
759
+ <Input
760
+ value={f.value}
761
+ onChange={(e) => patchFilter(i, { value: e.target.value })}
762
+ placeholder={t('dynamic.custom_stages.value_placeholder', {
763
+ defaultValue: 'Valor',
764
+ })}
765
+ className="h-8 text-xs"
766
+ data-testid={`custom-stage-condition-value-${i}`}
767
+ />
768
+ <button
769
+ type="button"
770
+ onClick={() => removeFilter(i)}
771
+ disabled={filters.length === 1}
772
+ className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-destructive disabled:opacity-40"
773
+ aria-label={t('dynamic.custom_stages.remove_condition', {
774
+ defaultValue: 'Quitar condición',
775
+ })}
776
+ data-testid={`custom-stage-condition-remove-${i}`}
777
+ >
778
+ <X className="h-3.5 w-3.5" />
779
+ </button>
780
+ </div>
781
+ ))}
782
+ <Button
783
+ variant="ghost"
784
+ size="sm"
785
+ className="h-7 justify-start gap-1 text-xs"
786
+ onClick={addFilter}
787
+ data-testid="custom-stage-add-condition"
788
+ >
789
+ <Plus className="h-3.5 w-3.5" />
790
+ {t('dynamic.custom_stages.add_condition', {
791
+ defaultValue: 'Agregar condición',
792
+ })}
793
+ </Button>
794
+ </div>
795
+ )}
796
+ </div>
797
+
798
+ <DialogFooter>
799
+ <Button
800
+ variant="outline"
801
+ onClick={() => onOpenChange(false)}
802
+ disabled={saving}
803
+ >
804
+ {t('dynamic.custom_stages.cancel', { defaultValue: 'Cancelar' })}
805
+ </Button>
806
+ <Button
807
+ onClick={() => void submit()}
808
+ disabled={!valid || saving}
809
+ data-testid="custom-stage-save"
810
+ >
811
+ {initial
812
+ ? t('dynamic.custom_stages.save', { defaultValue: 'Guardar' })
813
+ : t('dynamic.custom_stages.create', { defaultValue: 'Crear etapa' })}
814
+ </Button>
815
+ </DialogFooter>
816
+ </DialogContent>
817
+ </Dialog>
818
+ )
819
+ }
820
+
821
+ // ---------------------------------------------------------------------------
822
+ // Delete confirmation
823
+ // ---------------------------------------------------------------------------
824
+
825
+ export interface CustomStageDeleteDialogProps {
826
+ open: boolean
827
+ onOpenChange: (open: boolean) => void
828
+ stage: CustomStage | null
829
+ /** Other lanes a real stage's cards can be reassigned to (key excluded). */
830
+ reassignTargets?: { key: string; label: string }[]
831
+ /** `reassignTo` is set on retry after a 409 (real stage with cards). */
832
+ onConfirm: (stage: CustomStage, reassignTo?: string) => Promise<void>
833
+ }
834
+
835
+ /**
836
+ * Confirms deleting a custom lane. A 409 (the backend rejected because cards
837
+ * still sit on a real stage) doesn't close the dialog — it reads `meta.cards`
838
+ * from the response, shows the count, and offers a target lane to reassign the
839
+ * cards to; confirming again retries the delete with `reassign_to`.
840
+ */
841
+ export function CustomStageDeleteDialog({
842
+ open,
843
+ onOpenChange,
844
+ stage,
845
+ reassignTargets = [],
846
+ onConfirm,
847
+ }: CustomStageDeleteDialogProps) {
848
+ const { t } = useTranslation()
849
+ const [busy, setBusy] = useState(false)
850
+ const [conflict, setConflict] = useState(false)
851
+ const [cardCount, setCardCount] = useState<number | null>(null)
852
+ const [reassignTo, setReassignTo] = useState<string>('')
853
+
854
+ useEffect(() => {
855
+ if (open) {
856
+ setConflict(false)
857
+ setCardCount(null)
858
+ setReassignTo('')
859
+ }
860
+ }, [open])
861
+
862
+ if (!stage) return null
863
+
864
+ const targets = reassignTargets.filter((tg) => tg.key !== stage.key)
865
+
866
+ const confirm = async () => {
867
+ // In conflict mode the delete only proceeds once a target is chosen.
868
+ if (conflict && !reassignTo) return
869
+ setBusy(true)
870
+ try {
871
+ await onConfirm(stage, conflict ? reassignTo : undefined)
872
+ toast.success(
873
+ t('dynamic.custom_stages.deleted', {
874
+ defaultValue: 'Etapa eliminada',
875
+ }),
876
+ )
877
+ onOpenChange(false)
878
+ } catch (e: any) {
879
+ if (e?.response?.status === 409) {
880
+ // Cards still on the stage — surface the count and let the user
881
+ // pick a lane to move them to, then retry with reassign_to.
882
+ setCardCount(e?.response?.data?.meta?.cards ?? null)
883
+ setConflict(true)
884
+ } else {
885
+ onOpenChange(false)
886
+ }
887
+ } finally {
888
+ setBusy(false)
889
+ }
890
+ }
891
+
892
+ return (
893
+ <Dialog open={open} onOpenChange={onOpenChange}>
894
+ <DialogContent className="sm:max-w-md">
895
+ <DialogHeader>
896
+ <DialogTitle>
897
+ {t('dynamic.custom_stages.delete_title', {
898
+ defaultValue: 'Eliminar etapa',
899
+ })}
900
+ </DialogTitle>
901
+ <DialogDescription>
902
+ {conflict
903
+ ? t('dynamic.custom_stages.delete_conflict', {
904
+ defaultValue:
905
+ 'La etapa tiene {{count}} tarjeta(s). Elige a qué columna moverlas antes de eliminarla.',
906
+ count: cardCount ?? 0,
907
+ })
908
+ : t('dynamic.custom_stages.delete_confirm', {
909
+ defaultValue:
910
+ '¿Eliminar la etapa "{{label}}"? Esta acción no se puede deshacer.',
911
+ label: t(stage.label, { defaultValue: stage.label }),
912
+ })}
913
+ </DialogDescription>
914
+ </DialogHeader>
915
+
916
+ {conflict && (
917
+ <div className="flex flex-col gap-1.5">
918
+ <Label className="text-xs">
919
+ {t('dynamic.custom_stages.reassign_label', {
920
+ defaultValue: 'Mover tarjetas a',
921
+ })}
922
+ </Label>
923
+ <Select value={reassignTo} onValueChange={setReassignTo}>
924
+ <SelectTrigger data-testid="custom-stage-reassign">
925
+ <SelectValue
926
+ placeholder={t(
927
+ 'dynamic.custom_stages.reassign_placeholder',
928
+ { defaultValue: 'Elige una columna' },
929
+ )}
930
+ />
931
+ </SelectTrigger>
932
+ <SelectContent>
933
+ {targets.map((tg) => (
934
+ <SelectItem key={tg.key} value={tg.key}>
935
+ {t(tg.label, { defaultValue: tg.label })}
936
+ </SelectItem>
937
+ ))}
938
+ </SelectContent>
939
+ </Select>
940
+ </div>
941
+ )}
942
+
943
+ <DialogFooter>
944
+ <Button
945
+ variant="outline"
946
+ onClick={() => onOpenChange(false)}
947
+ disabled={busy}
948
+ >
949
+ {t('dynamic.custom_stages.cancel', { defaultValue: 'Cancelar' })}
950
+ </Button>
951
+ <Button
952
+ variant="destructive"
953
+ onClick={() => void confirm()}
954
+ disabled={busy || (conflict && !reassignTo)}
955
+ data-testid="custom-stage-delete-confirm"
956
+ >
957
+ {conflict
958
+ ? t('dynamic.custom_stages.reassign_and_delete', {
959
+ defaultValue: 'Mover y eliminar',
960
+ })
961
+ : t('dynamic.custom_stages.delete', {
962
+ defaultValue: 'Eliminar',
963
+ })}
964
+ </Button>
965
+ </DialogFooter>
966
+ </DialogContent>
967
+ </Dialog>
968
+ )
969
+ }
970
+
971
+ // ---------------------------------------------------------------------------
972
+ // Smart (virtual) lane — fetches its own records from the model's list
973
+ // ---------------------------------------------------------------------------
974
+
975
+ export interface SmartLaneProps {
976
+ stage: CustomStage
977
+ model: string
978
+ /** Org-scoped list endpoint base (same as the kanban's). */
979
+ endpoint?: string
980
+ /** Board-wide static filters always applied (never shown as a chip). */
981
+ defaultFilters?: Record<string, any>
982
+ pageSize?: number
983
+ isDark: boolean
984
+ /** Renders one card the same way the board's real lanes do (read-only). */
985
+ renderCard: (card: any) => React.ReactNode
986
+ /** Refetch trigger — bump to re-run the lane's query. */
987
+ refreshTrigger?: any
988
+ onEdit: (stage: CustomStage) => void
989
+ onDelete: (stage: CustomStage) => void
990
+ }
991
+
992
+ /**
993
+ * A virtual lane defined by `filters`. It runs its OWN list query (the board's
994
+ * shared records don't include it), so it stays correct regardless of what the
995
+ * main board page loaded. Cards render read-only — a smart lane is a saved view,
996
+ * not a drop target — and the header carries a funnel glyph + the custom menu.
997
+ */
998
+ export function SmartLane({
999
+ stage,
1000
+ model,
1001
+ endpoint,
1002
+ defaultFilters,
1003
+ pageSize = 50,
1004
+ isDark,
1005
+ renderCard,
1006
+ refreshTrigger,
1007
+ onEdit,
1008
+ onDelete,
1009
+ }: SmartLaneProps) {
1010
+ const { t } = useTranslation()
1011
+ const api = useApi()
1012
+ const [records, setRecords] = useState<any[]>([])
1013
+ const [total, setTotal] = useState<number | null>(null)
1014
+ const [loading, setLoading] = useState(true)
1015
+
1016
+ const params = useMemo(
1017
+ () => smartLaneParams(stage.filters),
1018
+ [stage.filters],
1019
+ )
1020
+
1021
+ useEffect(() => {
1022
+ let cancelled = false
1023
+ setLoading(true)
1024
+ api
1025
+ .get(endpoint || `/data/${model}`, {
1026
+ params: {
1027
+ page: 1,
1028
+ per_page: pageSize,
1029
+ ...defaultFilters,
1030
+ ...params,
1031
+ },
1032
+ })
1033
+ .then((res: { data: ApiResponse<any[]> & { meta?: any } }) => {
1034
+ if (cancelled) return
1035
+ if (res.data.success) setRecords(res.data.data || [])
1036
+ setTotal(res.data.meta?.total ?? res.data.meta?.count ?? null)
1037
+ })
1038
+ .catch(() => {
1039
+ if (!cancelled) setRecords([])
1040
+ })
1041
+ .finally(() => {
1042
+ if (!cancelled) setLoading(false)
1043
+ })
1044
+ return () => {
1045
+ cancelled = true
1046
+ }
1047
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1048
+ }, [api, endpoint, model, pageSize, JSON.stringify(params), JSON.stringify(defaultFilters ?? {}), refreshTrigger])
1049
+
1050
+ const headerStyle = generateBadgeStyles(stage.color || optionColor(stage.key), {
1051
+ isDark,
1052
+ })
1053
+ const count = total ?? records.length
1054
+
1055
+ return (
1056
+ <div
1057
+ className="flex min-w-[280px] max-w-[420px] flex-1 shrink-0 flex-col rounded-xl border border-dashed bg-muted/20"
1058
+ data-smart-stage={stage.key}
1059
+ data-testid={`smart-lane-${stage.key}`}
1060
+ >
1061
+ <div className="flex items-center justify-between gap-2 px-3 py-2.5">
1062
+ <div className="flex min-w-0 items-center gap-2">
1063
+ <Badge
1064
+ variant="outline"
1065
+ className="border-0 text-xs font-semibold"
1066
+ style={headerStyle}
1067
+ >
1068
+ {t(stage.label, { defaultValue: stage.label })}
1069
+ </Badge>
1070
+ <span
1071
+ className="text-muted-foreground"
1072
+ title={t('dynamic.custom_stages.smart_hint', {
1073
+ defaultValue: 'Etapa inteligente (por condiciones)',
1074
+ })}
1075
+ >
1076
+ <Filter className="h-3 w-3" />
1077
+ </span>
1078
+ <span className="text-xs font-medium tabular-nums text-muted-foreground">
1079
+ {count}
1080
+ </span>
1081
+ </div>
1082
+ <CustomStageLaneMenu
1083
+ stage={stage}
1084
+ onEdit={onEdit}
1085
+ onDelete={onDelete}
1086
+ />
1087
+ </div>
1088
+ <div className="flex min-h-[55vh] max-h-[70vh] min-w-0 flex-col gap-2 overflow-y-auto px-2 pb-3">
1089
+ {loading && records.length === 0 ? (
1090
+ <>
1091
+ <Skeleton className="h-20 w-full" />
1092
+ <Skeleton className="h-20 w-full" />
1093
+ </>
1094
+ ) : records.length === 0 ? (
1095
+ <p className="px-1 py-6 text-center text-xs text-muted-foreground">
1096
+ {t('dynamic.custom_stages.smart_empty', {
1097
+ defaultValue: 'Sin tarjetas que cumplan las condiciones',
1098
+ })}
1099
+ </p>
1100
+ ) : (
1101
+ records.map((card) => (
1102
+ <React.Fragment key={String(card.id)}>
1103
+ {renderCard(card)}
1104
+ </React.Fragment>
1105
+ ))
1106
+ )}
1107
+ </div>
1108
+ </div>
1109
+ )
1110
+ }
1111
+
1112
+ // Re-export the api client type so hosts can see the transport contract.
1113
+ export type { ApiClient }