ezfw-core 1.0.21 → 1.0.23

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 (43) hide show
  1. package/components/EzBaseComponent.ts +100 -5
  2. package/components/EzComponent.ts +3 -3
  3. package/components/EzLabel.ts +12 -3
  4. package/components/avatar/EzAvatar.ts +84 -54
  5. package/components/badge/EzBadge.ts +43 -24
  6. package/components/button/EzButton.ts +5 -3
  7. package/components/button/EzButtonGroup.ts +7 -10
  8. package/components/card/EzCard.ts +2 -1
  9. package/components/chart/EzChart.ts +20 -15
  10. package/components/checkbox/EzCheckbox.ts +47 -43
  11. package/components/dataview/EzDataView.ts +14 -29
  12. package/components/dataview/modes/EzDataViewCards.ts +51 -41
  13. package/components/dataview/modes/EzDataViewGrid.ts +5 -2
  14. package/components/datepicker/EzDatePicker.ts +2 -2
  15. package/components/dialog/EzDialog.ts +84 -67
  16. package/components/dropdown/EzDropdown.ts +72 -58
  17. package/components/form/EzForm.ts +45 -37
  18. package/components/kanban/EzKanban.module.scss +221 -0
  19. package/components/kanban/EzKanban.ts +222 -0
  20. package/components/kanban/EzKanbanTypes.ts +166 -0
  21. package/components/kanban/board/EzKanbanBoard.ts +117 -0
  22. package/components/kanban/card/EzKanbanCard.module.scss +173 -0
  23. package/components/kanban/card/EzKanbanCard.ts +275 -0
  24. package/components/kanban/card/EzKanbanCardEditor.ts +209 -0
  25. package/components/kanban/column/EzKanbanColumn.ts +253 -0
  26. package/components/kanban/state/EzKanbanController.ts +373 -0
  27. package/components/kanban/state/EzKanbanDragDrop.ts +226 -0
  28. package/components/panel/EzPanel.ts +59 -68
  29. package/components/picker/EzPicker.module.scss +14 -0
  30. package/components/picker/EzPicker.ts +118 -0
  31. package/components/radio/EzRadio.ts +55 -47
  32. package/components/select/EzSelect.ts +48 -44
  33. package/components/skeleton/EzSkeleton.ts +31 -26
  34. package/components/switch/EzSwitch.ts +52 -44
  35. package/components/tabs/EzTabPanel.ts +52 -48
  36. package/components/textarea/EzTextarea.ts +69 -54
  37. package/components/timepicker/EzTimePicker.ts +2 -2
  38. package/components/tooltip/EzTooltip.ts +20 -33
  39. package/core/ez.ts +7 -0
  40. package/core/loader.ts +2 -0
  41. package/core/renderer.ts +80 -4
  42. package/core/styleShortcuts.ts +418 -0
  43. package/package.json +1 -1
@@ -0,0 +1,253 @@
1
+ import { EzBaseComponent } from '../../EzBaseComponent.js';
2
+ import type { EzKanbanController } from '../state/EzKanbanController.js';
3
+ import type { EzKanbanDragDrop } from '../state/EzKanbanDragDrop.js';
4
+ import type { EzKanbanConfig, NormalizedColumn, KanbanCard } from '../EzKanbanTypes.js';
5
+
6
+ declare const ez: {
7
+ _createElement(config: unknown): Promise<HTMLElement>;
8
+ define(name: string, cls: unknown): void;
9
+ dialog: {
10
+ prompt(opts: { title: string; placeholder: string; value?: string }): Promise<string | null>;
11
+ danger(opts: { title: string; message: string }): Promise<boolean>;
12
+ open(opts: unknown): Promise<unknown>;
13
+ };
14
+ };
15
+
16
+ interface EzKanbanColumnConfig {
17
+ column: NormalizedColumn;
18
+ kanbanController: EzKanbanController;
19
+ kanbanDragDrop: EzKanbanDragDrop;
20
+ kanbanConfig: EzKanbanConfig;
21
+ [key: string]: unknown;
22
+ }
23
+
24
+ export class EzKanbanColumn extends EzBaseComponent {
25
+ declare config: EzKanbanColumnConfig;
26
+
27
+ private _el: HTMLElement | null = null;
28
+ private _cardsContainer: HTMLElement | null = null;
29
+
30
+ async render(): Promise<HTMLElement> {
31
+ const { column, kanbanController, kanbanDragDrop, kanbanConfig } = this.config;
32
+
33
+ const width = kanbanConfig.columnWidth || 280;
34
+ const minWidth = kanbanConfig.columnMinWidth || 250;
35
+ const maxWidth = kanbanConfig.columnMaxWidth || 400;
36
+
37
+ const items: unknown[] = [];
38
+
39
+ // Header
40
+ items.push(await this._buildHeaderConfig());
41
+
42
+ // Cards container
43
+ const cardsContainerConfig = {
44
+ eztype: 'div',
45
+ cls: 'ez-kanban-column-cards',
46
+ items: await this._buildCardsConfigs()
47
+ };
48
+ items.push(cardsContainerConfig);
49
+
50
+ // Add card button
51
+ if (kanbanConfig.addCardEnabled !== false && !column.locked) {
52
+ items.push({
53
+ eztype: 'button',
54
+ cls: 'ez-kanban-add-card',
55
+ items: [
56
+ { eztype: 'i', cls: 'fa-solid fa-plus' },
57
+ { eztype: 'span', text: 'Add card' }
58
+ ],
59
+ onClick: () => this._openCardDialog()
60
+ });
61
+ }
62
+
63
+ const el = await ez._createElement({
64
+ eztype: 'div',
65
+ cls: 'ez-kanban-column',
66
+ 'data-column-id': column._id,
67
+ style: {
68
+ width: `${width}px`,
69
+ minWidth: `${minWidth}px`,
70
+ maxWidth: `${maxWidth}px`,
71
+ '--column-color': column.color || undefined
72
+ },
73
+ items
74
+ }) as HTMLElement;
75
+
76
+ this._el = el;
77
+ this._cardsContainer = el.querySelector('.ez-kanban-column-cards');
78
+
79
+ this._setupDragDrop();
80
+
81
+ return el;
82
+ }
83
+
84
+ private async _buildHeaderConfig(): Promise<unknown> {
85
+ const { column, kanbanController, kanbanConfig } = this.config;
86
+ const cards = kanbanController.getFilteredCards(column._id);
87
+
88
+ const countText = column.limit
89
+ ? `${cards.length}/${column.limit}`
90
+ : String(cards.length);
91
+
92
+ const countCls = column.limit && cards.length >= column.limit
93
+ ? 'column-count at-limit'
94
+ : 'column-count';
95
+
96
+ const actionItems: unknown[] = [];
97
+
98
+ if (kanbanConfig.columnsEditable !== false) {
99
+ actionItems.push({
100
+ eztype: 'button',
101
+ cls: 'column-action',
102
+ title: 'Edit column',
103
+ items: [{ eztype: 'i', cls: 'fa-solid fa-pen' }],
104
+ onClick: (e: Event) => {
105
+ e.stopPropagation();
106
+ this._editColumn();
107
+ }
108
+ });
109
+ }
110
+
111
+ if (kanbanConfig.columnsDeletable !== false) {
112
+ actionItems.push({
113
+ eztype: 'button',
114
+ cls: 'column-action column-action--danger',
115
+ title: 'Delete column',
116
+ items: [{ eztype: 'i', cls: 'fa-solid fa-trash' }],
117
+ onClick: (e: Event) => {
118
+ e.stopPropagation();
119
+ this._deleteColumn();
120
+ }
121
+ });
122
+ }
123
+
124
+ return {
125
+ eztype: 'div',
126
+ cls: 'ez-kanban-column-header',
127
+ items: [
128
+ { eztype: 'span', cls: 'column-title', text: column.title },
129
+ { eztype: 'span', cls: countCls, text: countText },
130
+ { eztype: 'div', cls: 'column-actions', items: actionItems }
131
+ ]
132
+ };
133
+ }
134
+
135
+ private async _buildCardsConfigs(): Promise<unknown[]> {
136
+ const { column, kanbanController, kanbanDragDrop, kanbanConfig } = this.config;
137
+ const cards = kanbanController.getFilteredCards(column._id);
138
+
139
+ return cards.map(card => ({
140
+ eztype: 'EzKanbanCard',
141
+ card,
142
+ column,
143
+ kanbanController,
144
+ kanbanDragDrop,
145
+ kanbanConfig
146
+ }));
147
+ }
148
+
149
+ private _setupDragDrop(): void {
150
+ const { column, kanbanController, kanbanDragDrop, kanbanConfig } = this.config;
151
+
152
+ if (kanbanConfig.columnsReorderable !== false && this._el) {
153
+ const header = this._el.querySelector('.ez-kanban-column-header') as HTMLElement;
154
+ if (header) {
155
+ header.draggable = true;
156
+
157
+ header.addEventListener('dragstart', (e) => {
158
+ kanbanDragDrop.handleColumnDragStart(e, column);
159
+ });
160
+
161
+ header.addEventListener('dragend', () => {
162
+ kanbanDragDrop.handleColumnDragEnd();
163
+ });
164
+ }
165
+
166
+ this._el.addEventListener('dragover', (e) => {
167
+ const ctx = kanbanController.getDragContext();
168
+ if (ctx?.type === 'column') {
169
+ kanbanDragDrop.handleColumnDragOver(e, column);
170
+ }
171
+ });
172
+
173
+ this._el.addEventListener('drop', (e) => {
174
+ const ctx = kanbanController.getDragContext();
175
+ if (ctx?.type === 'column') {
176
+ kanbanDragDrop.handleColumnDrop(e, column);
177
+ }
178
+ });
179
+ }
180
+
181
+ if (kanbanConfig.cardsReorderable !== false && this._cardsContainer) {
182
+ this._cardsContainer.addEventListener('dragover', (e) => {
183
+ const ctx = kanbanController.getDragContext();
184
+ if (ctx?.type === 'card') {
185
+ kanbanDragDrop.handleCardDragOver(e, column._id);
186
+ }
187
+ });
188
+
189
+ this._cardsContainer.addEventListener('dragleave', (e) => {
190
+ kanbanDragDrop.handleCardDragLeave(e);
191
+ });
192
+
193
+ this._cardsContainer.addEventListener('drop', (e) => {
194
+ const ctx = kanbanController.getDragContext();
195
+ if (ctx?.type === 'card') {
196
+ kanbanDragDrop.handleCardDrop(e, column._id, this._cardsContainer!);
197
+ }
198
+ });
199
+ }
200
+ }
201
+
202
+ private _editColumn(): void {
203
+ const { column, kanbanController } = this.config;
204
+
205
+ ez.dialog.prompt({
206
+ title: 'Edit Column',
207
+ placeholder: 'Column name',
208
+ value: column.title
209
+ }).then((name: string | null) => {
210
+ if (name && name !== column.title) {
211
+ kanbanController.updateColumn(column._id, { title: name });
212
+ }
213
+ });
214
+ }
215
+
216
+ private _deleteColumn(): void {
217
+ const { column, kanbanController } = this.config;
218
+
219
+ ez.dialog.danger({
220
+ title: 'Delete Column',
221
+ message: `Are you sure you want to delete "${column.title}"? All cards in this column will be deleted.`
222
+ }).then((confirmed: boolean) => {
223
+ if (confirmed) {
224
+ kanbanController.deleteColumn(column._id);
225
+ }
226
+ });
227
+ }
228
+
229
+ private _openCardDialog(card?: KanbanCard): void {
230
+ const { column, kanbanController, kanbanConfig } = this.config;
231
+
232
+ ez.dialog.open({
233
+ title: card ? 'Edit Card' : 'New Card',
234
+ size: 'md',
235
+ items: [{
236
+ eztype: 'EzKanbanCardEditor',
237
+ card,
238
+ columnId: column._id,
239
+ availableLabels: kanbanConfig.availableLabels,
240
+ availableAssignees: kanbanConfig.availableAssignees,
241
+ onSave: (cardData: Partial<KanbanCard>) => {
242
+ if (card) {
243
+ kanbanController.updateCard(card.id, cardData);
244
+ } else {
245
+ kanbanController.addCard(column._id, cardData);
246
+ }
247
+ }
248
+ }]
249
+ });
250
+ }
251
+ }
252
+
253
+ ez.define('EzKanbanColumn', EzKanbanColumn);
@@ -0,0 +1,373 @@
1
+ import { signal, computed } from '@preact/signals-core';
2
+ import type {
3
+ KanbanCard,
4
+ KanbanColumn,
5
+ NormalizedColumn,
6
+ KanbanControllerState,
7
+ KanbanFilterConfig,
8
+ DragContext,
9
+ EzKanbanConfig
10
+ } from '../EzKanbanTypes.js';
11
+
12
+ export class EzKanbanController {
13
+ private _columns = signal<NormalizedColumn[]>([]);
14
+ private _loading = signal(false);
15
+ private _error = signal<Error | null>(null);
16
+ private _filters = signal<KanbanFilterConfig>({});
17
+ private _dragContext = signal<DragContext | null>(null);
18
+
19
+ private _listeners: Record<string, Array<(payload: unknown) => void>> = {};
20
+ private _config: EzKanbanConfig;
21
+
22
+ constructor(config: EzKanbanConfig) {
23
+ this._config = config;
24
+
25
+ if (config.columns || config.cards) {
26
+ this.setData(config.columns || [], config.cards || []);
27
+ }
28
+ }
29
+
30
+ get state(): KanbanControllerState {
31
+ return {
32
+ columns: this._columns.value,
33
+ loading: this._loading.value,
34
+ error: this._error.value,
35
+ filters: this._filters.value,
36
+ dragContext: this._dragContext.value
37
+ };
38
+ }
39
+
40
+ get columns() {
41
+ return computed(() => this._columns.value);
42
+ }
43
+
44
+ get loading() {
45
+ return computed(() => this._loading.value);
46
+ }
47
+
48
+ on(event: string, fn: (payload: unknown) => void): () => void {
49
+ (this._listeners[event] ??= []).push(fn);
50
+ return () => this.off(event, fn);
51
+ }
52
+
53
+ off(event: string, fn: (payload: unknown) => void): void {
54
+ if (!this._listeners[event]) return;
55
+ this._listeners[event] = this._listeners[event].filter(l => l !== fn);
56
+ }
57
+
58
+ emit(event: string, payload: unknown): void {
59
+ this._listeners[event]?.forEach(fn => fn(payload));
60
+ }
61
+
62
+ setData(columns: KanbanColumn[], cards: KanbanCard[]): void {
63
+ const normalized = this._normalizeColumns(columns, cards);
64
+ this._columns.value = normalized;
65
+ this.emit('datachange', normalized);
66
+ }
67
+
68
+ async load(): Promise<void> {
69
+ const remote = this._config.remote;
70
+ if (!remote) return;
71
+
72
+ this._loading.value = true;
73
+ this.emit('loadingchange', true);
74
+
75
+ try {
76
+ const result = await (window as unknown as { ez: { _api: { request: (url: string) => Promise<unknown> } } }).ez._api.request(remote.api);
77
+
78
+ const source = remote.source || {};
79
+ const columnsPath = source.columnsPath || 'columns';
80
+ const cardsPath = source.cardsPath || 'cards';
81
+
82
+ const columns = this._getNestedValue(result, columnsPath) as KanbanColumn[];
83
+ const cards = this._getNestedValue(result, cardsPath) as KanbanCard[];
84
+
85
+ this.setData(columns, cards);
86
+ } catch (err) {
87
+ this._error.value = err as Error;
88
+ this.emit('error', err);
89
+ } finally {
90
+ this._loading.value = false;
91
+ this.emit('loadingchange', false);
92
+ }
93
+ }
94
+
95
+ async reload(): Promise<void> {
96
+ return this.load();
97
+ }
98
+
99
+ addCard(columnId: string | number, cardData: Partial<KanbanCard>): KanbanCard {
100
+ const id = cardData.id || `card-${Date.now()}`;
101
+ const card: KanbanCard = {
102
+ id,
103
+ columnId,
104
+ title: cardData.title || 'New Card',
105
+ ...cardData
106
+ };
107
+
108
+ const columns = [...this._columns.value];
109
+ const colIndex = columns.findIndex(c => c._id === String(columnId));
110
+
111
+ if (colIndex !== -1) {
112
+ columns[colIndex] = {
113
+ ...columns[colIndex],
114
+ cards: [card, ...columns[colIndex].cards]
115
+ };
116
+ this._columns.value = columns;
117
+ this.emit('cardcreate', { card, columnId });
118
+ this.emit('datachange', columns);
119
+ }
120
+
121
+ return card;
122
+ }
123
+
124
+ updateCard(cardId: string | number, updates: Partial<KanbanCard>): KanbanCard | null {
125
+ const columns = [...this._columns.value];
126
+ let updatedCard: KanbanCard | null = null;
127
+
128
+ for (let i = 0; i < columns.length; i++) {
129
+ const cardIndex = columns[i].cards.findIndex(c => c.id === cardId);
130
+ if (cardIndex !== -1) {
131
+ updatedCard = { ...columns[i].cards[cardIndex], ...updates };
132
+ columns[i] = {
133
+ ...columns[i],
134
+ cards: columns[i].cards.map((c, idx) =>
135
+ idx === cardIndex ? updatedCard! : c
136
+ )
137
+ };
138
+ break;
139
+ }
140
+ }
141
+
142
+ if (updatedCard) {
143
+ this._columns.value = columns;
144
+ this.emit('cardupdate', { card: updatedCard, changes: updates });
145
+ this.emit('datachange', columns);
146
+ }
147
+
148
+ return updatedCard;
149
+ }
150
+
151
+ deleteCard(cardId: string | number): KanbanCard | null {
152
+ const columns = [...this._columns.value];
153
+ let deletedCard: KanbanCard | null = null;
154
+ let columnId: string | null = null;
155
+
156
+ for (let i = 0; i < columns.length; i++) {
157
+ const cardIndex = columns[i].cards.findIndex(c => c.id === cardId);
158
+ if (cardIndex !== -1) {
159
+ deletedCard = columns[i].cards[cardIndex];
160
+ columnId = columns[i]._id;
161
+ columns[i] = {
162
+ ...columns[i],
163
+ cards: columns[i].cards.filter(c => c.id !== cardId)
164
+ };
165
+ break;
166
+ }
167
+ }
168
+
169
+ if (deletedCard) {
170
+ this._columns.value = columns;
171
+ this.emit('carddelete', { card: deletedCard, columnId });
172
+ this.emit('datachange', columns);
173
+ }
174
+
175
+ return deletedCard;
176
+ }
177
+
178
+ moveCard(cardId: string | number, toColumnId: string | number, toIndex: number): void {
179
+ const columns = [...this._columns.value];
180
+ let movedCard: KanbanCard | null = null;
181
+ let fromColumnId: string | null = null;
182
+ let fromIndex = -1;
183
+
184
+ for (let i = 0; i < columns.length; i++) {
185
+ const cardIndex = columns[i].cards.findIndex(c => c.id === cardId);
186
+ if (cardIndex !== -1) {
187
+ movedCard = columns[i].cards[cardIndex];
188
+ fromColumnId = columns[i]._id;
189
+ fromIndex = cardIndex;
190
+ columns[i] = {
191
+ ...columns[i],
192
+ cards: columns[i].cards.filter(c => c.id !== cardId)
193
+ };
194
+ break;
195
+ }
196
+ }
197
+
198
+ if (!movedCard || fromColumnId === null) return;
199
+
200
+ const targetColIndex = columns.findIndex(c => c._id === String(toColumnId));
201
+ if (targetColIndex === -1) return;
202
+
203
+ movedCard = { ...movedCard, columnId: toColumnId };
204
+ const targetCards = [...columns[targetColIndex].cards];
205
+ targetCards.splice(toIndex, 0, movedCard);
206
+
207
+ columns[targetColIndex] = {
208
+ ...columns[targetColIndex],
209
+ cards: targetCards
210
+ };
211
+
212
+ this._columns.value = columns;
213
+ this.emit('cardmove', {
214
+ card: movedCard,
215
+ fromColumnId,
216
+ toColumnId,
217
+ fromIndex,
218
+ toIndex
219
+ });
220
+ this.emit('datachange', columns);
221
+ }
222
+
223
+ addColumn(columnData: Partial<KanbanColumn>): NormalizedColumn {
224
+ const id = columnData.id || `col-${Date.now()}`;
225
+ const column: NormalizedColumn = {
226
+ id,
227
+ _id: String(id),
228
+ title: columnData.title || 'New Column',
229
+ cards: [],
230
+ ...columnData
231
+ };
232
+
233
+ this._columns.value = [...this._columns.value, column];
234
+ this.emit('columncreate', column);
235
+ this.emit('datachange', this._columns.value);
236
+
237
+ return column;
238
+ }
239
+
240
+ updateColumn(columnId: string | number, updates: Partial<KanbanColumn>): NormalizedColumn | null {
241
+ const columns = [...this._columns.value];
242
+ const colIndex = columns.findIndex(c => c._id === String(columnId));
243
+
244
+ if (colIndex === -1) return null;
245
+
246
+ const updated = { ...columns[colIndex], ...updates };
247
+ columns[colIndex] = updated;
248
+ this._columns.value = columns;
249
+
250
+ this.emit('columnupdate', updated);
251
+ this.emit('datachange', columns);
252
+
253
+ return updated;
254
+ }
255
+
256
+ deleteColumn(columnId: string | number): NormalizedColumn | null {
257
+ const columns = this._columns.value;
258
+ const column = columns.find(c => c._id === String(columnId));
259
+
260
+ if (!column) return null;
261
+
262
+ this._columns.value = columns.filter(c => c._id !== String(columnId));
263
+ this.emit('columndelete', column);
264
+ this.emit('datachange', this._columns.value);
265
+
266
+ return column;
267
+ }
268
+
269
+ moveColumn(columnId: string | number, toIndex: number): void {
270
+ const columns = [...this._columns.value];
271
+ const fromIndex = columns.findIndex(c => c._id === String(columnId));
272
+
273
+ if (fromIndex === -1 || fromIndex === toIndex) return;
274
+
275
+ const [column] = columns.splice(fromIndex, 1);
276
+ columns.splice(toIndex, 0, column);
277
+
278
+ this._columns.value = columns;
279
+ this.emit('columnmove', { column, fromIndex, toIndex });
280
+ this.emit('datachange', columns);
281
+ }
282
+
283
+ setFilters(filters: KanbanFilterConfig): void {
284
+ this._filters.value = { ...this._filters.value, ...filters };
285
+ this.emit('filterchange', this._filters.value);
286
+ }
287
+
288
+ clearFilters(): void {
289
+ this._filters.value = {};
290
+ this.emit('filterchange', {});
291
+ }
292
+
293
+ getFilters(): KanbanFilterConfig {
294
+ return this._filters.value;
295
+ }
296
+
297
+ getFilteredCards(columnId: string): KanbanCard[] {
298
+ const column = this._columns.value.find(c => c._id === columnId);
299
+ if (!column) return [];
300
+
301
+ const filters = this._filters.value;
302
+ let cards = [...column.cards];
303
+
304
+ if (filters.search) {
305
+ const search = filters.search.toLowerCase();
306
+ cards = cards.filter(c =>
307
+ c.title.toLowerCase().includes(search) ||
308
+ c.description?.toLowerCase().includes(search)
309
+ );
310
+ }
311
+
312
+ if (filters.assignees?.length) {
313
+ cards = cards.filter(c => {
314
+ if (c.assignee && filters.assignees!.includes(c.assignee.id)) return true;
315
+ if (c.assignees?.some(a => filters.assignees!.includes(a.id))) return true;
316
+ return false;
317
+ });
318
+ }
319
+
320
+ if (filters.labels?.length) {
321
+ cards = cards.filter(c =>
322
+ c.labels?.some(l => filters.labels!.includes(l.id))
323
+ );
324
+ }
325
+
326
+ if (filters.priorities?.length) {
327
+ cards = cards.filter(c =>
328
+ c.priority && filters.priorities!.includes(c.priority)
329
+ );
330
+ }
331
+
332
+ return cards;
333
+ }
334
+
335
+ setDragContext(context: DragContext | null): void {
336
+ this._dragContext.value = context;
337
+ }
338
+
339
+ getDragContext(): DragContext | null {
340
+ return this._dragContext.value;
341
+ }
342
+
343
+ findCard(cardId: string | number): KanbanCard | null {
344
+ for (const col of this._columns.value) {
345
+ const card = col.cards.find(c => c.id === cardId);
346
+ if (card) return card;
347
+ }
348
+ return null;
349
+ }
350
+
351
+ findCardIndex(cardId: string | number, columnId: string): number {
352
+ const column = this._columns.value.find(c => c._id === columnId);
353
+ if (!column) return -1;
354
+ return column.cards.findIndex(c => c.id === cardId);
355
+ }
356
+
357
+ private _normalizeColumns(columns: KanbanColumn[], cards: KanbanCard[]): NormalizedColumn[] {
358
+ return columns.map(col => ({
359
+ ...col,
360
+ _id: String(col.id),
361
+ cards: cards.filter(c => String(c.columnId) === String(col.id))
362
+ }));
363
+ }
364
+
365
+ private _getNestedValue(obj: unknown, path: string): unknown {
366
+ return path.split('.').reduce((acc, key) => {
367
+ if (acc && typeof acc === 'object' && key in acc) {
368
+ return (acc as Record<string, unknown>)[key];
369
+ }
370
+ return undefined;
371
+ }, obj);
372
+ }
373
+ }