@asteby/metacore-runtime-react 23.8.0 → 23.9.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.
- package/CHANGELOG.md +46 -0
- package/dist/custom-stages.d.ts +96 -1
- package/dist/custom-stages.d.ts.map +1 -1
- package/dist/custom-stages.js +259 -24
- package/dist/dynamic-form-schema.d.ts +18 -1
- package/dist/dynamic-form-schema.d.ts.map +1 -1
- package/dist/dynamic-form-schema.js +43 -0
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +30 -9
- package/dist/dynamic-kanban.d.ts +8 -0
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +188 -16
- package/dist/dynamic-line-items.d.ts.map +1 -1
- package/dist/dynamic-line-items.js +24 -3
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +5 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/stage-overrides.d.ts +25 -0
- package/dist/stage-overrides.d.ts.map +1 -0
- package/dist/stage-overrides.js +64 -0
- package/dist/types.d.ts +69 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/dependent-enum-form.test.tsx +84 -0
- package/src/__tests__/dependent-enum-options.test.ts +67 -0
- package/src/__tests__/dynamic-kanban.test.tsx +75 -0
- package/src/__tests__/stage-overrides.test.tsx +293 -0
- package/src/custom-stages.tsx +716 -109
- package/src/dynamic-form-schema.ts +44 -1
- package/src/dynamic-form.tsx +76 -28
- package/src/dynamic-kanban.tsx +236 -25
- package/src/dynamic-line-items.tsx +26 -2
- package/src/dynamic-table.tsx +4 -0
- package/src/index.ts +13 -0
- package/src/stage-overrides.ts +95 -0
- package/src/types.ts +63 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// callers (and unit tests) can use the zod schema without pulling in React or
|
|
3
3
|
// metacore-ui primitives.
|
|
4
4
|
import { z, type ZodTypeAny } from 'zod'
|
|
5
|
-
import type { ActionFieldDef, FieldValidation, FieldOptionsConfig } from './types'
|
|
5
|
+
import type { ActionFieldDef, FieldValidation, FieldOptionsConfig, OptionDef } from './types'
|
|
6
6
|
import { resolveValidatorToken } from './use-org-config-bridge'
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -287,6 +287,49 @@ export function resolveDependsValue(
|
|
|
287
287
|
return String(raw)
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
+
/**
|
|
291
|
+
* Filters a STATIC enum's `options[]` by each option's `when` gate against the
|
|
292
|
+
* current form values. Pure — no React, no side effects.
|
|
293
|
+
*
|
|
294
|
+
* Rule per option:
|
|
295
|
+
* - No `when` → always included (retrocompat; existing enums untouched).
|
|
296
|
+
* - With `when`: the gating field is `when.field ?? dependsOn`. If neither is
|
|
297
|
+
* present the option is included (nothing to gate on). Otherwise the form's
|
|
298
|
+
* value for that field is compared AS STRING: included when (no `in`, or
|
|
299
|
+
* value ∈ `in`) AND (no `not_in`, or value ∉ `not_in`). Tolerates the
|
|
300
|
+
* snake_case `not_in` the kernel serves alongside camelCase `notIn`.
|
|
301
|
+
*
|
|
302
|
+
* `formValues` is the flat map of the surrounding form/row values the gating
|
|
303
|
+
* field is read from; `dependsOn` is the containing field's declared dependency
|
|
304
|
+
* used as the default gating field.
|
|
305
|
+
*/
|
|
306
|
+
export function applyOptionWhen(
|
|
307
|
+
options: OptionDef[] | undefined,
|
|
308
|
+
formValues: Record<string, any> | null | undefined,
|
|
309
|
+
dependsOn?: string,
|
|
310
|
+
): OptionDef[] {
|
|
311
|
+
if (!Array.isArray(options)) return []
|
|
312
|
+
return options.filter((opt) => {
|
|
313
|
+
const when = opt?.when
|
|
314
|
+
if (!when) return true
|
|
315
|
+
const gate = (typeof when.field === 'string' && when.field.trim() !== '')
|
|
316
|
+
? when.field.trim()
|
|
317
|
+
: dependsOn
|
|
318
|
+
if (!gate) return true
|
|
319
|
+
const raw = formValues ? formValues[gate] : undefined
|
|
320
|
+
const current = raw == null ? '' : String(raw)
|
|
321
|
+
const inList = when.in
|
|
322
|
+
const notIn = when.notIn ?? when.not_in
|
|
323
|
+
if (Array.isArray(inList) && inList.length > 0) {
|
|
324
|
+
if (!inList.some((v) => String(v) === current)) return false
|
|
325
|
+
}
|
|
326
|
+
if (Array.isArray(notIn) && notIn.length > 0) {
|
|
327
|
+
if (notIn.some((v) => String(v) === current)) return false
|
|
328
|
+
}
|
|
329
|
+
return true
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
|
|
290
333
|
/**
|
|
291
334
|
* Reads a field's enriched options-resolution config, tolerating the camelCase
|
|
292
335
|
* `optionsConfig` (authored SDK shape) and the snake_case `options_config` the
|
package/src/dynamic-form.tsx
CHANGED
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
resolveWidget,
|
|
21
21
|
isLineItemsField,
|
|
22
22
|
evaluateBalance,
|
|
23
|
+
applyOptionWhen,
|
|
24
|
+
getDependsOn,
|
|
23
25
|
} from './dynamic-form-schema'
|
|
24
26
|
import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
|
|
25
27
|
import { DynamicLineItems } from './dynamic-line-items'
|
|
@@ -111,32 +113,17 @@ export function DynamicForm({
|
|
|
111
113
|
return (
|
|
112
114
|
<form onSubmit={handleSubmit} className="grid gap-4">
|
|
113
115
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
114
|
-
{fields.map((field) =>
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
{field.label}
|
|
126
|
-
{field.required && <span className="text-red-500 ml-1">*</span>}
|
|
127
|
-
</Label>
|
|
128
|
-
<FieldRenderer
|
|
129
|
-
field={field}
|
|
130
|
-
value={values[field.key]}
|
|
131
|
-
onChange={(v: any) => update(field.key, v)}
|
|
132
|
-
initialValues={initialValues}
|
|
133
|
-
/>
|
|
134
|
-
{errors[field.key] && (
|
|
135
|
-
<span className="text-red-500 text-sm" role="alert">{errors[field.key]}</span>
|
|
136
|
-
)}
|
|
137
|
-
</div>
|
|
138
|
-
)
|
|
139
|
-
})}
|
|
116
|
+
{fields.map((field) => (
|
|
117
|
+
<FieldRow
|
|
118
|
+
key={field.key}
|
|
119
|
+
field={field}
|
|
120
|
+
value={values[field.key]}
|
|
121
|
+
onChange={(v: any) => update(field.key, v)}
|
|
122
|
+
values={values}
|
|
123
|
+
error={errors[field.key]}
|
|
124
|
+
initialValues={initialValues}
|
|
125
|
+
/>
|
|
126
|
+
))}
|
|
140
127
|
</div>
|
|
141
128
|
<div className="flex justify-end gap-2 pt-2">
|
|
142
129
|
{onCancel && (
|
|
@@ -158,6 +145,61 @@ interface FieldRendererProps {
|
|
|
158
145
|
onChange: (v: any) => void
|
|
159
146
|
/** The form's initial record — used to seed an FK picker's existing label/image. */
|
|
160
147
|
initialValues?: Record<string, any>
|
|
148
|
+
/**
|
|
149
|
+
* The full flat map of current form values — read by a STATIC enum select to
|
|
150
|
+
* gate its options by a sibling field's value (`applyOptionWhen`).
|
|
151
|
+
*/
|
|
152
|
+
values?: Record<string, any>
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface FieldRowProps extends FieldRendererProps {
|
|
156
|
+
error?: string
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// One form field row: label + renderer + inline error. Encapsulated as its own
|
|
160
|
+
// component so a STATIC enum select whose options are all gated out by a sibling
|
|
161
|
+
// value (`when`) can hide the ENTIRE row (label included) and run its reset
|
|
162
|
+
// effect with valid hook ordering.
|
|
163
|
+
function FieldRow({ field, value, onChange, values, error, initialValues }: FieldRowProps) {
|
|
164
|
+
const isStaticSelect =
|
|
165
|
+
resolveWidget(field) === 'select' && !field.ref && Array.isArray(field.options)
|
|
166
|
+
const effectiveOptions = isStaticSelect
|
|
167
|
+
? applyOptionWhen(field.options, values, getDependsOn(field))
|
|
168
|
+
: undefined
|
|
169
|
+
|
|
170
|
+
// Reset a selection that the current sibling value no longer permits (e.g.
|
|
171
|
+
// the parent switched away from the value that made this option valid).
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (!isStaticSelect || !effectiveOptions) return
|
|
174
|
+
if (value && !effectiveOptions.some((o) => String(o.value) === String(value))) {
|
|
175
|
+
onChange('')
|
|
176
|
+
}
|
|
177
|
+
}, [isStaticSelect, effectiveOptions, value, onChange])
|
|
178
|
+
|
|
179
|
+
// No option applies under the current sibling value → hide the whole field.
|
|
180
|
+
if (isStaticSelect && effectiveOptions && effectiveOptions.length === 0) return null
|
|
181
|
+
|
|
182
|
+
const fullWidth =
|
|
183
|
+
isLineItemsField(field) ||
|
|
184
|
+
resolveWidget(field) === 'textarea' ||
|
|
185
|
+
resolveWidget(field) === 'richtext'
|
|
186
|
+
return (
|
|
187
|
+
<div className={'grid gap-2 ' + (fullWidth ? 'sm:col-span-2' : '')}>
|
|
188
|
+
<Label htmlFor={field.key}>
|
|
189
|
+
{field.label}
|
|
190
|
+
{field.required && <span className="text-red-500 ml-1">*</span>}
|
|
191
|
+
</Label>
|
|
192
|
+
<FieldRenderer
|
|
193
|
+
field={field}
|
|
194
|
+
value={value}
|
|
195
|
+
onChange={onChange}
|
|
196
|
+
initialValues={initialValues}
|
|
197
|
+
values={values}
|
|
198
|
+
effectiveOptions={effectiveOptions}
|
|
199
|
+
/>
|
|
200
|
+
{error && <span className="text-red-500 text-sm" role="alert">{error}</span>}
|
|
201
|
+
</div>
|
|
202
|
+
)
|
|
161
203
|
}
|
|
162
204
|
|
|
163
205
|
// seedOptionFromSibling builds a pre-resolved option for an FK field from the
|
|
@@ -187,7 +229,13 @@ function seedOptionFromSibling(
|
|
|
187
229
|
}
|
|
188
230
|
}
|
|
189
231
|
|
|
190
|
-
function FieldRenderer({
|
|
232
|
+
function FieldRenderer({
|
|
233
|
+
field,
|
|
234
|
+
value,
|
|
235
|
+
onChange,
|
|
236
|
+
initialValues,
|
|
237
|
+
effectiveOptions,
|
|
238
|
+
}: FieldRendererProps & { effectiveOptions?: import('./types').OptionDef[] }) {
|
|
191
239
|
// Repeatable line-items group → render the row grid. Its value is an array
|
|
192
240
|
// of row objects rather than a scalar.
|
|
193
241
|
if (isLineItemsField(field)) {
|
|
@@ -228,7 +276,7 @@ function FieldRenderer({ field, value, onChange, initialValues }: FieldRendererP
|
|
|
228
276
|
<Select value={value || ''} onValueChange={onChange}>
|
|
229
277
|
<SelectTrigger className="w-full"><SelectValue placeholder={field.placeholder || 'Seleccionar...'} /></SelectTrigger>
|
|
230
278
|
<SelectContent>
|
|
231
|
-
{field.options?.map((opt) => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)}
|
|
279
|
+
{(effectiveOptions ?? field.options)?.map((opt) => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)}
|
|
232
280
|
</SelectContent>
|
|
233
281
|
</Select>
|
|
234
282
|
)
|
package/src/dynamic-kanban.tsx
CHANGED
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
MoreHorizontal,
|
|
55
55
|
RotateCcw,
|
|
56
56
|
Search,
|
|
57
|
+
Settings2,
|
|
57
58
|
Tag,
|
|
58
59
|
ToggleLeft,
|
|
59
60
|
Type,
|
|
@@ -99,13 +100,18 @@ import {
|
|
|
99
100
|
splitCustomStages,
|
|
100
101
|
mergeLaneStages,
|
|
101
102
|
resolveSmartLanes,
|
|
103
|
+
cardMatchesStageFilters,
|
|
104
|
+
smartLaneParams,
|
|
102
105
|
AddStageColumn,
|
|
103
|
-
CustomStageLaneMenu,
|
|
104
106
|
CustomStageDialog,
|
|
105
107
|
CustomStageDeleteDialog,
|
|
108
|
+
StageConfigDialog,
|
|
106
109
|
SmartLane,
|
|
107
110
|
type CustomStage,
|
|
111
|
+
type CustomStageFilter,
|
|
112
|
+
type StageConfigTarget,
|
|
108
113
|
} from './custom-stages'
|
|
114
|
+
import { useStageOverrides } from './stage-overrides'
|
|
109
115
|
import { useDynamicFilters } from './use-dynamic-filters'
|
|
110
116
|
import {
|
|
111
117
|
FilterChipsRow,
|
|
@@ -117,7 +123,7 @@ import { useMetadataCache } from './metadata-cache'
|
|
|
117
123
|
import { ActivityValueRenderer } from './activity-value-renderer'
|
|
118
124
|
import { DynamicIcon } from './dynamic-icon'
|
|
119
125
|
import { isColumnVisibleInTable } from './column-visibility'
|
|
120
|
-
import { isRowActionVisible } from './dynamic-columns'
|
|
126
|
+
import { isRowActionVisible, relationKeyFor } from './dynamic-columns'
|
|
121
127
|
import { useCan, usePermissionsActive, resolveRowActions } from './permissions-context'
|
|
122
128
|
import { useDynamicRowActions } from './dynamic-row-actions'
|
|
123
129
|
import { useStageLayout } from './stage-layout'
|
|
@@ -317,6 +323,27 @@ export function selectCardColumns(
|
|
|
317
323
|
return { title, fields }
|
|
318
324
|
}
|
|
319
325
|
|
|
326
|
+
/** The all-zeros UUID — a Go zero-value FK serialized as "set" when it isn't. */
|
|
327
|
+
const ZERO_UUID = /^0{8}-0{4}-0{4}-0{4}-0{12}$/
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The value a card cell should render for a column — same resolution the
|
|
331
|
+
* table's cells apply. An FK column (`<rel>_id`) prefers the backend-resolved
|
|
332
|
+
* sibling object (`card.<rel>`, e.g. `{ name }` / `{ value, label }`) over the
|
|
333
|
+
* raw UUID; a zero-UUID FK counts as unset (renders the em-dash). Pure —
|
|
334
|
+
* exported for unit tests.
|
|
335
|
+
*/
|
|
336
|
+
export function cardCellValue(card: any, col: ColumnDefinition): unknown {
|
|
337
|
+
const raw = card?.[col.key]
|
|
338
|
+
if (typeof raw === 'string' && ZERO_UUID.test(raw)) return null
|
|
339
|
+
const relKey = relationKeyFor(col)
|
|
340
|
+
if (relKey !== col.key) {
|
|
341
|
+
const sibling = card?.[relKey]
|
|
342
|
+
if (sibling && typeof sibling === 'object') return sibling
|
|
343
|
+
}
|
|
344
|
+
return raw
|
|
345
|
+
}
|
|
346
|
+
|
|
320
347
|
/**
|
|
321
348
|
* Whether a card passes a lane funnel. Picked select/facet `values` match by
|
|
322
349
|
* equality (IN — the card's field value must be one of them); a free-text
|
|
@@ -489,10 +516,16 @@ export function DynamicKanban({
|
|
|
489
516
|
// the host has no `/custom-stages` endpoint — the "+ Agregar etapa" column
|
|
490
517
|
// and lane menus simply don't render.
|
|
491
518
|
const customStages = useCustomStages(model)
|
|
519
|
+
// Per-org overrides for DECLARED lanes (rename/recolor/conditions). Degrades
|
|
520
|
+
// to no-op when the host has no `/stage-overrides` endpoint — the ⚙ gear then
|
|
521
|
+
// hides on declared lanes (custom lanes keep it via /custom-stages).
|
|
522
|
+
const stageOverrides = useStageOverrides(model)
|
|
492
523
|
// Dialog state: create/edit a stage, and the delete confirmation.
|
|
493
524
|
const [stageDialogOpen, setStageDialogOpen] = useState(false)
|
|
494
525
|
const [editingStage, setEditingStage] = useState<CustomStage | null>(null)
|
|
495
526
|
const [deletingStage, setDeletingStage] = useState<CustomStage | null>(null)
|
|
527
|
+
// The gear (⚙) "Configurar etapa" dialog — one UI for declared + custom lanes.
|
|
528
|
+
const [configTarget, setConfigTarget] = useState<StageConfigTarget | null>(null)
|
|
496
529
|
const openCreateStage = useCallback(() => {
|
|
497
530
|
setEditingStage(null)
|
|
498
531
|
setStageDialogOpen(true)
|
|
@@ -628,6 +661,7 @@ export function DynamicKanban({
|
|
|
628
661
|
const r = (await api.get(endpoint || `/data/${model}`, {
|
|
629
662
|
params: {
|
|
630
663
|
...filterParams,
|
|
664
|
+
...smartLaneParams(stage.filters),
|
|
631
665
|
page: 1,
|
|
632
666
|
per_page: 1,
|
|
633
667
|
[`f_${gb}`]: stage.key,
|
|
@@ -661,6 +695,27 @@ export function DynamicKanban({
|
|
|
661
695
|
// the shared records. Scoped by `f_<group_by>=<stage>` on top of the active
|
|
662
696
|
// filterParams (the stage scope wins over any global group_by filter).
|
|
663
697
|
const groupByKey = metadata?.group_by || ''
|
|
698
|
+
// Extra per-lane conditions (stage overrides) keyed by stage. A real lane
|
|
699
|
+
// that carries these queries its data — and counts its header — with the
|
|
700
|
+
// stage scope PLUS these filters (serialized like a smart lane's), so the
|
|
701
|
+
// top-up + eager-total requests below layer them on. Sourced from
|
|
702
|
+
// `metadata.stages` (the kernel applies declared + custom-real overrides).
|
|
703
|
+
const stageExtraFilters = useMemo(() => {
|
|
704
|
+
const m = new Map<string, CustomStageFilter[]>()
|
|
705
|
+
for (const s of metadata?.stages ?? []) {
|
|
706
|
+
if (s.filters && s.filters.length > 0) {
|
|
707
|
+
m.set(
|
|
708
|
+
s.key,
|
|
709
|
+
s.filters.map((f) => ({
|
|
710
|
+
field: f.field,
|
|
711
|
+
op: f.op as CustomStageFilter['op'],
|
|
712
|
+
value: f.value,
|
|
713
|
+
})),
|
|
714
|
+
)
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
return m
|
|
718
|
+
}, [metadata?.stages])
|
|
664
719
|
const loadMoreLane = useCallback(
|
|
665
720
|
async (stageKey: string) => {
|
|
666
721
|
if (!metadata || !groupByKey) return
|
|
@@ -680,6 +735,7 @@ export function DynamicKanban({
|
|
|
680
735
|
const res = (await api.get(endpoint || `/data/${model}`, {
|
|
681
736
|
params: {
|
|
682
737
|
...filterParams,
|
|
738
|
+
...smartLaneParams(stageExtraFilters.get(stageKey)),
|
|
683
739
|
page: nextPage,
|
|
684
740
|
per_page: lanePageSize,
|
|
685
741
|
[`f_${groupByKey}`]: stageKey,
|
|
@@ -712,7 +768,7 @@ export function DynamicKanban({
|
|
|
712
768
|
}))
|
|
713
769
|
}
|
|
714
770
|
},
|
|
715
|
-
[api, endpoint, model, metadata, groupByKey, filterParams, lanePageSize, lanePagination],
|
|
771
|
+
[api, endpoint, model, metadata, groupByKey, filterParams, lanePageSize, lanePagination, stageExtraFilters],
|
|
716
772
|
)
|
|
717
773
|
|
|
718
774
|
// Refetch when metadata resolves, on an explicit refresh, or when the
|
|
@@ -1133,14 +1189,82 @@ export function DynamicKanban({
|
|
|
1133
1189
|
stageKey === activeStage ||
|
|
1134
1190
|
isTransitionAllowed(transitions, activeStage, stageKey)
|
|
1135
1191
|
|
|
1192
|
+
// Opens the gear (⚙) "Configurar etapa" dialog for a lane, routing to the
|
|
1193
|
+
// right backend by kind: a custom real stage edits through /custom-stages, a
|
|
1194
|
+
// declared stage through /stage-overrides. Seeds the current label/color and
|
|
1195
|
+
// any extra conditions the lane already carries.
|
|
1196
|
+
const openConfigStage = (stage: StageMeta) => {
|
|
1197
|
+
const custom = customStages.available
|
|
1198
|
+
? customByKey.get(stage.key)
|
|
1199
|
+
: undefined
|
|
1200
|
+
const raw =
|
|
1201
|
+
stageExtraFilters.get(stage.key) ??
|
|
1202
|
+
(custom?.filters as CustomStageFilter[] | undefined) ??
|
|
1203
|
+
[]
|
|
1204
|
+
const filters: CustomStageFilter[] = raw.map((f) => ({
|
|
1205
|
+
field: f.field,
|
|
1206
|
+
op: f.op as CustomStageFilter['op'],
|
|
1207
|
+
value: f.value,
|
|
1208
|
+
}))
|
|
1209
|
+
if (custom) {
|
|
1210
|
+
setConfigTarget({
|
|
1211
|
+
kind: 'custom',
|
|
1212
|
+
stageKey: stage.key,
|
|
1213
|
+
id: custom.id,
|
|
1214
|
+
label: custom.label,
|
|
1215
|
+
color: custom.color,
|
|
1216
|
+
filters,
|
|
1217
|
+
customStage: custom,
|
|
1218
|
+
})
|
|
1219
|
+
} else {
|
|
1220
|
+
const original = stage.original
|
|
1221
|
+
? {
|
|
1222
|
+
label: stage.original.label,
|
|
1223
|
+
color: stage.original.color,
|
|
1224
|
+
filters: stage.original.filters?.map((f) => ({
|
|
1225
|
+
field: f.field,
|
|
1226
|
+
op: f.op as CustomStageFilter['op'],
|
|
1227
|
+
value: f.value,
|
|
1228
|
+
})),
|
|
1229
|
+
}
|
|
1230
|
+
: undefined
|
|
1231
|
+
setConfigTarget({
|
|
1232
|
+
kind: 'declared',
|
|
1233
|
+
stageKey: stage.key,
|
|
1234
|
+
label: t(stage.label, { defaultValue: stage.label }),
|
|
1235
|
+
color: stage.color ?? 'slate',
|
|
1236
|
+
filters,
|
|
1237
|
+
overridden: !!stage.overridden,
|
|
1238
|
+
isFinal: stage.is_final,
|
|
1239
|
+
original,
|
|
1240
|
+
})
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
// Whether a lane offers the gear: custom real stages always (their CRUD is
|
|
1244
|
+
// wired); declared lanes only when the host wired /stage-overrides. Never on
|
|
1245
|
+
// the synthetic "Sin etapa" lane.
|
|
1246
|
+
const isConfigurable = (stage: StageMeta): boolean => {
|
|
1247
|
+
if (stage.key === UNASSIGNED_LANE) return false
|
|
1248
|
+
return customByKey.has(stage.key)
|
|
1249
|
+
? customStages.available
|
|
1250
|
+
: stageOverrides.available
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1136
1253
|
// Builds every `KanbanLane` prop (minus the dnd wiring) for one stage —
|
|
1137
1254
|
// shared by the sortable real stages and the plain-droppable unassigned lane.
|
|
1138
1255
|
const buildLaneProps = (stage: StageMeta): Omit<KanbanLaneProps, 'dnd'> => {
|
|
1139
1256
|
const allCards = grouped.get(stage.key) ?? []
|
|
1257
|
+
// Extra lane conditions (stage override): the server already scopes this
|
|
1258
|
+
// lane's top-up + total queries by them, but the shared INITIAL board page
|
|
1259
|
+
// is unscoped, so narrow those cards client-side too (belt-and-suspenders).
|
|
1260
|
+
const extraFilters = stageExtraFilters.get(stage.key) ?? []
|
|
1140
1261
|
// Per-lane client-side narrowing (instant, scoped to this stage). The
|
|
1141
1262
|
// funnel (field/value) and the lane search (query) are AND-combined.
|
|
1142
1263
|
const laneFilter = laneFilters[stage.key]
|
|
1143
1264
|
let cards = allCards
|
|
1265
|
+
if (extraFilters.length > 0) {
|
|
1266
|
+
cards = cards.filter((c) => cardMatchesStageFilters(c, extraFilters))
|
|
1267
|
+
}
|
|
1144
1268
|
if (laneFilter?.field) {
|
|
1145
1269
|
cards = cards.filter((c) => cardMatchesLaneFunnel(c, laneFilter))
|
|
1146
1270
|
}
|
|
@@ -1179,11 +1303,9 @@ export function DynamicKanban({
|
|
|
1179
1303
|
onAutomationCreate: automations.create,
|
|
1180
1304
|
onAutomationUpdate: automations.update,
|
|
1181
1305
|
onAutomationRemove: automations.remove,
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
onEditStage: openEditStage,
|
|
1186
|
-
onDeleteStage: openDeleteStage,
|
|
1306
|
+
extraFilters,
|
|
1307
|
+
configurable: isConfigurable(stage),
|
|
1308
|
+
onConfigure: () => openConfigStage(stage),
|
|
1187
1309
|
children:
|
|
1188
1310
|
loadingData && cards.length === 0 ? (
|
|
1189
1311
|
<>
|
|
@@ -1461,6 +1583,50 @@ export function DynamicKanban({
|
|
|
1461
1583
|
/>
|
|
1462
1584
|
</>
|
|
1463
1585
|
)}
|
|
1586
|
+
|
|
1587
|
+
{/* Unified "Configurar etapa" dialog opened by the per-lane ⚙ gear —
|
|
1588
|
+
routes saves to /stage-overrides (declared) or /custom-stages
|
|
1589
|
+
(custom) by the target's kind. */}
|
|
1590
|
+
<StageConfigDialog
|
|
1591
|
+
open={!!configTarget}
|
|
1592
|
+
onOpenChange={(o) => {
|
|
1593
|
+
if (!o) setConfigTarget(null)
|
|
1594
|
+
}}
|
|
1595
|
+
columns={metadata?.columns ?? []}
|
|
1596
|
+
target={configTarget}
|
|
1597
|
+
onSaveOverride={async (stageKey, patch) => {
|
|
1598
|
+
await stageOverrides.save(stageKey, patch)
|
|
1599
|
+
// The override changes the served label/color/filters — refetch
|
|
1600
|
+
// metadata so the board repaints, and reload the cards.
|
|
1601
|
+
try {
|
|
1602
|
+
const res = await api.get(`/metadata/table/${model}`)
|
|
1603
|
+
const body = res.data as ApiResponse<TableMetadata>
|
|
1604
|
+
if (body.success) {
|
|
1605
|
+
setMetadata(body.data)
|
|
1606
|
+
cacheMetadata(model, body.data)
|
|
1607
|
+
}
|
|
1608
|
+
} catch {
|
|
1609
|
+
/* keep the current metadata on a refetch miss */
|
|
1610
|
+
}
|
|
1611
|
+
void fetchData()
|
|
1612
|
+
}}
|
|
1613
|
+
onResetOverride={async (stageKey) => {
|
|
1614
|
+
await stageOverrides.reset(stageKey)
|
|
1615
|
+
try {
|
|
1616
|
+
const res = await api.get(`/metadata/table/${model}`)
|
|
1617
|
+
const body = res.data as ApiResponse<TableMetadata>
|
|
1618
|
+
if (body.success) {
|
|
1619
|
+
setMetadata(body.data)
|
|
1620
|
+
cacheMetadata(model, body.data)
|
|
1621
|
+
}
|
|
1622
|
+
} catch {
|
|
1623
|
+
/* keep the current metadata on a refetch miss */
|
|
1624
|
+
}
|
|
1625
|
+
void fetchData()
|
|
1626
|
+
}}
|
|
1627
|
+
onUpdateCustom={customStages.update}
|
|
1628
|
+
onDeleteCustom={openDeleteStage}
|
|
1629
|
+
/>
|
|
1464
1630
|
</div>
|
|
1465
1631
|
)
|
|
1466
1632
|
}
|
|
@@ -1716,10 +1882,12 @@ interface KanbanLaneProps {
|
|
|
1716
1882
|
patch: Partial<StageAutomation>,
|
|
1717
1883
|
) => Promise<void>
|
|
1718
1884
|
onAutomationRemove: (id: StageAutomation['id']) => Promise<void>
|
|
1719
|
-
/**
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1885
|
+
/** Extra lane conditions (stage override) — drives the header filter dot + tooltip. */
|
|
1886
|
+
extraFilters: CustomStageFilter[]
|
|
1887
|
+
/** Whether the ⚙ "Configurar etapa" gear should render for this lane. */
|
|
1888
|
+
configurable: boolean
|
|
1889
|
+
/** Opens the gear config dialog for this lane. */
|
|
1890
|
+
onConfigure: () => void
|
|
1723
1891
|
children: React.ReactNode
|
|
1724
1892
|
}
|
|
1725
1893
|
|
|
@@ -1745,9 +1913,9 @@ function KanbanLane({
|
|
|
1745
1913
|
onAutomationCreate,
|
|
1746
1914
|
onAutomationUpdate,
|
|
1747
1915
|
onAutomationRemove,
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1916
|
+
extraFilters,
|
|
1917
|
+
configurable,
|
|
1918
|
+
onConfigure,
|
|
1751
1919
|
children,
|
|
1752
1920
|
}: KanbanLaneProps) {
|
|
1753
1921
|
const { t } = useTranslation()
|
|
@@ -1792,6 +1960,25 @@ function KanbanLane({
|
|
|
1792
1960
|
}
|
|
1793
1961
|
: undefined
|
|
1794
1962
|
|
|
1963
|
+
// Stage-override conditions active on this lane → a small filter dot in the
|
|
1964
|
+
// header, its `title` listing the conditions (e.g. "priority es igual high").
|
|
1965
|
+
const hasConditions = extraFilters.length > 0
|
|
1966
|
+
const conditionsSummary = extraFilters
|
|
1967
|
+
.map((f) => {
|
|
1968
|
+
const opLabel = t(`dynamic.custom_stages.op.${f.op}`, {
|
|
1969
|
+
defaultValue:
|
|
1970
|
+
f.op === 'eq'
|
|
1971
|
+
? 'es igual'
|
|
1972
|
+
: f.op === 'neq'
|
|
1973
|
+
? 'distinto'
|
|
1974
|
+
: f.op === 'contains'
|
|
1975
|
+
? 'contiene'
|
|
1976
|
+
: 'en lista',
|
|
1977
|
+
})
|
|
1978
|
+
return `${f.field} ${opLabel} ${f.value}`
|
|
1979
|
+
})
|
|
1980
|
+
.join(' · ')
|
|
1981
|
+
|
|
1795
1982
|
return (
|
|
1796
1983
|
<div
|
|
1797
1984
|
ref={dnd.setNodeRef}
|
|
@@ -1836,6 +2023,22 @@ function KanbanLane({
|
|
|
1836
2023
|
<span className="text-xs font-medium tabular-nums text-muted-foreground">
|
|
1837
2024
|
{formatLaneCount(count, totalCount, serverTotal, laneActive)}
|
|
1838
2025
|
</span>
|
|
2026
|
+
{hasConditions && (
|
|
2027
|
+
<span
|
|
2028
|
+
className="flex items-center text-primary"
|
|
2029
|
+
title={t('dynamic.stage_config.conditions_active', {
|
|
2030
|
+
defaultValue: 'Condiciones: {{list}}',
|
|
2031
|
+
list: conditionsSummary,
|
|
2032
|
+
})}
|
|
2033
|
+
data-testid={`lane-conditions-${stage.key}`}
|
|
2034
|
+
aria-label={t('dynamic.stage_config.conditions_active', {
|
|
2035
|
+
defaultValue: 'Condiciones: {{list}}',
|
|
2036
|
+
list: conditionsSummary,
|
|
2037
|
+
})}
|
|
2038
|
+
>
|
|
2039
|
+
<ListFilter className="h-3 w-3" />
|
|
2040
|
+
</span>
|
|
2041
|
+
)}
|
|
1839
2042
|
</div>
|
|
1840
2043
|
{/* Lane actions — always visible in muted (a hidden hover-reveal
|
|
1841
2044
|
was undiscoverable); active state is a primary tint + a count
|
|
@@ -1873,12 +2076,20 @@ function KanbanLane({
|
|
|
1873
2076
|
onRemove={onAutomationRemove}
|
|
1874
2077
|
/>
|
|
1875
2078
|
)}
|
|
1876
|
-
{
|
|
1877
|
-
<
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
2079
|
+
{configurable && (
|
|
2080
|
+
<button
|
|
2081
|
+
type="button"
|
|
2082
|
+
onClick={onConfigure}
|
|
2083
|
+
className={`relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
|
|
2084
|
+
hasConditions ? 'text-primary' : 'text-muted-foreground'
|
|
2085
|
+
}`}
|
|
2086
|
+
aria-label={t('dynamic.stage_config.open', {
|
|
2087
|
+
defaultValue: 'Configurar etapa',
|
|
2088
|
+
})}
|
|
2089
|
+
data-testid={`lane-config-${stage.key}`}
|
|
2090
|
+
>
|
|
2091
|
+
<Settings2 className="h-3.5 w-3.5" />
|
|
2092
|
+
</button>
|
|
1882
2093
|
)}
|
|
1883
2094
|
</div>
|
|
1884
2095
|
</div>
|
|
@@ -2152,7 +2363,7 @@ function KanbanCard({
|
|
|
2152
2363
|
<div className="min-w-0 flex-1 break-words text-sm font-medium leading-snug">
|
|
2153
2364
|
{titleCol ? (
|
|
2154
2365
|
<ActivityValueRenderer
|
|
2155
|
-
value={card
|
|
2366
|
+
value={cardCellValue(card, titleCol)}
|
|
2156
2367
|
col={titleCol}
|
|
2157
2368
|
locale={locale}
|
|
2158
2369
|
timeZone={timeZone}
|
|
@@ -2204,7 +2415,7 @@ function KanbanCard({
|
|
|
2204
2415
|
<span className="shrink-0 opacity-70">{col.label}:</span>
|
|
2205
2416
|
<span className="min-w-0 break-words">
|
|
2206
2417
|
<ActivityValueRenderer
|
|
2207
|
-
value={card
|
|
2418
|
+
value={cardCellValue(card, col)}
|
|
2208
2419
|
col={col}
|
|
2209
2420
|
locale={locale}
|
|
2210
2421
|
timeZone={timeZone}
|
|
@@ -2233,7 +2444,7 @@ function CardPreview({
|
|
|
2233
2444
|
<div className="break-words text-sm font-medium leading-snug">
|
|
2234
2445
|
{titleCol ? (
|
|
2235
2446
|
<ActivityValueRenderer
|
|
2236
|
-
value={card
|
|
2447
|
+
value={cardCellValue(card, titleCol)}
|
|
2237
2448
|
col={titleCol}
|
|
2238
2449
|
locale={locale}
|
|
2239
2450
|
timeZone={timeZone}
|
|
@@ -2251,7 +2462,7 @@ function CardPreview({
|
|
|
2251
2462
|
<span className="shrink-0 opacity-70">{col.label}:</span>
|
|
2252
2463
|
<span className="min-w-0 break-words">
|
|
2253
2464
|
<ActivityValueRenderer
|
|
2254
|
-
value={card
|
|
2465
|
+
value={cardCellValue(card, col)}
|
|
2255
2466
|
col={col}
|
|
2256
2467
|
locale={locale}
|
|
2257
2468
|
timeZone={timeZone}
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
resolveDependsValue,
|
|
32
32
|
getOptionsConfig,
|
|
33
33
|
resolveOptionsSource,
|
|
34
|
+
applyOptionWhen,
|
|
34
35
|
} from './dynamic-form-schema'
|
|
35
36
|
import { DynamicSelectField, DEFAULT_DEPENDS_HINT } from './dynamic-select-field'
|
|
36
37
|
import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
|
|
@@ -271,6 +272,25 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
|
|
|
271
272
|
const dependsValue = getDependsOn(field)
|
|
272
273
|
? resolveDependsValue(field, formValues, rowValues)
|
|
273
274
|
: undefined
|
|
275
|
+
|
|
276
|
+
// STATIC enum options gated per-cell by a sibling (row) or header value.
|
|
277
|
+
// Row values win over the header when a key exists in both, matching
|
|
278
|
+
// `resolveDependsValue`. Filtered against each option's `when`.
|
|
279
|
+
const isStaticSelect =
|
|
280
|
+
widget === 'select' && !field.ref && !getOptionsConfig(field)?.source && Array.isArray(field.options)
|
|
281
|
+
const gateValues = isStaticSelect ? { ...(formValues ?? {}), ...(rowValues ?? {}) } : undefined
|
|
282
|
+
const effectiveOptions = isStaticSelect
|
|
283
|
+
? applyOptionWhen(field.options, gateValues, getDependsOn(field))
|
|
284
|
+
: undefined
|
|
285
|
+
|
|
286
|
+
// Reset a selection the current sibling value no longer permits.
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
if (!isStaticSelect || !effectiveOptions) return
|
|
289
|
+
if (value && !effectiveOptions.some((o) => String(o.value) === String(value))) {
|
|
290
|
+
onChange('')
|
|
291
|
+
}
|
|
292
|
+
}, [isStaticSelect, effectiveOptions, value, onChange])
|
|
293
|
+
|
|
274
294
|
// Async searchable picker per row cell — e.g. the account_id column of a
|
|
275
295
|
// journal entry's debit/credit lines. Same widget as the flat form.
|
|
276
296
|
if (widget === 'dynamic_select') {
|
|
@@ -309,14 +329,17 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
|
|
|
309
329
|
disabled={off}
|
|
310
330
|
/>
|
|
311
331
|
)
|
|
312
|
-
case 'select':
|
|
332
|
+
case 'select': {
|
|
333
|
+
const opts = effectiveOptions ?? field.options
|
|
334
|
+
// No option applies under the current sibling value → hide the cell.
|
|
335
|
+
if (effectiveOptions && effectiveOptions.length === 0) return null
|
|
313
336
|
return (
|
|
314
337
|
<Select value={value || ''} onValueChange={onChange} disabled={off}>
|
|
315
338
|
<SelectTrigger className="w-full">
|
|
316
339
|
<SelectValue placeholder={field.placeholder || 'Seleccionar...'} />
|
|
317
340
|
</SelectTrigger>
|
|
318
341
|
<SelectContent>
|
|
319
|
-
{
|
|
342
|
+
{opts?.map((opt) => (
|
|
320
343
|
<SelectItem key={opt.value} value={opt.value}>
|
|
321
344
|
{opt.label}
|
|
322
345
|
</SelectItem>
|
|
@@ -324,6 +347,7 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
|
|
|
324
347
|
</SelectContent>
|
|
325
348
|
</Select>
|
|
326
349
|
)
|
|
350
|
+
}
|
|
327
351
|
case 'switch':
|
|
328
352
|
return <Switch checked={!!value} onCheckedChange={onChange} disabled={off} />
|
|
329
353
|
case 'number':
|