inicontent 1.0.1 → 1.0.3

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.
@@ -1,131 +1,602 @@
1
1
  <template>
2
- <NGrid v-if="dashboard.widgets?.length" :x-gap="12" :y-gap="12" :cols="24" responsive="screen">
3
- <NGridItem
2
+ <!-- Edit mode: draggable grid with per-widget controls -->
3
+ <div v-if="editMode" class="dashboard-edit">
4
+ <VueDraggable
5
+ v-if="editWidgets.length"
6
+ v-model="editWidgets"
7
+ item-key="id"
8
+ handle=".drag-handle"
9
+ :animation="200"
10
+ :custom-update="ignoreReorder"
11
+ @start="onDragStart"
12
+ @end="onDragEnd"
13
+ class="dashboard-grid"
14
+ >
15
+ <div
16
+ v-for="(widget, index) in editWidgets"
17
+ :key="widget.id"
18
+ :data-widget-id="widget.id"
19
+ class="dashboard-grid-item"
20
+ :style="widgetGridStyle(widget)"
21
+ >
22
+ <NCard
23
+ size="small"
24
+ :title="widget.title || t('untitledWidget')"
25
+ content-style="flex: 1; display: flex; align-items: center"
26
+ :style="
27
+ widget.color
28
+ ? `border-inline-start: 3px solid ${widget.color}`
29
+ : ''
30
+ "
31
+ >
32
+ <template #header-extra>
33
+ <NFlex :size="4" align="center">
34
+ <span
35
+ class="drag-handle"
36
+ style="
37
+ cursor: grab;
38
+ display: inline-flex;
39
+ align-items: center;
40
+ padding: 4px;
41
+ "
42
+ >
43
+ <NIcon :size="16">
44
+ <Icon name="tabler:grip-vertical" />
45
+ </NIcon>
46
+ </span>
47
+ <NSelect
48
+ size="tiny"
49
+ :value="widget.size || 'small'"
50
+ :options="sizeOptions"
51
+ style="width: 100px"
52
+ @update:value="(size) => updateWidth(widget, size)"
53
+ />
54
+ <NButton size="tiny" quaternary @click="openEditor(index)">
55
+ <template #icon>
56
+ <NIcon>
57
+ <Icon name="tabler:edit" />
58
+ </NIcon>
59
+ </template>
60
+ </NButton>
61
+ <NButton size="tiny" quaternary type="error" @click="removeWidget(index)">
62
+ <template #icon>
63
+ <NIcon>
64
+ <Icon name="tabler:trash" />
65
+ </NIcon>
66
+ </template>
67
+ </NButton>
68
+ </NFlex>
69
+ </template>
70
+ <component
71
+ :is="widgetRenderer(widget.type)"
72
+ :widget="widget"
73
+ :database-slug="databaseSlug"
74
+ :date-range-override="dateRangeOverride"
75
+ />
76
+ </NCard>
77
+ </div>
78
+ </VueDraggable>
79
+
80
+ <NEmpty v-if="!editWidgets.length" :description="t('noWidgets')" />
81
+
82
+ <NModal
83
+ v-model:show="showWidgetModal"
84
+ preset="card"
85
+ :title="pendingNewWidget ? t('addWidget') : t('editWidget')"
86
+ style="width: min(680px, 90vw)"
87
+ >
88
+ <DashboardWidgetEditor v-if="editingWidget" v-model="editingWidget" />
89
+ <template #footer>
90
+ <NFlex justify="end">
91
+ <NButton @click="closeEditor">{{ t("cancel") }}</NButton>
92
+ <NButton type="primary" @click="confirmWidget">{{ t("confirm") }}</NButton>
93
+ </NFlex>
94
+ </template>
95
+ </NModal>
96
+ </div>
97
+
98
+ <!-- View mode: read-only grid, same layout rules as the editor -->
99
+ <div
100
+ v-else-if="dashboard.widgets?.length"
101
+ class="dashboard-grid"
102
+ >
103
+ <div
4
104
  v-for="widget in dashboard.widgets"
5
105
  :key="widget.id + dateRangeKey"
6
- :span="getSpan(widget)"
106
+ class="dashboard-grid-item"
107
+ :style="widgetGridStyle(widget)"
7
108
  >
8
109
  <NCard
9
110
  :title="widget.title"
10
111
  size="small"
11
- :style="widget.color ? `border-left: 3px solid ${widget.color}` : ''"
112
+ content-style="flex: 1; display: flex; align-items: center"
113
+ :style="
114
+ widget.color
115
+ ? `border-inline-start: 3px solid ${widget.color}`
116
+ : ''
117
+ "
12
118
  >
13
119
  <template #header-extra>
14
120
  <NTag size="small" :bordered="false" type="info">
15
121
  {{ t(widget.type) }}
16
122
  </NTag>
17
123
  </template>
18
- <DashboardWidgetCounter
19
- v-if="widget.type === 'counter'"
20
- :widget="widget"
21
- :database-slug="databaseSlug"
22
- :date-range-override="dateRangeOverride"
23
- />
24
- <DashboardWidgetLineChart
25
- v-else-if="widget.type === 'line'"
26
- :widget="widget"
27
- :database-slug="databaseSlug"
28
- :date-range-override="dateRangeOverride"
29
- />
30
- <DashboardWidgetBarChart
31
- v-else-if="widget.type === 'bar'"
32
- :widget="widget"
33
- :database-slug="databaseSlug"
34
- :date-range-override="dateRangeOverride"
35
- />
36
- <DashboardWidgetPieChart
37
- v-else-if="widget.type === 'pie'"
38
- :widget="widget"
39
- :database-slug="databaseSlug"
40
- :date-range-override="dateRangeOverride"
41
- />
42
- <DashboardWidgetTable
43
- v-else-if="widget.type === 'table'"
124
+ <component
125
+ :is="widgetRenderer(widget.type)"
44
126
  :widget="widget"
45
127
  :database-slug="databaseSlug"
46
128
  :date-range-override="dateRangeOverride"
47
129
  />
48
130
  </NCard>
49
- </NGridItem>
50
- </NGrid>
51
- <NEmpty v-if="!(dashboard.widgets?.length)" :description="t('noWidgets')" />
131
+ </div>
132
+ </div>
133
+ <NEmpty v-else :description="t('noWidgets')" />
52
134
  </template>
53
135
 
54
136
  <script lang="ts" setup>
137
+ import { VueDraggable } from "vue-draggable-plus";
138
+ import {
139
+ DashboardWidgetBarChart,
140
+ DashboardWidgetCounter,
141
+ DashboardWidgetLineChart,
142
+ DashboardWidgetPieChart,
143
+ DashboardWidgetTable,
144
+ } from "#components";
145
+
55
146
  const props = defineProps<{
56
147
  dashboard: Dashboard;
57
148
  databaseSlug: string;
58
149
  dateRangeOverride?: WidgetDateRange;
150
+ editMode?: boolean;
59
151
  }>();
60
152
 
153
+ const editWidgets = defineModel<Widget[]>("widgets", { default: () => [] });
154
+
61
155
  const dateRangeKey = computed(() => props.dateRangeOverride ?? "default");
62
156
 
63
- const windowWidth = ref(0);
157
+ const editingWidgetIndex = ref<number | null>(null);
158
+ const showWidgetModal = ref(false);
159
+ const pendingNewWidget = ref<Widget | null>(null);
64
160
 
65
- const smallWidgetsCount = computed(
66
- () =>
67
- (props.dashboard.widgets ?? []).filter(
68
- (widget) => widget.size === "small" || !widget.size,
69
- ).length,
70
- );
161
+ const editingWidget = computed({
162
+ get: () => {
163
+ if (pendingNewWidget.value) return pendingNewWidget.value;
164
+ return editingWidgetIndex.value !== null
165
+ ? (editWidgets.value[editingWidgetIndex.value] ?? null)
166
+ : null;
167
+ },
168
+ set: (v) => {
169
+ if (pendingNewWidget.value) {
170
+ pendingNewWidget.value = v;
171
+ } else if (editingWidgetIndex.value !== null && v) {
172
+ editWidgets.value[editingWidgetIndex.value] = v;
173
+ }
174
+ },
175
+ });
71
176
 
72
- const desktopSmallWidgetsPerLine = computed(() => {
73
- if (windowWidth.value < 1024) {
74
- return 2;
75
- }
177
+ watch(editingWidgetIndex, (v) => {
178
+ showWidgetModal.value = v !== null;
179
+ });
76
180
 
77
- let maxPerLine = 3;
78
-
79
- if (windowWidth.value >= 2160) {
80
- maxPerLine = 8;
81
- } else if (windowWidth.value >= 1920) {
82
- maxPerLine = 7;
83
- } else if (windowWidth.value >= 1600) {
84
- maxPerLine = 6;
85
- } else if (windowWidth.value >= 1366) {
86
- maxPerLine = 5;
87
- } else if (windowWidth.value >= 1200) {
88
- maxPerLine = 4;
181
+ watch(showWidgetModal, (v) => {
182
+ if (!v) {
183
+ editingWidgetIndex.value = null;
184
+ pendingNewWidget.value = null;
89
185
  }
186
+ });
90
187
 
91
- if (smallWidgetsCount.value === 0) {
92
- return 3;
93
- }
188
+ function addWidget() {
189
+ pendingNewWidget.value = {
190
+ id: `w_${Date.now()}`,
191
+ type: "counter",
192
+ title: "",
193
+ table: "",
194
+ operation: "count",
195
+ size: "small",
196
+ dateRange: "all",
197
+ searchArray: { and: [[null, "=", null]] },
198
+ };
199
+ showWidgetModal.value = true;
200
+ }
94
201
 
95
- if (smallWidgetsCount.value < 3) {
96
- return smallWidgetsCount.value;
97
- }
202
+ function removeWidget(index: number) {
203
+ editWidgets.value.splice(index, 1);
204
+ }
98
205
 
99
- return Math.min(maxPerLine, smallWidgetsCount.value);
100
- });
206
+ function openEditor(index: number) {
207
+ editingWidgetIndex.value = index;
208
+ }
101
209
 
102
- const smallWidgetSpan = computed(() =>
103
- Math.max(3, Math.floor(24 / Math.max(1, desktopSmallWidgetsPerLine.value))),
104
- );
210
+ function closeEditor() {
211
+ showWidgetModal.value = false;
212
+ }
105
213
 
106
- function updateWindowWidth() {
107
- windowWidth.value = window.innerWidth;
214
+ function confirmWidget() {
215
+ // Newly added widgets must have a source table before they can render.
216
+ if (pendingNewWidget.value) {
217
+ if (!pendingNewWidget.value.table) {
218
+ window.$message.error(t("selectTable"));
219
+ return;
220
+ }
221
+ editWidgets.value.push(pendingNewWidget.value);
222
+ }
223
+ showWidgetModal.value = false;
108
224
  }
109
225
 
110
- onMounted(() => {
111
- updateWindowWidth();
112
- window.addEventListener("resize", updateWindowWidth);
113
- });
226
+ function updateWidth(widget: Widget, size: string) {
227
+ if (size === "small" || size === "medium" || size === "large") {
228
+ widget.size = size;
229
+ }
230
+ }
114
231
 
115
- onBeforeUnmount(() => {
116
- window.removeEventListener("resize", updateWindowWidth);
117
- });
232
+ const sizeOptions = [
233
+ { label: t("small"), value: "small" },
234
+ { label: t("medium"), value: "medium" },
235
+ { label: t("large"), value: "large" },
236
+ ];
237
+
238
+ const widgetRenderers = {
239
+ counter: DashboardWidgetCounter,
240
+ line: DashboardWidgetLineChart,
241
+ bar: DashboardWidgetBarChart,
242
+ pie: DashboardWidgetPieChart,
243
+ table: DashboardWidgetTable,
244
+ } as const;
245
+
246
+ function widgetRenderer(
247
+ type: Widget["type"],
248
+ ): (typeof widgetRenderers)[keyof typeof widgetRenderers] | null {
249
+ return widgetRenderers[type] ?? null;
250
+ }
118
251
 
119
252
  function getSpan(widget: Widget): number {
120
- // Tables always get the full row so columns can breathe.
121
- if (widget.type === "table") return 24;
122
253
  switch (widget.size) {
123
254
  case "large":
124
255
  return 24;
125
256
  case "medium":
126
- return 12;
257
+ return 16;
127
258
  default:
128
- return smallWidgetSpan.value;
259
+ return 8;
260
+ }
261
+ }
262
+
263
+ // Tall widgets (charts/tables) height exceeds a counter, so the layout lets
264
+ // short widgets stack in the free columns beside them.
265
+ function isTall(widget: Widget): boolean {
266
+ return widget.type !== "counter";
267
+ }
268
+
269
+ const MAX_STACKED_SHORTS = 4;
270
+
271
+ type WidgetPlacement = {
272
+ colStart: number;
273
+ rowStart: number;
274
+ colSpan: number;
275
+ rowSpan: number;
276
+ };
277
+
278
+ // Explicit 24-column layout. A tall widget (chart/table) and the short
279
+ // widgets that fit in its remaining width form one block: the tall spans
280
+ // one row per stacked short, and the shorts stack one-per-row on the free
281
+ // side — regardless of whether they come before or after the tall in the
282
+ // list, and direction-safe (RTL/LTR use logical columns).
283
+ function computePlacements(widgets: Widget[]): Record<string, WidgetPlacement> {
284
+ const result: Record<string, WidgetPlacement> = {};
285
+ const n = widgets.length;
286
+
287
+ // Place a run of widgets on successive flow lines, left to right
288
+ // (logical), wrapping when a line overflows 24 columns.
289
+ const placeFlow = (items: Widget[], startRow: number): number => {
290
+ let row = startRow;
291
+ let col = 1;
292
+ for (const item of items) {
293
+ const span = getSpan(item);
294
+ if (col + span - 1 > 24) {
295
+ row += 1;
296
+ col = 1;
297
+ }
298
+ result[item.id] = {
299
+ colStart: col,
300
+ rowStart: row,
301
+ colSpan: span,
302
+ rowSpan: 1,
303
+ };
304
+ col += span;
305
+ }
306
+ return row + 1;
307
+ };
308
+
309
+ let cursorRow = 1;
310
+ let i = 0;
311
+ while (i < n) {
312
+ const widget = widgets[i];
313
+
314
+ if (!isTall(widget)) {
315
+ // Collect a contiguous run of shorts that fit on one flow line.
316
+ const run = [widget];
317
+ let runWidth = getSpan(widget);
318
+ let j = i + 1;
319
+ while (
320
+ j < n &&
321
+ !isTall(widgets[j]) &&
322
+ runWidth + getSpan(widgets[j]) <= 24
323
+ ) {
324
+ run.push(widgets[j]);
325
+ runWidth += getSpan(widgets[j]);
326
+ j += 1;
327
+ }
328
+
329
+ // A tall widget right after the run can host it: stack the run
330
+ // in its own column and let the tall span the hosting rows.
331
+ if (j < n && isTall(widgets[j])) {
332
+ const tall = widgets[j];
333
+ const remaining = 24 - getSpan(tall);
334
+ if (remaining > 0 && run.every((s) => getSpan(s) <= remaining)) {
335
+ const rowSpan = Math.max(2, Math.min(run.length, MAX_STACKED_SHORTS));
336
+ const assignCount = Math.min(run.length, rowSpan);
337
+ const maxShortSpan = Math.max(
338
+ ...run.slice(0, assignCount).map(getSpan),
339
+ );
340
+ run.slice(0, assignCount).forEach((s, t) => {
341
+ result[s.id] = {
342
+ colStart: 1,
343
+ rowStart: cursorRow + t,
344
+ colSpan: getSpan(s),
345
+ rowSpan: 1,
346
+ };
347
+ });
348
+ result[tall.id] = {
349
+ colStart: 1 + maxShortSpan,
350
+ rowStart: cursorRow,
351
+ colSpan: getSpan(tall),
352
+ rowSpan,
353
+ };
354
+ cursorRow += rowSpan;
355
+ const leftover = run.slice(assignCount);
356
+ if (leftover.length) cursorRow = placeFlow(leftover, cursorRow);
357
+ i = j + 1;
358
+ continue;
359
+ }
360
+ }
361
+
362
+ // Unhosted shorts: flow normally.
363
+ cursorRow = placeFlow(run, cursorRow);
364
+ i = j;
365
+ continue;
366
+ }
367
+
368
+ // Tall widget, possibly followed by shorts that fit beside it.
369
+ const remaining = 24 - getSpan(widget);
370
+ const shorts: Widget[] = [];
371
+ let j = i + 1;
372
+ while (
373
+ j < n &&
374
+ !isTall(widgets[j]) &&
375
+ remaining > 0 &&
376
+ getSpan(widgets[j]) <= remaining
377
+ ) {
378
+ shorts.push(widgets[j]);
379
+ j += 1;
380
+ }
381
+ const rowSpan = Math.max(2, Math.min(shorts.length, MAX_STACKED_SHORTS));
382
+ const assignCount = Math.min(shorts.length, rowSpan);
383
+ result[widget.id] = {
384
+ colStart: 1,
385
+ rowStart: cursorRow,
386
+ colSpan: getSpan(widget),
387
+ rowSpan,
388
+ };
389
+ shorts.slice(0, assignCount).forEach((s, t) => {
390
+ result[s.id] = {
391
+ colStart: 1 + getSpan(widget),
392
+ rowStart: cursorRow + t,
393
+ colSpan: getSpan(s),
394
+ rowSpan: 1,
395
+ };
396
+ });
397
+ cursorRow += rowSpan;
398
+ const leftover = shorts.slice(assignCount);
399
+ if (leftover.length) cursorRow = placeFlow(leftover, cursorRow);
400
+ i = j;
401
+ }
402
+
403
+ return result;
404
+ }
405
+
406
+ // While a drag is in progress the layout is frozen: vue-draggable-plus would
407
+ // otherwise reorder the model mid-drag and recompute placements under the
408
+ // pointer, making tall (multi-row) widgets jump and breaking the drop.
409
+ const dragPlacements = ref<Record<string, WidgetPlacement> | null>(null);
410
+ const dragStartOrder = ref<Widget[]>([]);
411
+ const draggedWidgetId = ref<string | null>(null);
412
+
413
+ const placements = computed<Record<string, WidgetPlacement>>(() => {
414
+ if (dragPlacements.value) return dragPlacements.value;
415
+ const widgets = props.editMode
416
+ ? editWidgets.value
417
+ : (props.dashboard.widgets ?? []);
418
+ return computePlacements(widgets);
419
+ });
420
+
421
+ type DragEventPayload = {
422
+ oldIndex?: number;
423
+ originalEvent?: {
424
+ clientX?: number;
425
+ clientY?: number;
426
+ touches?: Array<{ clientX?: number; clientY?: number }>;
427
+ changedTouches?: Array<{ clientX?: number; clientY?: number }>;
428
+ };
429
+ };
430
+
431
+ // Prevent vue-draggable-plus from reordering the model while dragging. The
432
+ // drop position is translated to a list order ourselves (see onDragEnd), so a
433
+ // live reorder would only fight the frozen layout.
434
+ function ignoreReorder() {}
435
+
436
+ function onDragStart(evt: DragEventPayload) {
437
+ dragStartOrder.value = [...editWidgets.value];
438
+ draggedWidgetId.value =
439
+ evt.oldIndex != null
440
+ ? (dragStartOrder.value[evt.oldIndex]?.id ?? null)
441
+ : null;
442
+ dragPlacements.value = computePlacements(editWidgets.value);
443
+ }
444
+
445
+ function onDragEnd(evt: DragEventPayload) {
446
+ const id = draggedWidgetId.value;
447
+ const pt = evt.originalEvent;
448
+ const clientX =
449
+ pt?.touches?.[0]?.clientX ??
450
+ pt?.changedTouches?.[0]?.clientX ??
451
+ pt?.clientX;
452
+ const clientY =
453
+ pt?.touches?.[0]?.clientY ??
454
+ pt?.changedTouches?.[0]?.clientY ??
455
+ pt?.clientY;
456
+ if (id && clientX != null && clientY != null) {
457
+ reorderToDropPoint(id, clientX, clientY);
129
458
  }
459
+ draggedWidgetId.value = null;
460
+ dragStartOrder.value = [];
461
+ dragPlacements.value = null;
462
+ }
463
+
464
+ function rectContainsPoint(rect: DOMRect, x: number, y: number) {
465
+ return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
466
+ }
467
+
468
+ function rectDistance(rect: DOMRect, x: number, y: number) {
469
+ const dx = Math.max(rect.left - x, 0, x - rect.right);
470
+ const dy = Math.max(rect.top - y, 0, y - rect.bottom);
471
+ return Math.hypot(dx, dy);
130
472
  }
473
+
474
+ // SortableJS maps a drop to a DOM index, but with an auto layout a stacked
475
+ // column has no "before" zone, so dropping a tall widget over the counters
476
+ // keeps it where it was. Instead, translate the pointer position into a list
477
+ // order: drop onto a column and the widget lands on that column's side.
478
+ function reorderToDropPoint(
479
+ draggedId: string,
480
+ clientX: number,
481
+ clientY: number,
482
+ ) {
483
+ const items = [...editWidgets.value];
484
+ const curIndex = items.findIndex((w) => w.id === draggedId);
485
+ if (curIndex === -1) return;
486
+
487
+ const cells = Array.from(
488
+ document.querySelectorAll(".dashboard-edit .dashboard-grid-item"),
489
+ )
490
+ .map((el) => {
491
+ const rect = el.getBoundingClientRect();
492
+ return { id: el.getAttribute("data-widget-id"), rect };
493
+ })
494
+ .filter((c): c is { id: string; rect: DOMRect } => c.id != null);
495
+
496
+ let anchor = cells[0] ?? null;
497
+ let best = Number.POSITIVE_INFINITY;
498
+ for (const cell of cells) {
499
+ const dist = rectContainsPoint(cell.rect, clientX, clientY)
500
+ ? 0
501
+ : rectDistance(cell.rect, clientX, clientY);
502
+ if (dist < best) {
503
+ best = dist;
504
+ anchor = cell;
505
+ }
506
+ }
507
+ if (!anchor || anchor.id === draggedId) return;
508
+
509
+ const frozen = dragPlacements.value;
510
+ if (!frozen) return;
511
+ const anchorIndex = items.findIndex((w) => w.id === anchor.id);
512
+ if (anchorIndex === -1) return;
513
+
514
+ const draggedPlacement = frozen[draggedId];
515
+ const anchorPlacement = frozen[anchor.id];
516
+ const sameColumn =
517
+ draggedPlacement != null &&
518
+ anchorPlacement != null &&
519
+ draggedPlacement.colStart === anchorPlacement.colStart &&
520
+ draggedPlacement.colSpan === anchorPlacement.colSpan;
521
+
522
+ // Column group: the contiguous run of widgets sharing the anchor column.
523
+ const anchorColStart = anchorPlacement?.colStart ?? -1;
524
+ let groupStart = anchorIndex;
525
+ while (
526
+ groupStart > 0 &&
527
+ frozen[items[groupStart - 1].id]?.colStart === anchorColStart
528
+ ) {
529
+ groupStart -= 1;
530
+ }
531
+ let groupEnd = anchorIndex;
532
+ while (
533
+ groupEnd < items.length - 1 &&
534
+ frozen[items[groupEnd + 1].id]?.colStart === anchorColStart
535
+ ) {
536
+ groupEnd += 1;
537
+ }
538
+
539
+ let dest: number;
540
+ if (sameColumn) {
541
+ // Same stacked column: reorder vertically by the pointer's row.
542
+ const anchorRect = cells.find((c) => c.id === anchor.id)?.rect;
543
+ dest =
544
+ anchorRect && clientY < anchorRect.top + anchorRect.height / 2
545
+ ? anchorIndex
546
+ : anchorIndex + 1;
547
+ } else {
548
+ // Other column: insert at the edge of the anchor's column group on the
549
+ // side the pointer is on (direction-aware).
550
+ const anchorRect = cells.find((c) => c.id === anchor.id)?.rect;
551
+ const rtl =
552
+ getComputedStyle(
553
+ document.querySelector(".dashboard-edit") ?? document.body,
554
+ ).direction === "rtl";
555
+ const onStartSide =
556
+ anchorRect != null &&
557
+ (rtl
558
+ ? clientX > anchorRect.left + anchorRect.width / 2
559
+ : clientX < anchorRect.left + anchorRect.width / 2);
560
+ dest = onStartSide ? groupStart : groupEnd + 1;
561
+ }
562
+
563
+ const [item] = items.splice(curIndex, 1);
564
+ if (item && curIndex < dest) dest -= 1;
565
+ items.splice(dest, 0, item);
566
+ editWidgets.value = items;
567
+ }
568
+
569
+ function widgetGridStyle(widget: Widget) {
570
+ const p = placements.value[widget.id];
571
+ if (!p) return {};
572
+ return {
573
+ gridColumn: `${p.colStart} / span ${p.colSpan}`,
574
+ gridRow: `${p.rowStart} / span ${p.rowSpan}`,
575
+ };
576
+ }
577
+
578
+ defineExpose({ addWidget });
131
579
  </script>
580
+
581
+ <style scoped>
582
+ .dashboard-grid {
583
+ display: grid;
584
+ grid-template-columns: repeat(24, 1fr);
585
+ /* Dense auto-fill lets short widgets stack into gaps left by tall ones. */
586
+ grid-auto-flow: row dense;
587
+ gap: 12px;
588
+ }
589
+
590
+ .dashboard-grid-item {
591
+ min-width: 0;
592
+ /* Let the card stretch to the full cell height so its content can be
593
+ vertically centered (helps short widgets in tall rows). */
594
+ display: flex;
595
+ }
596
+
597
+ @media (max-width: 768px) {
598
+ .dashboard-grid-item {
599
+ grid-column: 1 / -1 !important;
600
+ }
601
+ }
602
+ </style>
@@ -1,4 +1,5 @@
1
1
  <template>
2
+ <LazyFormDrawer />
2
3
  <NSpin :show="loading">
3
4
  <NDataTable
4
5
  size="small"
@@ -73,7 +74,8 @@ const columns = computed<DataTableColumns>(() =>
73
74
  key: field.key,
74
75
  minWidth: 150,
75
76
  width: 150,
76
- ellipsis: field.table !== undefined ? false : { tooltip: true },
77
+ resizable: true,
78
+ ellipsis: { tooltip: true },
77
79
  render: (row: Item) =>
78
80
  h(LazyColumn, {
79
81
  field,