@asteby/metacore-runtime-react 23.5.1 → 23.7.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 +16 -0
- package/dist/action-modal-dispatcher.js +8 -7
- package/dist/custom-stages.d.ts +171 -0
- package/dist/custom-stages.d.ts.map +1 -0
- package/dist/custom-stages.js +535 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +7 -4
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +81 -32
- package/dist/field-grid.d.ts +17 -0
- package/dist/field-grid.d.ts.map +1 -0
- package/dist/field-grid.js +12 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/stage-automations.d.ts +75 -0
- package/dist/stage-automations.d.ts.map +1 -0
- package/dist/stage-automations.js +264 -0
- package/dist/types.d.ts +29 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/action-modal-grid-layout.test.tsx +86 -0
- package/src/__tests__/custom-stages.test.tsx +480 -0
- package/src/__tests__/stage-automations.test.tsx +243 -0
- package/src/action-modal-dispatcher.tsx +29 -30
- package/src/custom-stages.tsx +1113 -0
- package/src/dialogs/dynamic-record.tsx +14 -8
- package/src/dynamic-kanban.tsx +203 -5
- package/src/field-grid.tsx +57 -0
- package/src/index.ts +39 -0
- package/src/stage-automations.tsx +580 -0
- package/src/types.ts +26 -0
|
@@ -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 }
|