@mosaicoo/kanban 0.1.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.
@@ -0,0 +1,1483 @@
1
+ /**
2
+ * Versão do pacote em runtime.
3
+ *
4
+ * Mantida em sincronia com projects/kanban/package.json pelo processo de
5
+ * release (standard-version updater — mesmo mecanismo do @mosaicoo/svg-engine).
6
+ */
7
+ const MKB_VERSION = '0.1.0';
8
+
9
+ /**
10
+ * Modelo do board — TUDO aqui é serializável (JSON): é o que permite editar a
11
+ * configuração numa tela do host e persistir em banco (docs/01 §4). Código
12
+ * custom (regras, filtros programáticos) entra por NOME, via KanbanRegistry.
13
+ */
14
+
15
+ /**
16
+ * Modelo DECLARATIVO de cartão — serializável (docs/01 §4). O cartão é uma
17
+ * LISTA ORDENADA de blocos tipados; qualquer tipo pode repetir à vontade, em
18
+ * qualquer posição (três `date`, dois `text`… tudo válido). Nenhum campo do
19
+ * item é especial — até o título é só um bloco `text` apontando um campo.
20
+ *
21
+ * Estilo NÃO mora aqui (docs: decisão de F5.5): posicionamento/altura/tema são
22
+ * CSS externo. Cada bloco carrega apenas `class` (combinação de classes do
23
+ * host) e recebe classes-base estáveis (`mkb-block`, `mkb-block-<tipo>`…) que
24
+ * são o CONTRATO de estilo. `tone` é a exceção — carrega SIGNIFICADO
25
+ * (a11y/semântica), não só aparência, e vira `mkb-tone-<tom>`.
26
+ */
27
+ /** Todos os tipos de bloco conhecidos (usado pela validação). */
28
+ const KANBAN_BLOCK_TYPES = [
29
+ 'text',
30
+ 'number',
31
+ 'date',
32
+ 'time',
33
+ 'datetime',
34
+ 'image',
35
+ 'tag',
36
+ 'badge',
37
+ 'icon',
38
+ 'avatar',
39
+ 'facts',
40
+ 'items',
41
+ 'alert',
42
+ 'progress',
43
+ 'divider',
44
+ 'actions',
45
+ 'row',
46
+ 'custom',
47
+ ];
48
+ /** Blocos que leem um campo obrigatório do item. */
49
+ const FIELD_BLOCKS = new Set([
50
+ 'text',
51
+ 'number',
52
+ 'date',
53
+ 'time',
54
+ 'datetime',
55
+ 'image',
56
+ 'tag',
57
+ 'badge',
58
+ 'items',
59
+ 'alert',
60
+ 'progress',
61
+ ]);
62
+ function isFieldBlock(type) {
63
+ return FIELD_BLOCKS.has(type);
64
+ }
65
+
66
+ /**
67
+ * Valida um BoardConfig vindo de fora (tela de configuração do host, banco,
68
+ * arquivo). Estrutural e barata — regras nomeadas NÃO são resolvidas aqui
69
+ * (o registry é runtime; a config só carrega nomes).
70
+ */
71
+ function validateConfig(config) {
72
+ const errors = [];
73
+ const err = (path, message) => errors.push({ path, message });
74
+ if (typeof config !== 'object' || config === null || Array.isArray(config)) {
75
+ err('', 'config deve ser um objeto');
76
+ return { valid: false, errors };
77
+ }
78
+ const cfg = config;
79
+ validateAxis(cfg.columns, 'columns', true, err);
80
+ if (cfg.lanes !== undefined)
81
+ validateAxis(cfg.lanes, 'lanes', false, err);
82
+ if (cfg.items !== undefined) {
83
+ if (!Array.isArray(cfg.items)) {
84
+ err('items', 'deve ser um array');
85
+ }
86
+ else {
87
+ const seen = new Set();
88
+ cfg.items.forEach((item, i) => {
89
+ if (typeof item !== 'object' || item === null) {
90
+ err(`items[${i}]`, 'deve ser um objeto');
91
+ return;
92
+ }
93
+ const id = item.id;
94
+ if (typeof id !== 'string' || id === '') {
95
+ err(`items[${i}].id`, 'obrigatório (string não vazia)');
96
+ }
97
+ else if (seen.has(id)) {
98
+ err(`items[${i}].id`, `duplicado: '${id}'`);
99
+ }
100
+ else {
101
+ seen.add(id);
102
+ }
103
+ });
104
+ }
105
+ }
106
+ if (cfg.sort !== undefined) {
107
+ if (typeof cfg.sort !== 'object' || cfg.sort === null) {
108
+ err('sort', 'deve ser um objeto { field, direction? }');
109
+ }
110
+ else {
111
+ if (typeof cfg.sort.field !== 'string' || cfg.sort.field === '') {
112
+ err('sort.field', 'obrigatório (string não vazia)');
113
+ }
114
+ const dir = cfg.sort.direction;
115
+ if (dir !== undefined && dir !== 'asc' && dir !== 'desc') {
116
+ err('sort.direction', "deve ser 'asc' ou 'desc'");
117
+ }
118
+ }
119
+ }
120
+ if (cfg.filters !== undefined && !Array.isArray(cfg.filters)) {
121
+ err('filters', 'deve ser um array');
122
+ }
123
+ if (cfg.rules !== undefined) {
124
+ if (typeof cfg.rules !== 'object' || cfg.rules === null) {
125
+ err('rules', 'deve ser um objeto');
126
+ }
127
+ else {
128
+ if (cfg.rules.transitions !== undefined && !Array.isArray(cfg.rules.transitions)) {
129
+ err('rules.transitions', 'deve ser um array');
130
+ }
131
+ if (cfg.rules.rules !== undefined) {
132
+ if (!Array.isArray(cfg.rules.rules)) {
133
+ err('rules.rules', 'deve ser um array de nomes');
134
+ }
135
+ else {
136
+ cfg.rules.rules.forEach((name, i) => {
137
+ if (typeof name !== 'string' || name === '') {
138
+ err(`rules.rules[${i}]`, 'deve ser um nome (string não vazia)');
139
+ }
140
+ });
141
+ }
142
+ }
143
+ if (cfg.rules.entryRules !== undefined) {
144
+ if (!Array.isArray(cfg.rules.entryRules)) {
145
+ err('rules.entryRules', 'deve ser um array de nomes');
146
+ }
147
+ else {
148
+ cfg.rules.entryRules.forEach((name, i) => {
149
+ if (typeof name !== 'string' || name === '') {
150
+ err(`rules.entryRules[${i}]`, 'deve ser um nome (string não vazia)');
151
+ }
152
+ });
153
+ }
154
+ }
155
+ }
156
+ }
157
+ if (cfg.card !== undefined) {
158
+ if (typeof cfg.card !== 'object' || cfg.card === null) {
159
+ err('card', 'deve ser um objeto { blocks }');
160
+ }
161
+ else {
162
+ if (cfg.card.dragHandle !== undefined &&
163
+ typeof cfg.card.dragHandle !== 'boolean') {
164
+ err('card.dragHandle', 'deve ser boolean');
165
+ }
166
+ if (!Array.isArray(cfg.card.blocks)) {
167
+ err('card.blocks', 'deve ser um array');
168
+ }
169
+ else {
170
+ cfg.card.blocks.forEach((block, i) => validateBlock(block, `card.blocks[${i}]`, err));
171
+ }
172
+ }
173
+ }
174
+ return { valid: errors.length === 0, errors };
175
+ }
176
+ const KNOWN_BLOCK_TYPES = new Set(KANBAN_BLOCK_TYPES);
177
+ function validateBlock(block, path, err) {
178
+ if (typeof block !== 'object' || block === null || Array.isArray(block)) {
179
+ err(path, 'deve ser um objeto de bloco');
180
+ return;
181
+ }
182
+ const b = block;
183
+ const type = b.type;
184
+ if (typeof type !== 'string' || !KNOWN_BLOCK_TYPES.has(type)) {
185
+ err(`${path}.type`, `tipo de bloco desconhecido: ${String(type)}`);
186
+ return;
187
+ }
188
+ if (isFieldBlock(type)) {
189
+ if (typeof b['field'] !== 'string' || b['field'] === '') {
190
+ err(`${path}.field`, `bloco '${type}' exige field (string não vazia)`);
191
+ }
192
+ }
193
+ if (b['class'] !== undefined) {
194
+ const cls = b['class'];
195
+ const ok = typeof cls === 'string' ||
196
+ (Array.isArray(cls) && cls.every((c) => typeof c === 'string'));
197
+ if (!ok)
198
+ err(`${path}.class`, 'deve ser string ou lista de strings');
199
+ }
200
+ switch (type) {
201
+ case 'facts': {
202
+ const items = b['items'];
203
+ if (!Array.isArray(items)) {
204
+ err(`${path}.items`, "bloco 'facts' exige items (array)");
205
+ }
206
+ else {
207
+ items.forEach((fact, i) => {
208
+ if (typeof fact !== 'object' ||
209
+ fact === null ||
210
+ typeof fact.field !== 'string') {
211
+ err(`${path}.items[${i}].field`, 'obrigatório (string)');
212
+ }
213
+ });
214
+ }
215
+ break;
216
+ }
217
+ case 'actions': {
218
+ const items = b['items'];
219
+ if (!Array.isArray(items)) {
220
+ err(`${path}.items`, "bloco 'actions' exige items (array)");
221
+ }
222
+ else {
223
+ items.forEach((action, i) => {
224
+ const a = action;
225
+ if (typeof a?.id !== 'string' || a.id === '') {
226
+ err(`${path}.items[${i}].id`, 'obrigatório (string não vazia)');
227
+ }
228
+ if (typeof a?.label !== 'string') {
229
+ err(`${path}.items[${i}].label`, 'obrigatório (string)');
230
+ }
231
+ });
232
+ }
233
+ break;
234
+ }
235
+ case 'icon':
236
+ if (typeof b['icon'] !== 'string' || b['icon'] === '') {
237
+ err(`${path}.icon`, "bloco 'icon' exige icon (string não vazia)");
238
+ }
239
+ break;
240
+ case 'custom':
241
+ if (typeof b['renderer'] !== 'string' || b['renderer'] === '') {
242
+ err(`${path}.renderer`, "bloco 'custom' exige renderer (nome do registry)");
243
+ }
244
+ break;
245
+ case 'row': {
246
+ const blocks = b['blocks'];
247
+ if (!Array.isArray(blocks)) {
248
+ err(`${path}.blocks`, "bloco 'row' exige blocks (array)");
249
+ }
250
+ else {
251
+ blocks.forEach((child, i) => validateBlock(child, `${path}.blocks[${i}]`, err));
252
+ }
253
+ break;
254
+ }
255
+ }
256
+ }
257
+ function validateAxis(axis, path, required, err) {
258
+ if (axis === undefined) {
259
+ if (required)
260
+ err(path, 'obrigatório');
261
+ return;
262
+ }
263
+ const groups = Array.isArray(axis)
264
+ ? axis
265
+ : typeof axis === 'object' && axis !== null
266
+ ? axis.groups
267
+ : undefined;
268
+ if (!Array.isArray(axis) && (typeof axis !== 'object' || axis === null)) {
269
+ err(path, 'deve ser um array de grupos ou um objeto de eixo');
270
+ return;
271
+ }
272
+ if (!Array.isArray(axis)) {
273
+ const cfg = axis;
274
+ if (cfg.field !== undefined && (typeof cfg.field !== 'string' || cfg.field === '')) {
275
+ err(`${path}.field`, 'deve ser uma string não vazia');
276
+ }
277
+ if (cfg.reorderable !== undefined && typeof cfg.reorderable !== 'boolean') {
278
+ err(`${path}.reorderable`, 'deve ser boolean');
279
+ }
280
+ if (cfg.sortGroups !== undefined &&
281
+ cfg.sortGroups !== 'asc' &&
282
+ cfg.sortGroups !== 'desc') {
283
+ err(`${path}.sortGroups`, "deve ser 'asc' ou 'desc'");
284
+ }
285
+ if (cfg.groups === undefined && cfg.field === undefined) {
286
+ err(path, 'eixo derivado precisa de field; eixo estático precisa de groups');
287
+ }
288
+ if (cfg.groups !== undefined && !Array.isArray(cfg.groups)) {
289
+ err(`${path}.groups`, 'deve ser um array');
290
+ return;
291
+ }
292
+ }
293
+ else if (axis.length === 0 && required) {
294
+ err(path, 'precisa de ao menos um grupo');
295
+ }
296
+ if (Array.isArray(groups)) {
297
+ const seen = new Set();
298
+ groups.forEach((g, i) => {
299
+ const id = typeof g === 'string' ? g : g?.id;
300
+ if (typeof id !== 'string' || id === '') {
301
+ err(`${path}[${i}].id`, 'obrigatório (string não vazia)');
302
+ }
303
+ else if (seen.has(id)) {
304
+ err(`${path}[${i}].id`, `duplicado: '${id}'`);
305
+ }
306
+ else {
307
+ seen.add(id);
308
+ }
309
+ if (typeof g === 'object' && g !== null) {
310
+ const limit = g.limit;
311
+ if (limit !== undefined && (typeof limit !== 'number' || limit < 0)) {
312
+ err(`${path}[${i}].limit`, 'deve ser um número >= 0');
313
+ }
314
+ }
315
+ });
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Registro nomeado de código custom (docs/01 §4): a config serializável
321
+ * referencia funções por NOME ('rule:berco-livre', 'filter:atrasados'…) e o
322
+ * host registra as implementações aqui, uma vez.
323
+ *
324
+ * É uma INSTÂNCIA (injetada em createBoard), nunca um singleton de módulo —
325
+ * higiene de instância (docs/01 §8).
326
+ */
327
+ class KanbanRegistry {
328
+ entries = new Map();
329
+ register(name, value) {
330
+ if (!name)
331
+ throw new Error('KanbanRegistry: nome vazio');
332
+ this.entries.set(name, value);
333
+ return this;
334
+ }
335
+ get(name) {
336
+ return this.entries.get(name);
337
+ }
338
+ has(name) {
339
+ return this.entries.has(name);
340
+ }
341
+ names() {
342
+ return [...this.entries.keys()];
343
+ }
344
+ }
345
+
346
+ /** Motivos padronizados de recusa (regras nomeadas retornam texto livre). */
347
+ const MOVE_REASONS = {
348
+ readOnly: 'read-only',
349
+ locked: 'locked',
350
+ transitionNotAllowed: 'transition-not-allowed',
351
+ wipLimit: 'wip-limit',
352
+ notFound: 'not-found',
353
+ unknownGroup: 'unknown-group',
354
+ forbidden: 'forbidden',
355
+ notReorderable: 'not-reorderable',
356
+ canceled: 'canceled',
357
+ destroyed: 'destroyed',
358
+ };
359
+ const sideMatches = (side, value) => side === undefined ||
360
+ (Array.isArray(side) ? side.includes(value) : side === value);
361
+ /**
362
+ * Cadeia única de validação de movimento — usada tanto pelo preview
363
+ * (canMove) quanto pelo commit (moveItem): preview == commit por construção.
364
+ * Ordem: locked → transições → WIP → regras nomeadas.
365
+ */
366
+ function evaluateMove(ev) {
367
+ const { ctx } = ev;
368
+ const columnChanged = ctx.from.column !== ctx.to.column;
369
+ const laneChanged = ctx.from.lane !== ctx.to.lane;
370
+ if (columnChanged && (ev.fromColumnGroup?.locked || ev.toColumnGroup?.locked)) {
371
+ return MOVE_REASONS.locked;
372
+ }
373
+ if (laneChanged && (ev.fromLaneGroup?.locked || ev.toLaneGroup?.locked)) {
374
+ return MOVE_REASONS.locked;
375
+ }
376
+ if (columnChanged && ev.transitions) {
377
+ const allowed = ev.transitions.some((t) => sideMatches(t.from, ctx.from.column) && sideMatches(t.to, ctx.to.column));
378
+ if (!allowed)
379
+ return MOVE_REASONS.transitionNotAllowed;
380
+ }
381
+ const colLimit = ev.toColumnGroup?.limit;
382
+ if (columnChanged && colLimit !== undefined && ev.targetColumnCount >= colLimit) {
383
+ return MOVE_REASONS.wipLimit;
384
+ }
385
+ const laneLimit = ev.toLaneGroup?.limit;
386
+ if (laneChanged && laneLimit !== undefined && ev.targetLaneCount >= laneLimit) {
387
+ return MOVE_REASONS.wipLimit;
388
+ }
389
+ for (const name of ev.ruleNames ?? []) {
390
+ const rule = ev.registry.get(name);
391
+ if (!rule)
392
+ return `regra não registrada: ${name}`;
393
+ const verdict = rule(ctx);
394
+ if (verdict !== true)
395
+ return verdict;
396
+ }
397
+ return true;
398
+ }
399
+
400
+ /**
401
+ * Helpers PUROS de entries (itens internos de card) — docs/02 §7.
402
+ * Uma entry vive num array serializável dentro de um campo do card:
403
+ * `card[field]: KanbanEntry[]`. Nada aqui toca estado.
404
+ */
405
+ /** Lê as entries de um campo do card; valor ausente/malformado → []. */
406
+ function entriesOf(card, field) {
407
+ const value = card[field];
408
+ if (!Array.isArray(value))
409
+ return [];
410
+ return value.filter((e) => typeof e === 'object' && e !== null && typeof e.id === 'string');
411
+ }
412
+ function findEntry(card, field, entryId) {
413
+ const list = entriesOf(card, field);
414
+ const index = list.findIndex((e) => e.id === entryId);
415
+ return index === -1 ? null : { entry: list[index], index };
416
+ }
417
+ /** Remove a entry da origem e insere no destino (imutável). Para movimento
418
+ * dentro da MESMA lista, `list` já vem sem a entry — `index` é a posição
419
+ * entre as REMANESCENTES (mesma semântica do moveItem/placeInCell). */
420
+ function insertEntry(list, entry, index) {
421
+ const next = [...list];
422
+ const at = index === undefined ? next.length : Math.max(0, Math.min(index, next.length));
423
+ next.splice(at, 0, entry);
424
+ return next;
425
+ }
426
+
427
+ /** Emitter minimalista e tipado — o core não depende de RxJS de propósito
428
+ * (zero dependências; consumidores não-Angular não pagam nada). */
429
+ class Emitter {
430
+ handlers = new Map();
431
+ on(event, handler) {
432
+ let set = this.handlers.get(event);
433
+ if (!set) {
434
+ set = new Set();
435
+ this.handlers.set(event, set);
436
+ }
437
+ set.add(handler);
438
+ return () => set.delete(handler);
439
+ }
440
+ emit(event, payload) {
441
+ const set = this.handlers.get(event);
442
+ if (!set)
443
+ return;
444
+ // cópia: um handler pode (des)inscrever outros durante a emissão
445
+ for (const handler of [...set]) {
446
+ handler(payload);
447
+ }
448
+ }
449
+ clear() {
450
+ this.handlers.clear();
451
+ }
452
+ }
453
+ /** Cria o objeto de evento cancelável dos before:*. */
454
+ function cancelable(data) {
455
+ let canceled = false;
456
+ let reason;
457
+ return {
458
+ data,
459
+ get canceled() {
460
+ return canceled;
461
+ },
462
+ get reason() {
463
+ return reason;
464
+ },
465
+ cancel(r) {
466
+ canceled = true;
467
+ reason = r ?? reason;
468
+ },
469
+ };
470
+ }
471
+
472
+ const toGroupDef = (g) => typeof g === 'string' ? { id: g } : { ...g };
473
+ function normalizeAxis(input, defaultField) {
474
+ if (Array.isArray(input)) {
475
+ return {
476
+ field: defaultField,
477
+ isStatic: true,
478
+ groups: input.map(toGroupDef),
479
+ reorderable: false,
480
+ };
481
+ }
482
+ const groups = input.groups?.map(toGroupDef) ?? [];
483
+ return {
484
+ field: input.field ?? defaultField,
485
+ isStatic: groups.length > 0,
486
+ groups,
487
+ sortGroups: input.sortGroups,
488
+ reorderable: input.reorderable === true,
489
+ };
490
+ }
491
+ function normalizeConfig(config) {
492
+ return {
493
+ columns: normalizeAxis(config.columns, 'column'),
494
+ lanes: config.lanes ? normalizeAxis(config.lanes, 'lane') : null,
495
+ };
496
+ }
497
+
498
+ /** Congela o snapshot (raso + arrays); os itens são congelados na entrada. */
499
+ function freezeState(state) {
500
+ Object.freeze(state.items);
501
+ Object.freeze(state.filters);
502
+ Object.freeze(state.selection);
503
+ Object.freeze(state.columnOrder);
504
+ Object.freeze(state.laneOrder);
505
+ return Object.freeze(state);
506
+ }
507
+ function freezeItem(item) {
508
+ return Object.freeze({ ...item });
509
+ }
510
+ /**
511
+ * Store imutável POR INSTÂNCIA de board (nada de estado de módulo — higiene
512
+ * de instância, docs/01 §8). Cada mudança produz um snapshot novo e congelado.
513
+ */
514
+ class BoardStore {
515
+ current;
516
+ listeners = new Set();
517
+ constructor(initial) {
518
+ this.current = freezeState({
519
+ items: initial.items.map(freezeItem),
520
+ filters: [...initial.filters],
521
+ selection: [],
522
+ readOnly: initial.readOnly,
523
+ columnOrder: [...initial.columnOrder],
524
+ laneOrder: [...initial.laneOrder],
525
+ });
526
+ }
527
+ get state() {
528
+ return this.current;
529
+ }
530
+ /** Aplica um patch parcial e notifica os inscritos com o novo snapshot. */
531
+ set(patch) {
532
+ this.current = freezeState({ ...this.current, ...patch });
533
+ for (const listener of [...this.listeners])
534
+ listener(this.current);
535
+ return this.current;
536
+ }
537
+ subscribe(listener) {
538
+ this.listeners.add(listener);
539
+ return () => this.listeners.delete(listener);
540
+ }
541
+ clear() {
542
+ this.listeners.clear();
543
+ }
544
+ }
545
+
546
+ const DO = { record: true, cancelable: true, enforce: true };
547
+ const REDO = { record: false, cancelable: false, enforce: true };
548
+ const REMOTE = { record: false, cancelable: false, enforce: false };
549
+ const MAX_HISTORY = 100;
550
+ const str = (v) => v === undefined || v === null ? undefined : String(v);
551
+ /**
552
+ * O board headless: estado imutável + comandos + regras + eventos + undo/redo.
553
+ * Instâncias são totalmente isoladas entre si (docs/01 §8) e nada aqui toca
554
+ * DOM, Angular ou persistência — a UI e os adapters consomem esta API.
555
+ */
556
+ class KanbanBoard {
557
+ columnsAxis;
558
+ lanesAxis;
559
+ config;
560
+ store;
561
+ emitter = new Emitter();
562
+ host;
563
+ registry;
564
+ undoStack = [];
565
+ redoStack = [];
566
+ destroyed = false;
567
+ constructor(config, options = {}) {
568
+ this.config = config;
569
+ const normalized = normalizeConfig(config);
570
+ this.columnsAxis = normalized.columns;
571
+ this.lanesAxis = normalized.lanes;
572
+ this.registry = options.registry ?? new KanbanRegistry();
573
+ this.host = options.host ?? {};
574
+ this.store = new BoardStore({
575
+ items: config.items ?? [],
576
+ filters: config.filters ?? [],
577
+ readOnly: config.readOnly ?? false,
578
+ columnOrder: this.columnsAxis.isStatic
579
+ ? this.columnsAxis.groups.map((g) => g.id)
580
+ : [],
581
+ laneOrder: this.lanesAxis?.isStatic === true
582
+ ? this.lanesAxis.groups.map((g) => g.id)
583
+ : [],
584
+ });
585
+ this.store.subscribe((state) => this.emitter.emit('change', state));
586
+ }
587
+ // ---- Estado e eventos ----------------------------------------------------
588
+ get state() {
589
+ return this.store.state;
590
+ }
591
+ /** Modelo declarativo do cartão (docs/01 §4); undefined = cartão default. */
592
+ get cardConfig() {
593
+ return this.config.card;
594
+ }
595
+ /** Locale fornecido pelo host — usado por formatadores de data/número. */
596
+ get locale() {
597
+ return this.host.locale;
598
+ }
599
+ subscribe(listener) {
600
+ return this.store.subscribe(listener);
601
+ }
602
+ on(event, handler) {
603
+ return this.emitter.on(event, handler);
604
+ }
605
+ // ---- Comandos --------------------------------------------------------------
606
+ execute(command) {
607
+ if (this.destroyed)
608
+ return fail(MOVE_REASONS.destroyed);
609
+ switch (command.type) {
610
+ case 'moveItem':
611
+ return this.applyMove(command.itemId, command.to, DO);
612
+ case 'addItem':
613
+ return this.applyAdd(command.item, command.to, DO);
614
+ case 'updateItem':
615
+ return this.applyUpdate(command.itemId, command.changes, DO);
616
+ case 'removeItem':
617
+ return this.applyRemove(command.itemId, DO);
618
+ case 'moveGroup':
619
+ return this.applyMoveGroup(command.axis, command.groupId, command.index, DO);
620
+ case 'moveEntry':
621
+ return this.applyEntryMove(command, DO);
622
+ case 'addEntry':
623
+ return this.applyAddEntry(command, DO);
624
+ case 'updateEntry':
625
+ return this.applyUpdateEntry(command, DO);
626
+ case 'removeEntry':
627
+ return this.applyRemoveEntry(command, DO);
628
+ case 'setFilters':
629
+ this.store.set({ filters: Object.freeze([...command.filters]) });
630
+ return ok();
631
+ case 'setSelection': {
632
+ const valid = new Set(this.state.items.map((i) => i.id));
633
+ const selection = [...new Set(command.itemIds)].filter((id) => valid.has(id));
634
+ this.store.set({ selection });
635
+ return ok();
636
+ }
637
+ case 'setReadOnly':
638
+ this.store.set({ readOnly: command.readOnly });
639
+ return ok();
640
+ }
641
+ }
642
+ /**
643
+ * Aplica um comando de DADOS vindo de FORA (tempo real / rollback do
644
+ * adapter): não entra no histórico de undo, não dispara before:* e NÃO
645
+ * passa por readOnly/regras/permissões — o evento já aconteceu na origem
646
+ * (last-write-wins). A estrutura ainda é validada (item/grupo existem).
647
+ */
648
+ applyRemote(command) {
649
+ if (this.destroyed)
650
+ return fail(MOVE_REASONS.destroyed);
651
+ switch (command.type) {
652
+ case 'moveItem':
653
+ return this.applyMove(command.itemId, command.to, REMOTE);
654
+ case 'addItem':
655
+ return this.applyAdd(command.item, command.to, REMOTE);
656
+ case 'updateItem':
657
+ return this.applyUpdate(command.itemId, command.changes, REMOTE);
658
+ case 'removeItem':
659
+ return this.applyRemove(command.itemId, REMOTE);
660
+ case 'moveGroup':
661
+ return this.applyMoveGroup(command.axis, command.groupId, command.index, REMOTE);
662
+ case 'moveEntry':
663
+ return this.applyEntryMove(command, REMOTE);
664
+ case 'addEntry':
665
+ return this.applyAddEntry(command, REMOTE);
666
+ case 'updateEntry':
667
+ return this.applyUpdateEntry(command, REMOTE);
668
+ case 'removeEntry':
669
+ return this.applyRemoveEntry(command, REMOTE);
670
+ default:
671
+ return fail('unsupported-remote-command');
672
+ }
673
+ }
674
+ // ---- Undo / redo -----------------------------------------------------------
675
+ get canUndo() {
676
+ return this.undoStack.length > 0;
677
+ }
678
+ get canRedo() {
679
+ return this.redoStack.length > 0;
680
+ }
681
+ /** Desfaz o último comando de DADOS. Regras são revalidadas (o mundo pode
682
+ * ter mudado); before:* não dispara — undo não é vetável, mas é auditável
683
+ * pelos after:*. */
684
+ undo() {
685
+ if (this.destroyed)
686
+ return fail(MOVE_REASONS.destroyed);
687
+ const entry = this.undoStack.pop();
688
+ if (!entry)
689
+ return fail('nothing-to-undo');
690
+ const result = this.applyHistoryCmd(entry.undo);
691
+ if (result.ok)
692
+ this.redoStack.push(entry);
693
+ else
694
+ this.undoStack.push(entry);
695
+ return result;
696
+ }
697
+ redo() {
698
+ if (this.destroyed)
699
+ return fail(MOVE_REASONS.destroyed);
700
+ const entry = this.redoStack.pop();
701
+ if (!entry)
702
+ return fail('nothing-to-redo');
703
+ const result = this.applyHistoryCmd(entry.redo);
704
+ if (result.ok)
705
+ this.undoStack.push(entry);
706
+ else
707
+ this.redoStack.push(entry);
708
+ return result;
709
+ }
710
+ applyHistoryCmd(cmd) {
711
+ switch (cmd.type) {
712
+ case 'moveItem':
713
+ return this.applyMove(cmd.itemId, cmd.to, REDO);
714
+ case 'addItem':
715
+ return this.applyAdd(cmd.item, cmd.to, REDO);
716
+ case 'updateItem':
717
+ return this.applyUpdate(cmd.itemId, cmd.changes, REDO);
718
+ case 'removeItem':
719
+ return this.applyRemove(cmd.itemId, REDO);
720
+ case 'moveGroup':
721
+ return this.applyMoveGroup(cmd.axis, cmd.groupId, cmd.index, REDO);
722
+ case 'moveEntry':
723
+ return this.applyEntryMove(cmd, REDO);
724
+ case 'addEntry':
725
+ return this.applyAddEntry(cmd, REDO);
726
+ case 'updateEntry':
727
+ return this.applyUpdateEntry(cmd, REDO);
728
+ case 'removeEntry':
729
+ return this.applyRemoveEntry(cmd, REDO);
730
+ case 'insertItem': {
731
+ const items = [...this.state.items];
732
+ items.splice(Math.min(cmd.globalIndex, items.length), 0, freezeItem(cmd.item));
733
+ this.store.set({ items });
734
+ this.emitter.emit('after:add', { item: cmd.item });
735
+ return ok();
736
+ }
737
+ default:
738
+ return fail('unsupported-history-command');
739
+ }
740
+ }
741
+ // ---- Queries ---------------------------------------------------------------
742
+ /** Grupos resolvidos do eixo de colunas (estático ou derivado), com contagem. */
743
+ columns() {
744
+ return this.resolveGroups(this.columnsAxis);
745
+ }
746
+ /** Grupos do eixo de lanes; null quando o board não tem lanes. */
747
+ lanes() {
748
+ return this.lanesAxis ? this.resolveGroups(this.lanesAxis) : null;
749
+ }
750
+ /** Itens da célula (ordem manual ou `sort` da config). NÃO aplica filtros. */
751
+ itemsIn(columnId, laneId) {
752
+ const items = this.state.items.filter((item) => this.inCell(item, columnId, laneId));
753
+ return this.applySort(items);
754
+ }
755
+ /** Como itemsIn, mas só os itens visíveis pelos filtros ativos. */
756
+ visibleItemsIn(columnId, laneId) {
757
+ return this.itemsIn(columnId, laneId).filter((item) => this.isVisible(item));
758
+ }
759
+ isVisible(item) {
760
+ return this.state.filters.every((filter) => {
761
+ if ('equals' in filter)
762
+ return item[filter.field] === filter.equals;
763
+ if ('oneOf' in filter)
764
+ return filter.oneOf.includes(item[filter.field]);
765
+ if ('text' in filter) {
766
+ const needle = filter.text.toLowerCase();
767
+ const fields = filter.fields ?? Object.keys(item);
768
+ return fields.some((f) => String(item[f] ?? '')
769
+ .toLowerCase()
770
+ .includes(needle));
771
+ }
772
+ // filtro por predicado nomeado; nome não registrado NÃO filtra
773
+ const predicate = this.registry.get(filter.rule);
774
+ return predicate ? predicate(item) : true;
775
+ });
776
+ }
777
+ getItem(id) {
778
+ return this.state.items.find((item) => item.id === id);
779
+ }
780
+ find(predicate) {
781
+ return this.state.items.filter(predicate);
782
+ }
783
+ /**
784
+ * Dry-run do moveItem: roda EXATAMENTE a mesma cadeia de regras do commit
785
+ * (preview == commit). Não dispara eventos nem altera estado — é o que a UI
786
+ * usa para indicar drop válido/inválido durante o arrasto.
787
+ */
788
+ canMove(itemId, to) {
789
+ if (this.destroyed)
790
+ return fail(MOVE_REASONS.destroyed);
791
+ return this.validateMove(itemId, to).result;
792
+ }
793
+ destroy() {
794
+ this.destroyed = true;
795
+ this.emitter.clear();
796
+ this.store.clear();
797
+ this.undoStack = [];
798
+ this.redoStack = [];
799
+ }
800
+ // ---- Movimento ---------------------------------------------------------------
801
+ validateMove(itemId, to, enforce = true) {
802
+ const item = this.getItem(itemId);
803
+ if (!item)
804
+ return { result: fail(MOVE_REASONS.notFound) };
805
+ if (enforce && this.state.readOnly)
806
+ return { result: fail(MOVE_REASONS.readOnly) };
807
+ if (enforce && this.host.can?.('move', item) === false) {
808
+ return { result: fail(MOVE_REASONS.forbidden) };
809
+ }
810
+ const from = this.cellOf(item);
811
+ const target = {
812
+ column: to.column ?? from.column,
813
+ lane: this.lanesAxis ? (to.lane ?? from.lane) : undefined,
814
+ };
815
+ if (!this.lanesAxis && to.lane !== undefined) {
816
+ return { result: fail(MOVE_REASONS.unknownGroup) };
817
+ }
818
+ if (target.column === undefined)
819
+ return { result: fail(MOVE_REASONS.unknownGroup) };
820
+ if (!this.groupExists(this.columnsAxis, target.column)) {
821
+ return { result: fail(MOVE_REASONS.unknownGroup) };
822
+ }
823
+ if (this.lanesAxis &&
824
+ target.lane !== undefined &&
825
+ !this.groupExists(this.lanesAxis, target.lane)) {
826
+ return { result: fail(MOVE_REASONS.unknownGroup) };
827
+ }
828
+ if (!enforce)
829
+ return { result: ok(), item, from, target };
830
+ const ctx = {
831
+ item,
832
+ from,
833
+ to: target,
834
+ targetCount: this.countInCell(target, item.id),
835
+ state: this.state,
836
+ };
837
+ const verdict = evaluateMove({
838
+ ctx,
839
+ fromColumnGroup: this.groupDef(this.columnsAxis, from.column),
840
+ toColumnGroup: this.groupDef(this.columnsAxis, target.column),
841
+ fromLaneGroup: this.lanesAxis ? this.groupDef(this.lanesAxis, from.lane) : undefined,
842
+ toLaneGroup: this.lanesAxis ? this.groupDef(this.lanesAxis, target.lane) : undefined,
843
+ targetColumnCount: this.countInColumn(target.column, item.id),
844
+ targetLaneCount: this.lanesAxis ? this.countInLane(target.lane, item.id) : 0,
845
+ transitions: this.config.rules?.transitions,
846
+ ruleNames: this.config.rules?.rules,
847
+ registry: this.registry,
848
+ });
849
+ if (verdict !== true)
850
+ return { result: fail(verdict) };
851
+ return { result: ok(), item, from, target };
852
+ }
853
+ /**
854
+ * Nota: mover grava o ID DO GRUPO (string) no campo do eixo — um campo
855
+ * numérico agrupado dinamicamente passa a string após um movimento.
856
+ */
857
+ applyMove(itemId, to, opts) {
858
+ const { result, item, from, target } = this.validateMove(itemId, to, opts.enforce);
859
+ if (!result.ok || !item || !from || !target)
860
+ return result;
861
+ const data = { item, from, to: target, index: to.index };
862
+ if (opts.cancelable) {
863
+ const ev = cancelable(data);
864
+ this.emitter.emit('before:move', ev);
865
+ if (ev.canceled)
866
+ return fail(ev.reason ?? MOVE_REASONS.canceled);
867
+ }
868
+ const fromIndex = this.indexInCell(item);
869
+ const moved = freezeItem({
870
+ ...item,
871
+ [this.columnsAxis.field]: target.column,
872
+ ...(this.lanesAxis ? { [this.lanesAxis.field]: target.lane } : {}),
873
+ });
874
+ const without = this.state.items.filter((i) => i.id !== item.id);
875
+ const items = this.placeInCell(without, moved, to.index);
876
+ // no-op (mesma célula, mesma posição): não emite nem entra no histórico
877
+ if (items.every((it, i) => it.id === this.state.items[i]?.id) && sameCell(from, target)) {
878
+ return ok();
879
+ }
880
+ this.store.set({ items });
881
+ if (opts.record) {
882
+ this.pushHistory({
883
+ undo: {
884
+ type: 'moveItem',
885
+ itemId,
886
+ to: { column: from.column, lane: from.lane, index: fromIndex },
887
+ },
888
+ redo: { type: 'moveItem', itemId, to },
889
+ });
890
+ }
891
+ this.emitter.emit('after:move', { ...data, item: moved });
892
+ return ok();
893
+ }
894
+ // ---- Demais comandos de dados ---------------------------------------------
895
+ applyAdd(item, to, opts) {
896
+ if (opts.enforce && this.state.readOnly)
897
+ return fail(MOVE_REASONS.readOnly);
898
+ if (typeof item.id !== 'string' || item.id === '')
899
+ return fail('invalid-item');
900
+ if (this.getItem(item.id))
901
+ return fail('duplicate-id');
902
+ if (opts.enforce && this.host.can?.('add', item) === false) {
903
+ return fail(MOVE_REASONS.forbidden);
904
+ }
905
+ let candidate = { ...item };
906
+ if (to?.column !== undefined) {
907
+ candidate = { ...candidate, [this.columnsAxis.field]: to.column };
908
+ }
909
+ if (this.lanesAxis && to?.lane !== undefined) {
910
+ candidate = { ...candidate, [this.lanesAxis.field]: to.lane };
911
+ }
912
+ const cell = this.cellOf(candidate);
913
+ if (cell.column !== undefined) {
914
+ if (!this.groupExists(this.columnsAxis, cell.column)) {
915
+ return fail(MOVE_REASONS.unknownGroup);
916
+ }
917
+ const group = this.groupDef(this.columnsAxis, cell.column);
918
+ if (opts.enforce && group?.locked)
919
+ return fail(MOVE_REASONS.locked);
920
+ if (opts.enforce &&
921
+ group?.limit !== undefined &&
922
+ this.countInColumn(cell.column) >= group.limit) {
923
+ return fail(MOVE_REASONS.wipLimit);
924
+ }
925
+ }
926
+ const data = { item: candidate };
927
+ if (opts.cancelable) {
928
+ const ev = cancelable(data);
929
+ this.emitter.emit('before:add', ev);
930
+ if (ev.canceled)
931
+ return fail(ev.reason ?? MOVE_REASONS.canceled);
932
+ }
933
+ const frozen = freezeItem(candidate);
934
+ const items = cell.column === undefined
935
+ ? [...this.state.items, frozen]
936
+ : this.placeInCell([...this.state.items], frozen, to?.index);
937
+ this.store.set({ items });
938
+ if (opts.record) {
939
+ this.pushHistory({
940
+ undo: { type: 'removeItem', itemId: frozen.id },
941
+ redo: { type: 'addItem', item: frozen, to },
942
+ });
943
+ }
944
+ this.emitter.emit('after:add', { item: frozen });
945
+ return ok();
946
+ }
947
+ applyUpdate(itemId, changes, opts) {
948
+ if (opts.enforce && this.state.readOnly)
949
+ return fail(MOVE_REASONS.readOnly);
950
+ const item = this.getItem(itemId);
951
+ if (!item)
952
+ return fail(MOVE_REASONS.notFound);
953
+ if ('id' in changes && changes['id'] !== itemId)
954
+ return fail('immutable-id');
955
+ if (opts.enforce && this.host.can?.('update', item) === false) {
956
+ return fail(MOVE_REASONS.forbidden);
957
+ }
958
+ const next = { ...item, ...changes };
959
+ const from = this.cellOf(item);
960
+ const target = this.cellOf(next);
961
+ // mudança de campo de eixo é um MOVIMENTO lógico: passa pelas regras
962
+ if (!sameCell(from, target)) {
963
+ const verdict = this.validateMove(itemId, { column: target.column, lane: target.lane }, opts.enforce).result;
964
+ if (!verdict.ok)
965
+ return verdict;
966
+ }
967
+ const data = { item: next, previous: item, changes };
968
+ if (opts.cancelable) {
969
+ const ev = cancelable(data);
970
+ this.emitter.emit('before:update', ev);
971
+ if (ev.canceled)
972
+ return fail(ev.reason ?? MOVE_REASONS.canceled);
973
+ }
974
+ const frozen = freezeItem(next);
975
+ let items = this.state.items.map((i) => (i.id === itemId ? frozen : i));
976
+ if (!sameCell(from, target)) {
977
+ items = this.placeInCell(items.filter((i) => i.id !== itemId), frozen, undefined);
978
+ }
979
+ this.store.set({ items });
980
+ if (opts.record) {
981
+ const previousValues = {};
982
+ for (const key of Object.keys(changes))
983
+ previousValues[key] = item[key];
984
+ this.pushHistory({
985
+ undo: { type: 'updateItem', itemId, changes: previousValues },
986
+ redo: { type: 'updateItem', itemId, changes },
987
+ });
988
+ }
989
+ this.emitter.emit('after:update', data);
990
+ return ok();
991
+ }
992
+ applyRemove(itemId, opts) {
993
+ if (opts.enforce && this.state.readOnly)
994
+ return fail(MOVE_REASONS.readOnly);
995
+ const item = this.getItem(itemId);
996
+ if (!item)
997
+ return fail(MOVE_REASONS.notFound);
998
+ if (opts.enforce && this.host.can?.('remove', item) === false) {
999
+ return fail(MOVE_REASONS.forbidden);
1000
+ }
1001
+ const data = { item };
1002
+ if (opts.cancelable) {
1003
+ const ev = cancelable(data);
1004
+ this.emitter.emit('before:remove', ev);
1005
+ if (ev.canceled)
1006
+ return fail(ev.reason ?? MOVE_REASONS.canceled);
1007
+ }
1008
+ const globalIndex = this.state.items.findIndex((i) => i.id === itemId);
1009
+ const items = this.state.items.filter((i) => i.id !== itemId);
1010
+ const selection = this.state.selection.filter((id) => id !== itemId);
1011
+ this.store.set({ items, selection });
1012
+ if (opts.record) {
1013
+ this.pushHistory({
1014
+ undo: { type: 'insertItem', item, globalIndex },
1015
+ redo: { type: 'removeItem', itemId },
1016
+ });
1017
+ }
1018
+ this.emitter.emit('after:remove', data);
1019
+ return ok();
1020
+ }
1021
+ // ---- Reordenação de grupos (colunas/lanes) --------------------------------
1022
+ /** O eixo é reordenável? (estático, opt-in `reorderable`, sem sortGroups). */
1023
+ isReorderable(axis) {
1024
+ const norm = axis === 'columns' ? this.columnsAxis : this.lanesAxis;
1025
+ return (!!norm && norm.isStatic && norm.reorderable && norm.sortGroups === undefined);
1026
+ }
1027
+ /** Dry-run do moveGroup — mesma cadeia do commit (preview == commit). */
1028
+ canMoveGroup(axis, groupId, index) {
1029
+ if (this.destroyed)
1030
+ return fail(MOVE_REASONS.destroyed);
1031
+ return this.validateMoveGroup(axis, groupId, index, true).result;
1032
+ }
1033
+ validateMoveGroup(axis, groupId, _index, enforce) {
1034
+ const norm = axis === 'columns' ? this.columnsAxis : this.lanesAxis;
1035
+ if (!norm)
1036
+ return { result: fail(MOVE_REASONS.unknownGroup) };
1037
+ if (enforce && this.state.readOnly)
1038
+ return { result: fail(MOVE_REASONS.readOnly) };
1039
+ if (enforce && !this.isReorderable(axis)) {
1040
+ return { result: fail(MOVE_REASONS.notReorderable) };
1041
+ }
1042
+ const order = [
1043
+ ...(axis === 'columns' ? this.state.columnOrder : this.state.laneOrder),
1044
+ ];
1045
+ const from = order.indexOf(groupId);
1046
+ if (from === -1)
1047
+ return { result: fail(MOVE_REASONS.unknownGroup) };
1048
+ return { result: ok(), order, from };
1049
+ }
1050
+ applyMoveGroup(axis, groupId, index, opts) {
1051
+ const { result, order, from } = this.validateMoveGroup(axis, groupId, index, opts.enforce);
1052
+ if (!result.ok || !order || from === undefined)
1053
+ return result;
1054
+ const to = Math.max(0, Math.min(index, order.length - 1));
1055
+ if (to === from)
1056
+ return ok(); // no-op
1057
+ order.splice(from, 1);
1058
+ order.splice(to, 0, groupId);
1059
+ const data = { axis, groupId, from, to, order };
1060
+ if (opts.cancelable) {
1061
+ const ev = cancelable(data);
1062
+ this.emitter.emit('before:group-move', ev);
1063
+ if (ev.canceled)
1064
+ return fail(ev.reason ?? MOVE_REASONS.canceled);
1065
+ }
1066
+ this.store.set(axis === 'columns' ? { columnOrder: order } : { laneOrder: order });
1067
+ if (opts.record) {
1068
+ this.pushHistory({
1069
+ undo: { type: 'moveGroup', axis, groupId, index: from },
1070
+ redo: { type: 'moveGroup', axis, groupId, index: to },
1071
+ });
1072
+ }
1073
+ this.emitter.emit('after:group-move', data);
1074
+ return ok();
1075
+ }
1076
+ // ---- Entries (itens internos de card) — docs/02 §7 -------------------------
1077
+ /** Entries de um campo do card ([] quando ausente/malformado). */
1078
+ entries(cardId, field) {
1079
+ const card = this.getItem(cardId);
1080
+ return card ? entriesOf(card, field) : [];
1081
+ }
1082
+ /** Dry-run do moveEntry — mesma cadeia do commit (preview == commit). */
1083
+ canMoveEntry(cmd) {
1084
+ if (this.destroyed)
1085
+ return fail(MOVE_REASONS.destroyed);
1086
+ return this.validateEntryMove(cmd, true).result;
1087
+ }
1088
+ validateEntryMove(cmd, enforce) {
1089
+ const fromCard = this.getItem(cmd.cardId);
1090
+ if (!fromCard)
1091
+ return { result: fail(MOVE_REASONS.notFound) };
1092
+ const found = findEntry(fromCard, cmd.field, cmd.entryId);
1093
+ if (!found)
1094
+ return { result: fail('entry-not-found') };
1095
+ const toCard = this.getItem(cmd.toCardId ?? cmd.cardId);
1096
+ if (!toCard)
1097
+ return { result: fail(MOVE_REASONS.notFound) };
1098
+ const toField = cmd.toField ?? cmd.field;
1099
+ if (enforce && this.state.readOnly)
1100
+ return { result: fail(MOVE_REASONS.readOnly) };
1101
+ if (enforce &&
1102
+ (this.host.can?.('entry-move', fromCard) === false ||
1103
+ this.host.can?.('entry-move', toCard) === false)) {
1104
+ return { result: fail(MOVE_REASONS.forbidden) };
1105
+ }
1106
+ const crossList = toCard.id !== fromCard.id || toField !== cmd.field;
1107
+ if (crossList && findEntry(toCard, toField, cmd.entryId)) {
1108
+ return { result: fail('duplicate-entry') };
1109
+ }
1110
+ const targetList = crossList
1111
+ ? entriesOf(toCard, toField)
1112
+ : entriesOf(fromCard, cmd.field).filter((e) => e.id !== cmd.entryId);
1113
+ const to = {
1114
+ cardId: toCard.id,
1115
+ field: toField,
1116
+ index: Math.max(0, Math.min(cmd.index, targetList.length)),
1117
+ };
1118
+ const from = { cardId: fromCard.id, field: cmd.field, index: found.index };
1119
+ if (enforce) {
1120
+ const ctx = {
1121
+ entry: found.entry,
1122
+ fromCard,
1123
+ toCard,
1124
+ from,
1125
+ to,
1126
+ targetCount: targetList.length,
1127
+ state: this.state,
1128
+ };
1129
+ for (const name of this.config.rules?.entryRules ?? []) {
1130
+ const rule = this.registry.get(name);
1131
+ if (!rule)
1132
+ return { result: fail(`regra não registrada: ${name}`) };
1133
+ const verdict = rule(ctx);
1134
+ if (verdict !== true)
1135
+ return { result: fail(verdict) };
1136
+ }
1137
+ }
1138
+ return { result: ok(), entry: found.entry, fromCard, toCard, from, to };
1139
+ }
1140
+ applyEntryMove(cmd, opts) {
1141
+ const { result, entry, fromCard, toCard, from, to } = this.validateEntryMove(cmd, opts.enforce);
1142
+ if (!result.ok || !entry || !fromCard || !toCard || !from || !to)
1143
+ return result;
1144
+ const sameList = from.cardId === to.cardId && from.field === to.field;
1145
+ if (sameList && from.index === to.index)
1146
+ return ok(); // no-op
1147
+ const data = { entry, from, to };
1148
+ if (opts.cancelable) {
1149
+ const ev = cancelable(data);
1150
+ this.emitter.emit('before:entry-move', ev);
1151
+ if (ev.canceled)
1152
+ return fail(ev.reason ?? MOVE_REASONS.canceled);
1153
+ }
1154
+ const sourceWithout = entriesOf(fromCard, from.field).filter((e) => e.id !== entry.id);
1155
+ const patches = new Map();
1156
+ if (sameList) {
1157
+ patches.set(fromCard.id, { [from.field]: insertEntry(sourceWithout, entry, to.index) });
1158
+ }
1159
+ else {
1160
+ const inserted = insertEntry(entriesOf(toCard, to.field), entry, to.index);
1161
+ if (fromCard.id === toCard.id) {
1162
+ // mesmo card, campos diferentes: um patch com os dois campos
1163
+ patches.set(fromCard.id, { [from.field]: sourceWithout, [to.field]: inserted });
1164
+ }
1165
+ else {
1166
+ patches.set(fromCard.id, { [from.field]: sourceWithout });
1167
+ patches.set(toCard.id, { [to.field]: inserted });
1168
+ }
1169
+ }
1170
+ this.patchCards(patches);
1171
+ if (opts.record) {
1172
+ this.pushHistory({
1173
+ undo: {
1174
+ type: 'moveEntry',
1175
+ cardId: to.cardId,
1176
+ field: to.field,
1177
+ entryId: entry.id,
1178
+ index: from.index,
1179
+ toCardId: from.cardId,
1180
+ toField: from.field,
1181
+ },
1182
+ redo: { type: 'moveEntry', ...cmd },
1183
+ });
1184
+ }
1185
+ this.emitter.emit('after:entry-move', data);
1186
+ return ok();
1187
+ }
1188
+ applyAddEntry(cmd, opts) {
1189
+ const guard = this.entryWriteGuard(cmd.cardId, opts);
1190
+ if (!guard.ok)
1191
+ return guard.result;
1192
+ const card = guard.card;
1193
+ if (typeof cmd.entry.id !== 'string' || cmd.entry.id === '')
1194
+ return fail('invalid-entry');
1195
+ if (findEntry(card, cmd.field, cmd.entry.id))
1196
+ return fail('duplicate-entry');
1197
+ const data = {
1198
+ op: 'add',
1199
+ cardId: card.id,
1200
+ field: cmd.field,
1201
+ entry: cmd.entry,
1202
+ };
1203
+ if (opts.cancelable) {
1204
+ const ev = cancelable(data);
1205
+ this.emitter.emit('before:entry-update', ev);
1206
+ if (ev.canceled)
1207
+ return fail(ev.reason ?? MOVE_REASONS.canceled);
1208
+ }
1209
+ const list = insertEntry(entriesOf(card, cmd.field), cmd.entry, cmd.index);
1210
+ this.patchCards(new Map([[card.id, { [cmd.field]: list }]]));
1211
+ if (opts.record) {
1212
+ this.pushHistory({
1213
+ undo: { type: 'removeEntry', cardId: card.id, field: cmd.field, entryId: cmd.entry.id },
1214
+ redo: { type: 'addEntry', ...cmd },
1215
+ });
1216
+ }
1217
+ this.emitter.emit('after:entry-update', data);
1218
+ return ok();
1219
+ }
1220
+ applyUpdateEntry(cmd, opts) {
1221
+ const guard = this.entryWriteGuard(cmd.cardId, opts);
1222
+ if (!guard.ok)
1223
+ return guard.result;
1224
+ const card = guard.card;
1225
+ const found = findEntry(card, cmd.field, cmd.entryId);
1226
+ if (!found)
1227
+ return fail('entry-not-found');
1228
+ if ('id' in cmd.changes && cmd.changes['id'] !== cmd.entryId)
1229
+ return fail('immutable-id');
1230
+ const next = { ...found.entry, ...cmd.changes };
1231
+ const data = {
1232
+ op: 'update',
1233
+ cardId: card.id,
1234
+ field: cmd.field,
1235
+ entry: next,
1236
+ previous: found.entry,
1237
+ changes: cmd.changes,
1238
+ };
1239
+ if (opts.cancelable) {
1240
+ const ev = cancelable(data);
1241
+ this.emitter.emit('before:entry-update', ev);
1242
+ if (ev.canceled)
1243
+ return fail(ev.reason ?? MOVE_REASONS.canceled);
1244
+ }
1245
+ const list = entriesOf(card, cmd.field).map((e) => (e.id === cmd.entryId ? next : e));
1246
+ this.patchCards(new Map([[card.id, { [cmd.field]: list }]]));
1247
+ if (opts.record) {
1248
+ const previousValues = {};
1249
+ for (const key of Object.keys(cmd.changes))
1250
+ previousValues[key] = found.entry[key];
1251
+ this.pushHistory({
1252
+ undo: {
1253
+ type: 'updateEntry',
1254
+ cardId: card.id,
1255
+ field: cmd.field,
1256
+ entryId: cmd.entryId,
1257
+ changes: previousValues,
1258
+ },
1259
+ redo: { type: 'updateEntry', ...cmd },
1260
+ });
1261
+ }
1262
+ this.emitter.emit('after:entry-update', data);
1263
+ return ok();
1264
+ }
1265
+ applyRemoveEntry(cmd, opts) {
1266
+ const guard = this.entryWriteGuard(cmd.cardId, opts);
1267
+ if (!guard.ok)
1268
+ return guard.result;
1269
+ const card = guard.card;
1270
+ const found = findEntry(card, cmd.field, cmd.entryId);
1271
+ if (!found)
1272
+ return fail('entry-not-found');
1273
+ const data = {
1274
+ op: 'remove',
1275
+ cardId: card.id,
1276
+ field: cmd.field,
1277
+ entry: found.entry,
1278
+ };
1279
+ if (opts.cancelable) {
1280
+ const ev = cancelable(data);
1281
+ this.emitter.emit('before:entry-update', ev);
1282
+ if (ev.canceled)
1283
+ return fail(ev.reason ?? MOVE_REASONS.canceled);
1284
+ }
1285
+ const list = entriesOf(card, cmd.field).filter((e) => e.id !== cmd.entryId);
1286
+ this.patchCards(new Map([[card.id, { [cmd.field]: list }]]));
1287
+ if (opts.record) {
1288
+ this.pushHistory({
1289
+ undo: {
1290
+ type: 'addEntry',
1291
+ cardId: card.id,
1292
+ field: cmd.field,
1293
+ entry: found.entry,
1294
+ index: found.index,
1295
+ },
1296
+ redo: { type: 'removeEntry', ...cmd },
1297
+ });
1298
+ }
1299
+ this.emitter.emit('after:entry-update', data);
1300
+ return ok();
1301
+ }
1302
+ /** Guardas comuns de escrita de entry (card existe, readOnly, permissão). */
1303
+ entryWriteGuard(cardId, opts) {
1304
+ const card = this.getItem(cardId);
1305
+ if (!card)
1306
+ return { ok: false, result: fail(MOVE_REASONS.notFound) };
1307
+ if (opts.enforce && this.state.readOnly) {
1308
+ return { ok: false, result: fail(MOVE_REASONS.readOnly) };
1309
+ }
1310
+ if (opts.enforce && this.host.can?.('entry-update', card) === false) {
1311
+ return { ok: false, result: fail(MOVE_REASONS.forbidden) };
1312
+ }
1313
+ return { ok: true, card };
1314
+ }
1315
+ /** Aplica patches de campos a um ou mais cards num único snapshot. */
1316
+ patchCards(patches) {
1317
+ const items = this.state.items.map((item) => {
1318
+ const patch = patches.get(item.id);
1319
+ return patch ? freezeItem({ ...item, ...patch }) : item;
1320
+ });
1321
+ this.store.set({ items });
1322
+ }
1323
+ // ---- Infra interna ----------------------------------------------------------
1324
+ pushHistory(entry) {
1325
+ this.undoStack.push(entry);
1326
+ if (this.undoStack.length > MAX_HISTORY)
1327
+ this.undoStack.shift();
1328
+ this.redoStack = [];
1329
+ }
1330
+ cellOf(item) {
1331
+ return {
1332
+ column: str(item[this.columnsAxis.field]),
1333
+ lane: this.lanesAxis ? str(item[this.lanesAxis.field]) : undefined,
1334
+ };
1335
+ }
1336
+ inCell(item, columnId, laneId) {
1337
+ const cell = this.cellOf(item);
1338
+ if (cell.column !== columnId)
1339
+ return false;
1340
+ return laneId === undefined || cell.lane === laneId;
1341
+ }
1342
+ countInCell(cell, excludeId) {
1343
+ return this.state.items.filter((i) => i.id !== excludeId && sameCell(this.cellOf(i), cell)).length;
1344
+ }
1345
+ countInColumn(columnId, excludeId) {
1346
+ return this.state.items.filter((i) => i.id !== excludeId && this.cellOf(i).column === columnId).length;
1347
+ }
1348
+ countInLane(laneId, excludeId) {
1349
+ return this.state.items.filter((i) => i.id !== excludeId && this.cellOf(i).lane === laneId).length;
1350
+ }
1351
+ /** Posição do item entre os itens da própria célula (ordem manual). */
1352
+ indexInCell(item) {
1353
+ const cell = this.cellOf(item);
1354
+ let index = 0;
1355
+ for (const it of this.state.items) {
1356
+ if (it.id === item.id)
1357
+ return index;
1358
+ if (sameCell(this.cellOf(it), cell))
1359
+ index++;
1360
+ }
1361
+ return index;
1362
+ }
1363
+ /** Insere `item` (já com os campos novos) na posição `index` da sua célula,
1364
+ * mapeada para a posição global correspondente. `items` NÃO contém o item. */
1365
+ placeInCell(items, item, index) {
1366
+ const cell = this.cellOf(item);
1367
+ const positions = [];
1368
+ items.forEach((it, i) => {
1369
+ if (sameCell(this.cellOf(it), cell))
1370
+ positions.push(i);
1371
+ });
1372
+ let globalIndex;
1373
+ if (index === undefined || index >= positions.length) {
1374
+ globalIndex = positions.length
1375
+ ? positions[positions.length - 1] + 1
1376
+ : items.length;
1377
+ }
1378
+ else if (index <= 0) {
1379
+ globalIndex = positions.length ? positions[0] : items.length;
1380
+ }
1381
+ else {
1382
+ globalIndex = positions[index];
1383
+ }
1384
+ const next = [...items];
1385
+ next.splice(globalIndex, 0, item);
1386
+ return next;
1387
+ }
1388
+ groupExists(axis, id) {
1389
+ // eixo derivado aceita qualquer valor (o grupo passa a existir ao receber)
1390
+ return !axis.isStatic || axis.groups.some((g) => g.id === id);
1391
+ }
1392
+ groupDef(axis, id) {
1393
+ return id === undefined ? undefined : axis.groups.find((g) => g.id === id);
1394
+ }
1395
+ resolveGroups(axis) {
1396
+ let groups;
1397
+ if (axis.isStatic) {
1398
+ // ordem vem do estado (mutável via moveGroup); grupos novos que não
1399
+ // estejam na ordem entram ao final, na ordem declarada.
1400
+ const order = axis === this.columnsAxis ? this.state.columnOrder : this.state.laneOrder;
1401
+ const byId = new Map(axis.groups.map((g) => [g.id, g]));
1402
+ const ordered = [
1403
+ ...order.filter((id) => byId.has(id)),
1404
+ ...axis.groups.map((g) => g.id).filter((id) => !order.includes(id)),
1405
+ ];
1406
+ groups = ordered.map((id) => ({
1407
+ ...byId.get(id),
1408
+ count: this.countByField(axis.field, id),
1409
+ derived: false,
1410
+ }));
1411
+ }
1412
+ else {
1413
+ const seen = new Map();
1414
+ for (const item of this.state.items) {
1415
+ const value = str(item[axis.field]);
1416
+ if (value === undefined)
1417
+ continue;
1418
+ seen.set(value, (seen.get(value) ?? 0) + 1);
1419
+ }
1420
+ groups = [...seen.entries()].map(([id, count]) => ({ id, count, derived: true }));
1421
+ }
1422
+ if (axis.sortGroups) {
1423
+ const dir = axis.sortGroups === 'asc' ? 1 : -1;
1424
+ groups = [...groups].sort((a, b) => dir * a.id.localeCompare(b.id));
1425
+ }
1426
+ return groups;
1427
+ }
1428
+ countByField(field, groupId) {
1429
+ return this.state.items.filter((i) => str(i[field]) === groupId).length;
1430
+ }
1431
+ applySort(items) {
1432
+ const sort = this.config.sort;
1433
+ if (!sort)
1434
+ return items;
1435
+ const dir = sort.direction === 'desc' ? -1 : 1;
1436
+ return [...items].sort((a, b) => dir * compare(a[sort.field], b[sort.field]));
1437
+ }
1438
+ }
1439
+ // ---- Helpers de módulo (puros) ----------------------------------------------
1440
+ const ok = () => ({ ok: true });
1441
+ const fail = (reason) => ({ ok: false, reason });
1442
+ const sameCell = (a, b) => a.column === b.column && a.lane === b.lane;
1443
+ function compare(a, b) {
1444
+ if (a === b)
1445
+ return 0;
1446
+ if (a === undefined || a === null)
1447
+ return -1;
1448
+ if (b === undefined || b === null)
1449
+ return 1;
1450
+ if (typeof a === 'number' && typeof b === 'number')
1451
+ return a - b;
1452
+ return String(a).localeCompare(String(b));
1453
+ }
1454
+ /**
1455
+ * Cria um board a partir de uma config serializável. Falha RÁPIDO (throw) em
1456
+ * config estruturalmente inválida — para validar sem lançar (ex.: config
1457
+ * vinda do banco), use validateConfig antes.
1458
+ */
1459
+ function createBoard(config, options = {}) {
1460
+ const validation = validateConfig(config);
1461
+ if (!validation.valid) {
1462
+ const detail = validation.errors
1463
+ .map((e) => (e.path ? `${e.path}: ${e.message}` : e.message))
1464
+ .join('; ');
1465
+ throw new Error(`BoardConfig inválido — ${detail}`);
1466
+ }
1467
+ return new KanbanBoard(config, options);
1468
+ }
1469
+
1470
+ /*
1471
+ * @mosaicoo/kanban/core — núcleo HEADLESS do produto.
1472
+ *
1473
+ * REGRA ESTRUTURAL (docs/01-arquitetura.md §2): este entry point é TypeScript
1474
+ * puro, com ZERO dependências — nada de Angular, DOM, RxJS ou browser APIs.
1475
+ * O guard-rail em src/api-surface.spec.ts congela a superfície pública;
1476
+ * mudanças aqui são sempre deliberadas.
1477
+ */
1478
+
1479
+ /**
1480
+ * Generated bundle index. Do not edit.
1481
+ */
1482
+
1483
+ export { KANBAN_BLOCK_TYPES, KanbanBoard, KanbanRegistry, MKB_VERSION, MOVE_REASONS, createBoard, entriesOf, isFieldBlock, validateConfig };