@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/custom-stages.d.ts +96 -1
  3. package/dist/custom-stages.d.ts.map +1 -1
  4. package/dist/custom-stages.js +259 -24
  5. package/dist/dynamic-form-schema.d.ts +18 -1
  6. package/dist/dynamic-form-schema.d.ts.map +1 -1
  7. package/dist/dynamic-form-schema.js +43 -0
  8. package/dist/dynamic-form.d.ts.map +1 -1
  9. package/dist/dynamic-form.js +30 -9
  10. package/dist/dynamic-kanban.d.ts +8 -0
  11. package/dist/dynamic-kanban.d.ts.map +1 -1
  12. package/dist/dynamic-kanban.js +188 -16
  13. package/dist/dynamic-line-items.d.ts.map +1 -1
  14. package/dist/dynamic-line-items.js +24 -3
  15. package/dist/dynamic-table.d.ts.map +1 -1
  16. package/dist/dynamic-table.js +5 -0
  17. package/dist/index.d.ts +2 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +2 -1
  20. package/dist/stage-overrides.d.ts +25 -0
  21. package/dist/stage-overrides.d.ts.map +1 -0
  22. package/dist/stage-overrides.js +64 -0
  23. package/dist/types.d.ts +69 -4
  24. package/dist/types.d.ts.map +1 -1
  25. package/package.json +3 -3
  26. package/src/__tests__/dependent-enum-form.test.tsx +84 -0
  27. package/src/__tests__/dependent-enum-options.test.ts +67 -0
  28. package/src/__tests__/dynamic-kanban.test.tsx +75 -0
  29. package/src/__tests__/stage-overrides.test.tsx +293 -0
  30. package/src/custom-stages.tsx +716 -109
  31. package/src/dynamic-form-schema.ts +44 -1
  32. package/src/dynamic-form.tsx +76 -28
  33. package/src/dynamic-kanban.tsx +236 -25
  34. package/src/dynamic-line-items.tsx +26 -2
  35. package/src/dynamic-table.tsx +4 -0
  36. package/src/index.ts +13 -0
  37. package/src/stage-overrides.ts +95 -0
  38. 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
@@ -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
- const fullWidth =
116
- isLineItemsField(field) ||
117
- resolveWidget(field) === 'textarea' ||
118
- resolveWidget(field) === 'richtext'
119
- return (
120
- <div
121
- key={field.key}
122
- className={'grid gap-2 ' + (fullWidth ? 'sm:col-span-2' : '')}
123
- >
124
- <Label htmlFor={field.key}>
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({ field, value, onChange, initialValues }: FieldRendererProps) {
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
  )
@@ -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
- customStage: customStages.available
1183
- ? customByKey.get(stage.key)
1184
- : undefined,
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
- /** When set, this lane is a user-defined custom stage show Editar/Eliminar. */
1720
- customStage?: CustomStage
1721
- onEditStage?: (stage: CustomStage) => void
1722
- onDeleteStage?: (stage: CustomStage) => void
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
- customStage,
1749
- onEditStage,
1750
- onDeleteStage,
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
- {customStage && onEditStage && onDeleteStage && (
1877
- <CustomStageLaneMenu
1878
- stage={customStage}
1879
- onEdit={onEditStage}
1880
- onDelete={onDeleteStage}
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[titleCol.key]}
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[col.key]}
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[titleCol.key]}
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[col.key]}
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
- {field.options?.map((opt) => (
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':