@asteby/metacore-runtime-react 23.7.1 → 23.9.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.
@@ -19,7 +19,7 @@
19
19
  // All UI text goes through t() with a Spanish defaultValue.
20
20
  import React, { useCallback, useEffect, useMemo, useState } from 'react'
21
21
  import { useTranslation } from 'react-i18next'
22
- import { Plus, Trash2, Pencil, MoreVertical, Filter, X } from 'lucide-react'
22
+ import { Plus, Trash2, Pencil, MoreVertical, Filter, Flag, GripVertical, Lock, RotateCcw, X } from 'lucide-react'
23
23
  import { toast } from 'sonner'
24
24
  import {
25
25
  Badge,
@@ -151,6 +151,8 @@ export function mergeLaneStages(
151
151
  label: cs.label,
152
152
  color: cs.color,
153
153
  order: cs.position,
154
+ custom: true,
155
+ filters: cs.filters,
154
156
  })
155
157
  }
156
158
  lanes.sort((a, b) => {
@@ -173,7 +175,7 @@ export function mergeLaneStages(
173
175
  * Empty fields/values are skipped.
174
176
  */
175
177
  export function smartLaneParams(
176
- filters: CustomStageFilter[] | undefined,
178
+ filters: { field: string; op: string; value: string }[] | undefined,
177
179
  ): Record<string, string> {
178
180
  const params: Record<string, string> = {}
179
181
  for (const f of filters ?? []) {
@@ -254,6 +256,43 @@ export function isCustomStageDraftValid(draft: {
254
256
  return true
255
257
  }
256
258
 
259
+ /**
260
+ * Whether a card passes a set of extra lane `filters` (the same conditions a
261
+ * smart lane uses, layered on top of a real stage). Ops:
262
+ * - eq → equal (string compare)
263
+ * - neq → not equal
264
+ * - contains → the card's value (array or string) includes the value
265
+ * - in → the card's value is one of the comma-separated candidates
266
+ * Empty/absent filters pass. Pure — a client-side belt-and-suspenders over the
267
+ * initial (unscoped) board page; the server scopes the per-lane top-up queries.
268
+ */
269
+ export function cardMatchesStageFilters(
270
+ card: any,
271
+ filters: CustomStageFilter[] | undefined,
272
+ ): boolean {
273
+ if (!filters || filters.length === 0) return true
274
+ return filters.every((f) => {
275
+ if (!f.field || String(f.value ?? '').trim() === '') return true
276
+ const target = String(f.value).trim()
277
+ const raw = card?.[f.field]
278
+ switch (f.op) {
279
+ case 'neq':
280
+ return String(raw ?? '') !== target
281
+ case 'contains':
282
+ if (Array.isArray(raw)) return raw.map(String).includes(target)
283
+ return String(raw ?? '').includes(target)
284
+ case 'in':
285
+ return target
286
+ .split(',')
287
+ .map((s) => s.trim())
288
+ .includes(String(raw ?? ''))
289
+ case 'eq':
290
+ default:
291
+ return String(raw ?? '') === target
292
+ }
293
+ })
294
+ }
295
+
257
296
  /** Slugify a label into a stable-ish lane key (used only when creating). */
258
297
  export function slugifyStageKey(label: string): string {
259
298
  return (
@@ -463,6 +502,141 @@ export function CustomStageLaneMenu({
463
502
  )
464
503
  }
465
504
 
505
+ // ---------------------------------------------------------------------------
506
+ // Condition builder (field / operator / value rows) — shared by the smart-lane
507
+ // editor and the declared-stage config dialog.
508
+ // ---------------------------------------------------------------------------
509
+
510
+ export interface StageConditionBuilderProps {
511
+ /** The current filter rows. */
512
+ filters: CustomStageFilter[]
513
+ /** Called with the next filter rows on any add/remove/patch. */
514
+ onChange: (filters: CustomStageFilter[]) => void
515
+ /** Columns offered in the field dropdown (visible, non-id). */
516
+ fieldChoices: ColumnDefinition[]
517
+ /** Optional heading shown above the rows. */
518
+ label?: string
519
+ }
520
+
521
+ /**
522
+ * The reusable field/operator/value condition builder. Emits `CustomStageFilter[]`
523
+ * through `onChange`. Same ops (eq/neq/contains/in) and testids the smart-lane
524
+ * editor has always used, so it drops in for both the custom-stage smart lane
525
+ * and the declared-stage config dialog.
526
+ */
527
+ export function StageConditionBuilder({
528
+ filters,
529
+ onChange,
530
+ fieldChoices,
531
+ label,
532
+ }: StageConditionBuilderProps) {
533
+ const { t } = useTranslation()
534
+ const patchFilter = (i: number, patch: Partial<CustomStageFilter>) =>
535
+ onChange(filters.map((f, idx) => (idx === i ? { ...f, ...patch } : f)))
536
+ const addFilter = () =>
537
+ onChange([...filters, emptyCustomStageFilter(fieldChoices[0]?.key ?? '')])
538
+ const removeFilter = (i: number) =>
539
+ onChange(filters.filter((_, idx) => idx !== i))
540
+ return (
541
+ <div className="flex flex-col gap-2 rounded-md border bg-muted/30 p-3">
542
+ <div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
543
+ <Filter className="h-3.5 w-3.5" />
544
+ {label ??
545
+ t('dynamic.custom_stages.conditions_label', {
546
+ defaultValue: 'Condiciones',
547
+ })}
548
+ </div>
549
+ {filters.map((f, i) => (
550
+ <div
551
+ key={i}
552
+ className="grid grid-cols-[1fr_auto_1fr_auto] items-center gap-1.5"
553
+ data-testid={`custom-stage-condition-${i}`}
554
+ >
555
+ <Select
556
+ value={f.field}
557
+ onValueChange={(v) => patchFilter(i, { field: v })}
558
+ >
559
+ <SelectTrigger className="h-8 text-xs" data-testid={`custom-stage-condition-field-${i}`}>
560
+ <SelectValue
561
+ placeholder={t(
562
+ 'dynamic.custom_stages.field_placeholder',
563
+ { defaultValue: 'Campo' },
564
+ )}
565
+ />
566
+ </SelectTrigger>
567
+ <SelectContent>
568
+ {fieldChoices.map((c) => (
569
+ <SelectItem key={c.key} value={c.key} className="text-xs">
570
+ {t(c.label, { defaultValue: c.label })}
571
+ </SelectItem>
572
+ ))}
573
+ </SelectContent>
574
+ </Select>
575
+ <Select
576
+ value={f.op}
577
+ onValueChange={(v) =>
578
+ patchFilter(i, { op: v as CustomStageFilterOp })
579
+ }
580
+ >
581
+ <SelectTrigger className="h-8 w-[104px] text-xs" data-testid={`custom-stage-condition-op-${i}`}>
582
+ <SelectValue />
583
+ </SelectTrigger>
584
+ <SelectContent>
585
+ {CUSTOM_STAGE_FILTER_OPS.map((op) => (
586
+ <SelectItem key={op} value={op} className="text-xs">
587
+ {t(`dynamic.custom_stages.op.${op}`, {
588
+ defaultValue:
589
+ op === 'eq'
590
+ ? 'es igual'
591
+ : op === 'neq'
592
+ ? 'distinto'
593
+ : op === 'contains'
594
+ ? 'contiene'
595
+ : 'en lista',
596
+ })}
597
+ </SelectItem>
598
+ ))}
599
+ </SelectContent>
600
+ </Select>
601
+ <Input
602
+ value={f.value}
603
+ onChange={(e) => patchFilter(i, { value: e.target.value })}
604
+ placeholder={t('dynamic.custom_stages.value_placeholder', {
605
+ defaultValue: 'Valor',
606
+ })}
607
+ className="h-8 text-xs"
608
+ data-testid={`custom-stage-condition-value-${i}`}
609
+ />
610
+ <button
611
+ type="button"
612
+ onClick={() => removeFilter(i)}
613
+ disabled={filters.length === 1}
614
+ className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-destructive disabled:opacity-40"
615
+ aria-label={t('dynamic.custom_stages.remove_condition', {
616
+ defaultValue: 'Quitar condición',
617
+ })}
618
+ data-testid={`custom-stage-condition-remove-${i}`}
619
+ >
620
+ <X className="h-3.5 w-3.5" />
621
+ </button>
622
+ </div>
623
+ ))}
624
+ <Button
625
+ variant="ghost"
626
+ size="sm"
627
+ className="h-7 justify-start gap-1 text-xs"
628
+ onClick={addFilter}
629
+ data-testid="custom-stage-add-condition"
630
+ >
631
+ <Plus className="h-3.5 w-3.5" />
632
+ {t('dynamic.custom_stages.add_condition', {
633
+ defaultValue: 'Agregar condición',
634
+ })}
635
+ </Button>
636
+ </div>
637
+ )
638
+ }
639
+
466
640
  // ---------------------------------------------------------------------------
467
641
  // Create / edit dialog
468
642
  // ---------------------------------------------------------------------------
@@ -526,18 +700,6 @@ export function CustomStageDialog({
526
700
  }
527
701
  }, [open, initial, fieldChoices])
528
702
 
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
703
  const valid = isCustomStageDraftValid({ label, type, filters })
542
704
 
543
705
  const submit = async () => {
@@ -697,101 +859,11 @@ export function CustomStageDialog({
697
859
 
698
860
  {/* Condition builder — smart lanes only */}
699
861
  {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>
862
+ <StageConditionBuilder
863
+ filters={filters}
864
+ onChange={setFilters}
865
+ fieldChoices={fieldChoices}
866
+ />
795
867
  )}
796
868
  </div>
797
869
 
@@ -968,6 +1040,541 @@ export function CustomStageDeleteDialog({
968
1040
  )
969
1041
  }
970
1042
 
1043
+ // ---------------------------------------------------------------------------
1044
+ // Stage config dialog (the per-lane gear ⚙) — unifies DECLARED-lane overrides
1045
+ // and CUSTOM real-stage editing behind one "Configurar etapa" UI.
1046
+ // ---------------------------------------------------------------------------
1047
+
1048
+ export type StageConfigKind = 'declared' | 'custom'
1049
+
1050
+ /** What the gear opens the config dialog against — a declared or custom lane. */
1051
+ export interface StageConfigTarget {
1052
+ kind: StageConfigKind
1053
+ /** Stable lane key. */
1054
+ stageKey: string
1055
+ /** Custom-stage id — required (and only used) when `kind === 'custom'`. */
1056
+ id?: CustomStage['id']
1057
+ label: string
1058
+ color: string
1059
+ filters: CustomStageFilter[]
1060
+ /**
1061
+ * Declared kind: an override is currently applied → shows a "Personalizada"
1062
+ * badge + "Restablecer al original". Ignored for custom stages (they reset
1063
+ * via their own delete flow).
1064
+ */
1065
+ overridden?: boolean
1066
+ /** Terminal stage (e.g. "Done") — surfaces an "Etapa final" chip + tooltip. */
1067
+ isFinal?: boolean
1068
+ /**
1069
+ * The manifest ORIGINAL (pre-override) values, when the host serves them
1070
+ * (`metadata.stages[].original`). Drives the "Restablecer al original" confirm
1071
+ * so the user sees exactly what reverts. Absent → a generic confirm.
1072
+ */
1073
+ original?: {
1074
+ label?: string
1075
+ color?: string
1076
+ filters?: CustomStageFilter[]
1077
+ }
1078
+ /** The backing CustomStage (custom kind) so "Eliminar" can trigger delete. */
1079
+ customStage?: CustomStage
1080
+ }
1081
+
1082
+ /** A legible operator glyph for a condition chip: = / ≠ / contiene / en. */
1083
+ export function stageFilterOpSymbol(op: string): string {
1084
+ switch (op) {
1085
+ case 'neq':
1086
+ return '≠'
1087
+ case 'contains':
1088
+ return 'contiene'
1089
+ case 'in':
1090
+ return 'en'
1091
+ case 'eq':
1092
+ default:
1093
+ return '='
1094
+ }
1095
+ }
1096
+
1097
+ export interface StageConfigDialogProps {
1098
+ open: boolean
1099
+ onOpenChange: (open: boolean) => void
1100
+ /** Columns for the condition builder. */
1101
+ columns: ColumnDefinition[]
1102
+ /** The lane being configured, or null. */
1103
+ target: StageConfigTarget | null
1104
+ /** Declared: upsert the lane override (PUT /stage-overrides). */
1105
+ onSaveOverride: (
1106
+ stageKey: string,
1107
+ patch: { label: string; color: string; filters: CustomStageFilter[] },
1108
+ ) => Promise<void>
1109
+ /** Declared: reset the lane to its manifest default (DELETE /stage-overrides). */
1110
+ onResetOverride: (stageKey: string) => Promise<void>
1111
+ /** Custom: update the stage via its own CRUD (PUT /custom-stages/:id). */
1112
+ onUpdateCustom: (
1113
+ id: CustomStage['id'],
1114
+ patch: Partial<CustomStage>,
1115
+ ) => Promise<void>
1116
+ /** Custom: hand off to the existing delete (reassign) flow. */
1117
+ onDeleteCustom: (stage: CustomStage) => void
1118
+ }
1119
+
1120
+ /**
1121
+ * The gear (⚙) dialog. Renames, recolors and attaches extra CONDITIONS to a
1122
+ * lane. One UI, two backends: a DECLARED lane persists through `/stage-overrides`
1123
+ * (with a "Restablecer etapa" that drops the override); a CUSTOM real stage
1124
+ * persists through its own `/custom-stages` CRUD (and deletes via that flow). The
1125
+ * conditions narrow which cards the lane shows/counts but never stop it being a
1126
+ * drop target — dropping a card only sets the stage value.
1127
+ */
1128
+ export function StageConfigDialog({
1129
+ open,
1130
+ onOpenChange,
1131
+ columns,
1132
+ target,
1133
+ onSaveOverride,
1134
+ onResetOverride,
1135
+ onUpdateCustom,
1136
+ onDeleteCustom,
1137
+ }: StageConfigDialogProps) {
1138
+ const { t } = useTranslation()
1139
+ const isDark =
1140
+ typeof document !== 'undefined' &&
1141
+ document.documentElement.classList.contains('dark')
1142
+ const fieldChoices = useMemo(
1143
+ () => customStageFilterFields(columns),
1144
+ [columns],
1145
+ )
1146
+
1147
+ const [label, setLabel] = useState('')
1148
+ const [color, setColor] = useState<string>(CUSTOM_STAGE_COLORS[0])
1149
+ const [filters, setFilters] = useState<CustomStageFilter[]>([])
1150
+ const [saving, setSaving] = useState(false)
1151
+ const [resetting, setResetting] = useState(false)
1152
+ // Two-step reset: the first click reveals a confirm panel listing exactly
1153
+ // what reverts (the manifest original), the second actually drops the override.
1154
+ const [confirmingReset, setConfirmingReset] = useState(false)
1155
+
1156
+ // Re-seed the WHOLE form from the lane's current (already-override-applied)
1157
+ // metadata each time the dialog opens — the user always sees the live state,
1158
+ // never a blank form. Label, color and every extra condition pre-fill editable.
1159
+ useEffect(() => {
1160
+ if (!open || !target) return
1161
+ setLabel(target.label)
1162
+ setColor(target.color || CUSTOM_STAGE_COLORS[0])
1163
+ setFilters(target.filters?.map((f) => ({ ...f })) ?? [])
1164
+ setConfirmingReset(false)
1165
+ }, [open, target])
1166
+
1167
+ if (!target) return null
1168
+
1169
+ const valid = label.trim().length > 0
1170
+ // Only complete conditions are persisted; empty rows are dropped.
1171
+ const cleanFilters = () =>
1172
+ filters.filter((f) => f.field && String(f.value).trim() !== '')
1173
+ // The conditions that make up the lane's EFFECTIVE query, for the chip row:
1174
+ // the immovable stage scope first, then each extra (removable) condition.
1175
+ const chipFilters = filters.filter(
1176
+ (f) => f.field && String(f.value).trim() !== '',
1177
+ )
1178
+
1179
+ const removeFilterAt = (i: number) =>
1180
+ setFilters((prev) => prev.filter((_, idx) => idx !== i))
1181
+
1182
+ const submit = async () => {
1183
+ if (!valid || saving) return
1184
+ setSaving(true)
1185
+ try {
1186
+ if (target.kind === 'declared') {
1187
+ await onSaveOverride(target.stageKey, {
1188
+ label: label.trim(),
1189
+ color,
1190
+ filters: cleanFilters(),
1191
+ })
1192
+ } else if (target.id != null) {
1193
+ await onUpdateCustom(target.id, {
1194
+ label: label.trim(),
1195
+ color,
1196
+ filters: cleanFilters(),
1197
+ })
1198
+ }
1199
+ toast.success(
1200
+ t('dynamic.stage_config.saved', {
1201
+ defaultValue: 'Etapa configurada',
1202
+ }),
1203
+ )
1204
+ onOpenChange(false)
1205
+ } catch {
1206
+ // toast already surfaced by the caller's hook; keep the draft open.
1207
+ } finally {
1208
+ setSaving(false)
1209
+ }
1210
+ }
1211
+
1212
+ const reset = async () => {
1213
+ if (resetting) return
1214
+ setResetting(true)
1215
+ try {
1216
+ await onResetOverride(target.stageKey)
1217
+ toast.success(
1218
+ t('dynamic.stage_config.reset_done', {
1219
+ defaultValue: 'Etapa restablecida',
1220
+ }),
1221
+ )
1222
+ onOpenChange(false)
1223
+ } catch {
1224
+ toast.error(
1225
+ t('dynamic.stage_config.reset_error', {
1226
+ defaultValue: 'No se pudo restablecer la etapa',
1227
+ }),
1228
+ )
1229
+ } finally {
1230
+ setResetting(false)
1231
+ }
1232
+ }
1233
+
1234
+ const headerDot = generateBadgeStyles(
1235
+ color || optionColor(target.stageKey),
1236
+ { isDark },
1237
+ )
1238
+ // A one-line, human summary of the manifest original (for the reset confirm).
1239
+ const original = target.original
1240
+ const originalConditions =
1241
+ original?.filters && original.filters.length > 0
1242
+ ? original.filters
1243
+ .map(
1244
+ (f) =>
1245
+ `${f.field} ${stageFilterOpSymbol(f.op)} ${f.value}`,
1246
+ )
1247
+ .join(', ')
1248
+ : t('dynamic.stage_config.original_no_conditions', {
1249
+ defaultValue: 'sin condiciones',
1250
+ })
1251
+
1252
+ return (
1253
+ <Dialog open={open} onOpenChange={onOpenChange}>
1254
+ <DialogContent className="sm:max-w-lg">
1255
+ <DialogHeader>
1256
+ <DialogTitle className="flex items-center gap-2">
1257
+ <span
1258
+ className="inline-block size-3 shrink-0 rounded-full"
1259
+ style={{
1260
+ backgroundColor:
1261
+ headerDot.backgroundColor ||
1262
+ (headerDot as any).background,
1263
+ }}
1264
+ aria-hidden
1265
+ />
1266
+ {t('dynamic.stage_config.title', {
1267
+ defaultValue: 'Configurar etapa',
1268
+ })}
1269
+ <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] font-normal text-muted-foreground">
1270
+ {target.stageKey}
1271
+ </code>
1272
+ {target.kind === 'declared' && target.overridden && (
1273
+ <Badge
1274
+ variant="secondary"
1275
+ className="ml-auto text-[10px] font-medium"
1276
+ data-testid="stage-config-personalizada"
1277
+ >
1278
+ {t('dynamic.stage_config.customized', {
1279
+ defaultValue: 'Personalizada',
1280
+ })}
1281
+ </Badge>
1282
+ )}
1283
+ </DialogTitle>
1284
+ <DialogDescription>
1285
+ {t('dynamic.stage_config.description', {
1286
+ defaultValue:
1287
+ 'Renombra la columna, cambia su color y agrega condiciones para acotar qué tarjetas muestra y cuenta.',
1288
+ })}
1289
+ </DialogDescription>
1290
+ </DialogHeader>
1291
+
1292
+ <div className="flex flex-col gap-4">
1293
+ {/* "Condiciones actuales" — the lane's EFFECTIVE query as chips.
1294
+ The base "Etapa = <label>" chip is always present, locked and
1295
+ dimmed (it's the stage scope, never removable); the terminal
1296
+ chip appears for a final stage; each extra condition is an
1297
+ editable/removable chip mirroring the builder below. */}
1298
+ <div className="flex flex-col gap-1.5">
1299
+ <Label className="text-xs text-muted-foreground">
1300
+ {t('dynamic.stage_config.current_conditions_label', {
1301
+ defaultValue: 'Condiciones actuales',
1302
+ })}
1303
+ </Label>
1304
+ <div
1305
+ className="flex flex-wrap items-center gap-1.5"
1306
+ data-testid="stage-config-current-conditions"
1307
+ >
1308
+ <Badge
1309
+ variant="outline"
1310
+ className="cursor-default gap-1 border-dashed text-[11px] font-normal text-muted-foreground opacity-80"
1311
+ title={t('dynamic.stage_config.base_chip_hint', {
1312
+ defaultValue:
1313
+ 'Alcance base de la etapa: no se puede quitar.',
1314
+ })}
1315
+ data-testid="stage-config-base-chip"
1316
+ >
1317
+ <Lock className="h-3 w-3" />
1318
+ {t('dynamic.stage_config.base_chip', {
1319
+ defaultValue: 'Etapa = {{label}}',
1320
+ label: label || target.label,
1321
+ })}
1322
+ </Badge>
1323
+ {target.isFinal && (
1324
+ <Badge
1325
+ variant="outline"
1326
+ className="cursor-default gap-1 text-[11px] font-normal text-muted-foreground"
1327
+ title={t('dynamic.stage_config.final_chip_hint', {
1328
+ defaultValue:
1329
+ 'Etapa terminal: al mover una tarjeta aquí se marca como cerrada (p. ej. en GitHub cierra el issue).',
1330
+ })}
1331
+ data-testid="stage-config-final-chip"
1332
+ >
1333
+ <Flag className="h-3 w-3" />
1334
+ {t('dynamic.stage_config.final_chip', {
1335
+ defaultValue: 'Etapa final',
1336
+ })}
1337
+ </Badge>
1338
+ )}
1339
+ {chipFilters.map((f) => {
1340
+ // Chip index maps back to its position in `filters`.
1341
+ const i = filters.indexOf(f)
1342
+ return (
1343
+ <Badge
1344
+ key={i}
1345
+ variant="secondary"
1346
+ className="gap-1 pr-1 text-[11px] font-normal"
1347
+ data-testid={`stage-config-filter-chip-${i}`}
1348
+ >
1349
+ <span className="font-mono">{f.field}</span>
1350
+ <span className="opacity-70">
1351
+ {stageFilterOpSymbol(f.op)}
1352
+ </span>
1353
+ <span>{f.value}</span>
1354
+ <button
1355
+ type="button"
1356
+ onClick={() => removeFilterAt(i)}
1357
+ className="ml-0.5 rounded p-0.5 hover:bg-background/60"
1358
+ aria-label={t(
1359
+ 'dynamic.stage_config.remove_chip',
1360
+ { defaultValue: 'Quitar condición' },
1361
+ )}
1362
+ data-testid={`stage-config-filter-chip-remove-${i}`}
1363
+ >
1364
+ <X className="h-2.5 w-2.5" />
1365
+ </button>
1366
+ </Badge>
1367
+ )
1368
+ })}
1369
+ </div>
1370
+ </div>
1371
+
1372
+ <div className="border-t" />
1373
+
1374
+ {/* Name */}
1375
+ <div className="flex flex-col gap-1.5">
1376
+ <Label className="text-xs">
1377
+ {t('dynamic.stage_config.name_label', {
1378
+ defaultValue: 'Nombre',
1379
+ })}
1380
+ </Label>
1381
+ <Input
1382
+ value={label}
1383
+ onChange={(e) => setLabel(e.target.value)}
1384
+ placeholder={t('dynamic.stage_config.name_placeholder', {
1385
+ defaultValue: 'Nombre de la etapa',
1386
+ })}
1387
+ data-testid="stage-config-name"
1388
+ autoFocus
1389
+ />
1390
+ </div>
1391
+
1392
+ {/* Color palette */}
1393
+ <div className="flex flex-col gap-1.5">
1394
+ <Label className="text-xs">
1395
+ {t('dynamic.stage_config.color_label', {
1396
+ defaultValue: 'Color',
1397
+ })}
1398
+ </Label>
1399
+ <div className="flex flex-wrap gap-1.5" data-testid="stage-config-colors">
1400
+ {CUSTOM_STAGE_COLORS.map((c) => {
1401
+ const style = generateBadgeStyles(c, { isDark })
1402
+ const selected = c === color
1403
+ return (
1404
+ <button
1405
+ key={c}
1406
+ type="button"
1407
+ onClick={() => setColor(c)}
1408
+ className={`size-6 rounded-full border-2 transition-transform hover:scale-110 ${
1409
+ selected
1410
+ ? 'border-foreground'
1411
+ : 'border-transparent'
1412
+ }`}
1413
+ style={{
1414
+ backgroundColor:
1415
+ style.backgroundColor ||
1416
+ (style as any).background,
1417
+ }}
1418
+ aria-label={c}
1419
+ aria-pressed={selected}
1420
+ data-testid={`stage-config-color-${c}`}
1421
+ />
1422
+ )
1423
+ })}
1424
+ </div>
1425
+ </div>
1426
+
1427
+ {/* Conditions — the editable builder (pre-filled from the lane) */}
1428
+ <StageConditionBuilder
1429
+ filters={filters}
1430
+ onChange={setFilters}
1431
+ fieldChoices={fieldChoices}
1432
+ label={t('dynamic.stage_config.conditions_label', {
1433
+ defaultValue: 'Condiciones (opcional)',
1434
+ })}
1435
+ />
1436
+ {filters.length === 0 && (
1437
+ <Button
1438
+ variant="ghost"
1439
+ size="sm"
1440
+ className="-mt-2 h-7 justify-start gap-1 text-xs"
1441
+ onClick={() =>
1442
+ setFilters([
1443
+ emptyCustomStageFilter(fieldChoices[0]?.key ?? ''),
1444
+ ])
1445
+ }
1446
+ data-testid="stage-config-add-first-condition"
1447
+ >
1448
+ <Plus className="h-3.5 w-3.5" />
1449
+ {t('dynamic.stage_config.add_condition', {
1450
+ defaultValue: 'Agregar condición',
1451
+ })}
1452
+ </Button>
1453
+ )}
1454
+
1455
+ {/* Reset confirm — spells out exactly what reverts to the
1456
+ manifest original before dropping the override. */}
1457
+ {confirmingReset && (
1458
+ <div
1459
+ className="flex flex-col gap-2 rounded-md border border-dashed bg-muted/30 p-3 text-xs"
1460
+ data-testid="stage-config-reset-confirm-panel"
1461
+ >
1462
+ <p className="font-medium">
1463
+ {t('dynamic.stage_config.reset_confirm_title', {
1464
+ defaultValue:
1465
+ 'Se restablecerá la etapa a su versión original:',
1466
+ })}
1467
+ </p>
1468
+ <ul className="list-inside list-disc text-muted-foreground">
1469
+ <li>
1470
+ {t('dynamic.stage_config.reset_confirm_label', {
1471
+ defaultValue: 'Nombre: {{label}}',
1472
+ label: original?.label ?? target.stageKey,
1473
+ })}
1474
+ </li>
1475
+ <li>
1476
+ {t('dynamic.stage_config.reset_confirm_color', {
1477
+ defaultValue: 'Color: {{color}}',
1478
+ color: original?.color ?? '—',
1479
+ })}
1480
+ </li>
1481
+ <li>
1482
+ {t('dynamic.stage_config.reset_confirm_conditions', {
1483
+ defaultValue: 'Condiciones: {{list}}',
1484
+ list: originalConditions,
1485
+ })}
1486
+ </li>
1487
+ </ul>
1488
+ <div className="flex justify-end gap-2 pt-1">
1489
+ <Button
1490
+ variant="outline"
1491
+ size="sm"
1492
+ className="h-7 text-xs"
1493
+ onClick={() => setConfirmingReset(false)}
1494
+ disabled={resetting}
1495
+ >
1496
+ {t('dynamic.stage_config.cancel', {
1497
+ defaultValue: 'Cancelar',
1498
+ })}
1499
+ </Button>
1500
+ <Button
1501
+ variant="destructive"
1502
+ size="sm"
1503
+ className="h-7 text-xs"
1504
+ onClick={() => void reset()}
1505
+ disabled={resetting}
1506
+ data-testid="stage-config-reset-confirm"
1507
+ >
1508
+ {t('dynamic.stage_config.reset_confirm', {
1509
+ defaultValue: 'Restablecer al original',
1510
+ })}
1511
+ </Button>
1512
+ </div>
1513
+ </div>
1514
+ )}
1515
+ </div>
1516
+
1517
+ <DialogFooter className="sm:justify-between">
1518
+ {/* Reset (declared+overridden) / delete (custom) on the left. */}
1519
+ <div>
1520
+ {target.kind === 'declared' && target.overridden && (
1521
+ <Button
1522
+ variant="ghost"
1523
+ className="gap-1.5 text-muted-foreground"
1524
+ onClick={() => setConfirmingReset(true)}
1525
+ disabled={saving || resetting || confirmingReset}
1526
+ data-testid="stage-config-reset"
1527
+ >
1528
+ <RotateCcw className="h-4 w-4" />
1529
+ {t('dynamic.stage_config.reset', {
1530
+ defaultValue: 'Restablecer al original',
1531
+ })}
1532
+ </Button>
1533
+ )}
1534
+ {target.kind === 'custom' && target.customStage && (
1535
+ <Button
1536
+ variant="ghost"
1537
+ className="gap-1.5 text-destructive hover:text-destructive"
1538
+ onClick={() => {
1539
+ onOpenChange(false)
1540
+ onDeleteCustom(target.customStage!)
1541
+ }}
1542
+ disabled={saving}
1543
+ data-testid="stage-config-delete"
1544
+ >
1545
+ <Trash2 className="h-4 w-4" />
1546
+ {t('dynamic.stage_config.delete', {
1547
+ defaultValue: 'Eliminar etapa',
1548
+ })}
1549
+ </Button>
1550
+ )}
1551
+ </div>
1552
+ <div className="flex gap-2">
1553
+ <Button
1554
+ variant="outline"
1555
+ onClick={() => onOpenChange(false)}
1556
+ disabled={saving || resetting}
1557
+ >
1558
+ {t('dynamic.stage_config.cancel', {
1559
+ defaultValue: 'Cancelar',
1560
+ })}
1561
+ </Button>
1562
+ <Button
1563
+ onClick={() => void submit()}
1564
+ disabled={!valid || saving || resetting}
1565
+ data-testid="stage-config-save"
1566
+ >
1567
+ {t('dynamic.stage_config.save', {
1568
+ defaultValue: 'Guardar',
1569
+ })}
1570
+ </Button>
1571
+ </div>
1572
+ </DialogFooter>
1573
+ </DialogContent>
1574
+ </Dialog>
1575
+ )
1576
+ }
1577
+
971
1578
  // ---------------------------------------------------------------------------
972
1579
  // Smart (virtual) lane — fetches its own records from the model's list
973
1580
  // ---------------------------------------------------------------------------
@@ -987,6 +1594,18 @@ export interface SmartLaneProps {
987
1594
  refreshTrigger?: any
988
1595
  onEdit: (stage: CustomStage) => void
989
1596
  onDelete: (stage: CustomStage) => void
1597
+ /**
1598
+ * Optional drag-and-drop wiring (from the kanban's sortable wrapper) so a
1599
+ * smart lane can be reordered by its header like a real stage. Absent → the
1600
+ * lane is static.
1601
+ */
1602
+ dnd?: {
1603
+ setNodeRef: (el: HTMLElement | null) => void
1604
+ style?: React.CSSProperties
1605
+ isDragging?: boolean
1606
+ handleRef?: (el: HTMLElement | null) => void
1607
+ handleProps?: Record<string, any>
1608
+ }
990
1609
  }
991
1610
 
992
1611
  /**
@@ -1006,6 +1625,7 @@ export function SmartLane({
1006
1625
  refreshTrigger,
1007
1626
  onEdit,
1008
1627
  onDelete,
1628
+ dnd,
1009
1629
  }: SmartLaneProps) {
1010
1630
  const { t } = useTranslation()
1011
1631
  const api = useApi()
@@ -1054,12 +1674,26 @@ export function SmartLane({
1054
1674
 
1055
1675
  return (
1056
1676
  <div
1057
- className="flex min-w-[280px] max-w-[420px] flex-1 shrink-0 flex-col rounded-xl border border-dashed bg-muted/20"
1677
+ ref={dnd?.setNodeRef}
1678
+ className="group/lane flex min-w-[280px] max-w-[420px] flex-1 shrink-0 flex-col rounded-xl border border-dashed bg-muted/20"
1679
+ style={{ opacity: dnd?.isDragging ? 0.6 : 1, ...dnd?.style }}
1058
1680
  data-smart-stage={stage.key}
1059
1681
  data-testid={`smart-lane-${stage.key}`}
1060
1682
  >
1061
1683
  <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">
1684
+ <div
1685
+ ref={dnd ? dnd.handleRef : undefined}
1686
+ {...(dnd ? dnd.handleProps : {})}
1687
+ className={`flex min-w-0 items-center gap-1.5 ${
1688
+ dnd ? 'cursor-grab active:cursor-grabbing' : ''
1689
+ }`}
1690
+ >
1691
+ {dnd && (
1692
+ <GripVertical
1693
+ className="h-3.5 w-3.5 shrink-0 text-muted-foreground/40 opacity-0 transition-opacity group-hover/lane:opacity-70"
1694
+ aria-hidden
1695
+ />
1696
+ )}
1063
1697
  <Badge
1064
1698
  variant="outline"
1065
1699
  className="border-0 text-xs font-semibold"