main
vue 623 lines 15.1 KB
Raw
1 <template>
2 <n-spin v-model:show="loading" class="h-full w-full overflow-hidden" content-class="h-full w-full overflow-hidden">
3 <div v-if="org" class="report-panels flex h-full w-full gap-2">
4 <div class="panels-container flex h-full grow flex-col gap-4">
5 <div class="rows-container">
6 <n-scrollbar style="max-height: 100%" trigger="none">
7 <div class="drag-wrapper">
8 <draggable
9 v-model="rows"
10 item-key="id"
11 :animation="200"
12 ghost-class="ghost-row"
13 handle=".pan-area"
14 :group="{ name: 'rows', pull: false, put: false }"
15 class="flex flex-col gap-2"
16 >
17 <template #item="{ element: row }">
18 <div class="row p-3" :class="{ 'height-large': row.height === 2 }">
19 <div v-if="!row.panels.length" class="empty-message">Drop panels here</div>
20 <draggable
21 v-model="row.panels"
22 item-key="id"
23 :animation="200"
24 ghost-class="ghost-panel"
25 :group="{
26 name: 'panels',
27 put(to: { el: HTMLElement }) {
28 return to.el.children.length < 4
29 },
30 pull: ['panels']
31 }"
32 class="drop-panels-area flex h-full w-full gap-3"
33 >
34 <template #header>
35 <div class="left-box flex justify-end">
36 <div class="pan-area">
37 <Icon :name="PanIcon" :size="20" />
38 </div>
39 <div class="delete-box">
40 <n-tooltip trigger="hover">
41 <template #trigger>
42 <n-button text type="error" @click="removeRow(row)">
43 <template #icon>
44 <Icon :name="CloseIcon" />
45 </template>
46 </n-button>
47 </template>
48 Remove Row
49 </n-tooltip>
50 </div>
51 <div class="settings-box">
52 <n-popover trigger="click">
53 <template #trigger>
54 <n-button text>
55 <template #icon>
56 <Icon :name="RowSettingsIcon" :size="13" />
57 </template>
58 </n-button>
59 </template>
60
61 <div class="flex items-center gap-3 py-1">
62 <div class="text-secondary text-sm">Row height:</div>
63 <n-switch
64 v-model:value="row.height"
65 size="small"
66 :unchecked-value="1"
67 :checked-value="2"
68 >
69 <template #checked>
70 <small>Large</small>
71 </template>
72 <template #unchecked>
73 <small>Regular</small>
74 </template>
75 </n-switch>
76 </div>
77 </n-popover>
78 </div>
79 </div>
80 </template>
81 <template #item="{ element: panel }">
82 <div class="panel">
83 <div class="delete-box">
84 <n-tooltip trigger="hover">
85 <template #trigger>
86 <n-button
87 text
88 type="error"
89 @click="removePanel(row, panel)"
90 >
91 <template #icon>
92 <Icon :name="CloseIcon" />
93 </template>
94 </n-button>
95 </template>
96 Remove Panel
97 </n-tooltip>
98 </div>
99 <div class="dashboard-title">
100 {{ panel.dashboardTitle }}
101 </div>
102 <div class="content">
103 {{ panel.panelTitle }}
104 </div>
105 </div>
106 </template>
107 </draggable>
108 </div>
109 </template>
110 </draggable>
111 </div>
112 </n-scrollbar>
113 </div>
114 <div class="toolbar flex items-center justify-between gap-2 pr-4">
115 <n-button
116 v-if="dashboard || panelsReady"
117 class="add-task-btn mt-0! flex items-center justify-center"
118 @click="addRow()"
119 >
120 <template #icon>
121 <Icon :name="AddIcon" />
122 </template>
123 <span>Add row</span>
124 </n-button>
125
126 <div v-if="panelsReady" class="flex items-center gap-2">
127 <n-button type="success" :loading @click="print()">
128 <template #icon>
129 <Icon :name="PrintIcon" />
130 </template>
131 Print Report
132 </n-button>
133 <n-button type="success" :loading @click="openSettings()">
134 <template #icon>
135 <Icon :name="SettingsIcon" />
136 </template>
137 </n-button>
138 </div>
139 </div>
140 </div>
141
142 <div class="panels-sidebar bg-secondary h-full">
143 <n-scrollbar v-if="dashboard && panelsList.length" style="max-height: 100%" trigger="none">
144 <div class="p-3">
145 <draggable
146 class="flex flex-col gap-3"
147 :list="panelsList"
148 :group="{ name: 'panels', pull: 'clone', put: false }"
149 :sort="false"
150 item-key="id"
151 >
152 <template #item="{ element: panel }">
153 <div class="panel">
154 <div class="content">
155 {{ panel.panelTitle }}
156 </div>
157 </div>
158 </template>
159 </draggable>
160 </div>
161 </n-scrollbar>
162 <div v-else class="h-full p-3">
163 <div class="empty-message p-3">
164 <div v-if="!dashboard">
165 Select a
166 <code>Dashboard</code>
167 to get the panels
168 </div>
169 <div v-else>No Panels found</div>
170 </div>
171 </div>
172 </div>
173 </div>
174
175 <div v-else class="empty-message">
176 Select an
177 <code>Organization</code>
178 /
179 <code>Dashboard</code>
180 to create the Report
181 </div>
182
183 <n-drawer
184 v-model:show="settingDrawerOpen"
185 :width="500"
186 style="max-width: 90vw"
187 :trap-focus="false"
188 :class="{ 'opacity-0': preloadingPrintSettings }"
189 display-directive="show"
190 >
191 <n-drawer-content title="Report Settings" closable :native-scrollbar="false">
192 <PrintSettings @update="printSettings = $event" />
193 </n-drawer-content>
194 </n-drawer>
195 </n-spin>
196 </template>
197
198 <script setup lang="ts">
199 import type { PrintSettingsData } from "./PrintSettings.vue"
200 import type { GenerateReportPayload, ReportTimeRange } from "@/api/endpoints/reporting"
201 import type { Dashboard, Org, Panel } from "@/types/reporting.d"
202 import { useStorage } from "@vueuse/core"
203 import { saveAs } from "file-saver"
204 import _kebabCase from "lodash/kebabCase"
205 import { NButton, NDrawer, NDrawerContent, NPopover, NScrollbar, NSpin, NSwitch, NTooltip, useMessage } from "naive-ui"
206 import { computed, onMounted, ref, toRefs, watch } from "vue"
207 import draggable from "vuedraggable"
208 import Api from "@/api"
209 import Icon from "@/components/common/Icon.vue"
210 import { useSettingsStore } from "@/stores/settings"
211 import { formatDate } from "@/utils/format"
212 import * as defaultSettings from "./defaultSettings"
213 import PrintSettings from "./PrintSettings.vue"
214
215 const props = defineProps<{
216 timerange: ReportTimeRange | null
217 org: Org | null
218 dashboard: Dashboard | null
219 panels: Panel[]
220 }>()
221 const ROW_GAP = 20
222 const ROW_WIDTH = 800
223 const ROW_HEIGHT = 320
224
225 interface OrgData {
226 id: number
227 rows: Row[]
228 }
229
230 interface Row {
231 id: number
232 height: 1 | 2
233 panels: PanelData[]
234 }
235
236 interface PanelData {
237 panelId: number
238 panelTitle: string
239 orgId: number
240 orgName: string
241 dashboardUID: string
242 dashboardTitle: string
243 }
244
245 const { timerange, org, dashboard, panels } = toRefs(props)
246
247 const PanIcon = "carbon:draggable"
248 const CloseIcon = "carbon:close"
249 const SettingsIcon = "carbon:settings"
250 const AddIcon = "carbon:add-alt"
251 const PrintIcon = "carbon:printer"
252 const RowSettingsIcon = "carbon:fit-to-height"
253 const dFormats = useSettingsStore().dateFormat
254 const message = useMessage()
255 const loadingPrint = ref(false)
256 const loading = computed(() => loadingPrint.value)
257
258 const settingDrawerOpen = ref(false)
259 const printSettings = ref<Partial<PrintSettingsData>>({})
260 const preloadingPrintSettings = ref(true)
261
262 const orgs = useStorage<OrgData[]>("report-panel-orgs-data", [], localStorage)
263 const rows = computed<Row[]>({
264 get() {
265 return orgs.value.find(o => o.id === org.value?.id)?.rows || []
266 },
267 set(val: Row[]) {
268 const orgData = orgs.value.find(o => o.id === org.value?.id)
269 if (orgData?.rows) {
270 orgData.rows = val
271 }
272 }
273 })
274 const panelsList = ref<PanelData[]>([])
275
276 const panelsReady = computed<number>(() => {
277 return rows.value.reduce((acc, row) => {
278 return acc + row.panels.length
279 }, 0)
280 })
281
282 watch(org, val => {
283 if (val) {
284 setOrg(val)
285 }
286 panelsList.value = []
287 })
288
289 watch(panels, val => {
290 if (val?.length) {
291 setPanelsList(val)
292
293 if (!rows.value.length) {
294 addRow()
295 }
296 }
297 })
298
299 function openSettings() {
300 settingDrawerOpen.value = true
301 }
302
303 function addRow() {
304 rows.value.push({
305 id: Date.now(),
306 height: 1,
307 panels: []
308 })
309 }
310
311 function removeRow(row: Row) {
312 rows.value.splice(
313 rows.value.findIndex(o => o.id === row.id),
314 1
315 )
316 }
317
318 function removePanel(row: Row, panel: PanelData) {
319 row.panels.splice(
320 row.panels.findIndex(o => o.panelId === panel.panelId),
321 1
322 )
323 }
324
325 function setPanelsList(panels: Panel[]) {
326 if (org.value && dashboard.value && panels.length) {
327 panelsList.value = []
328
329 for (const panel of panels) {
330 panelsList.value.push({
331 panelId: panel.id,
332 panelTitle: panel.title,
333 orgId: org.value.id,
334 orgName: org.value.name,
335 dashboardUID: dashboard.value.uid,
336 dashboardTitle: dashboard.value.title
337 })
338 }
339 }
340 }
341
342 function setOrg(org: Org) {
343 const exist = orgs.value.find(o => o.id === org.id)
344 if (!exist) {
345 orgs.value.push({
346 id: org.id,
347 rows: []
348 })
349 }
350 }
351
352 const NUMBER_REGEX = /\d+/
353 const LETTER_REGEX = /[a-z]/i
354
355 function print() {
356 if (!timerange.value) {
357 return
358 }
359
360 loadingPrint.value = true
361
362 const timeValue = Number.parseInt(timerange.value.match(NUMBER_REGEX)?.[0] || "1")
363 const timeUnit = (timerange.value.match(LETTER_REGEX)?.[0] || "h").toLocaleLowerCase()
364 const timerangeText = `Last ${timeValue} ${timeUnit === "d" ? "Day" : timeUnit === "h" ? "Hour" : "minute"}${
365 timeValue > 1 ? "s" : ""
366 }`
367
368 const payload: GenerateReportPayload = {
369 timerange: timerange.value,
370 timerange_text: timerangeText,
371 logo_base64: printSettings.value.logo || defaultSettings.logo,
372 company_name: printSettings.value.company || defaultSettings.company,
373 rows: []
374 }
375
376 const density = printSettings.value.retina ? 2 : 1
377
378 for (const row of rows.value) {
379 if (row.panels.length) {
380 const panel_width = ((ROW_WIDTH - ROW_GAP * (row.panels.length - 1)) / row.panels.length) * density
381 const panel_height = ROW_HEIGHT * density * (row.height || 1)
382
383 payload.rows.push({
384 id: row.id,
385 panels: row.panels.map(o => ({
386 org_id: o.orgId,
387 dashboard_title: o.dashboardTitle,
388 dashboard_uid: o.dashboardUID,
389 panel_id: o.panelId,
390 panel_width,
391 panel_height,
392 theme: printSettings.value.theme || defaultSettings.theme
393 }))
394 })
395 }
396 }
397
398 const reportFileName = `report${org.value?.name ? `:${_kebabCase(org.value.name)}` : ""}_${formatDate(
399 new Date(),
400 dFormats.datetimesec
401 )}.pdf`
402
403 Api.reporting
404 .generateReport(payload)
405 .then(res => {
406 if (res.data.success) {
407 const dataUri = `data:application/pdf;base64,${res.data.base64_result}`
408 saveAs(dataUri, reportFileName)
409 } else {
410 message.warning(res.data?.message || "An error occurred. Please try again later.")
411 }
412 })
413 .catch(err => {
414 message.error(err.response?.data?.message || "An error occurred. Please try again later.")
415 })
416 .finally(() => {
417 loadingPrint.value = false
418 })
419 }
420
421 onMounted(() => {
422 // need to preload print settings from drawer
423 settingDrawerOpen.value = true
424 setTimeout(() => {
425 settingDrawerOpen.value = false
426 setTimeout(() => {
427 preloadingPrintSettings.value = false
428 }, 300)
429 }, 100)
430 })
431 </script>
432
433 <style lang="scss" scoped>
434 .empty-message {
435 border: 2px dashed var(--border-color) !important;
436 border-radius: var(--border-radius);
437 height: 100%;
438 width: 100%;
439 display: flex;
440 align-items: center;
441 justify-content: center;
442 text-align: center;
443 line-height: 1.3;
444 gap: 6px;
445 }
446 .report-panels {
447 overflow: hidden;
448
449 .panel {
450 aspect-ratio: 1.72;
451 cursor: move;
452
453 .content {
454 border-radius: var(--border-radius);
455 background-color: var(--bg-default-color);
456 border: 1px solid var(--border-color);
457 overflow: hidden;
458 display: flex;
459 align-items: center;
460 justify-content: center;
461 font-size: 12px;
462 font-weight: bold;
463 padding: 16px;
464 text-align: center;
465 transition: border-color 0.2s;
466 width: 100%;
467 height: 100%;
468 }
469
470 &:hover {
471 .content {
472 border-color: rgba(var(--primary-color-rgb) / 0.4);
473 }
474 }
475
476 &.ghost-panel {
477 .content {
478 border: 2px dashed rgba(var(--primary-color-rgb) / 0.4) !important;
479 }
480 }
481 }
482
483 .panels-container {
484 overflow: hidden;
485
486 .rows-container {
487 overflow: hidden;
488 border-radius: var(--border-radius);
489
490 .drag-wrapper {
491 padding-right: 16px;
492 padding-left: 20px;
493 }
494
495 .row {
496 border-radius: var(--border-radius);
497 background-color: var(--bg-secondary-color);
498 border: 1px solid var(--border-color);
499 height: 155px;
500 position: relative;
501 transition: border-color 0.2s;
502
503 &.height-large {
504 height: 300px;
505 }
506 .empty-message {
507 position: absolute;
508 z-index: 0;
509 width: unset;
510 height: unset;
511 inset: calc(var(--spacing) * 3);
512 }
513
514 .drop-panels-area {
515 position: absolute;
516 width: unset;
517 height: unset;
518 inset: calc(var(--spacing) * 3);
519 }
520
521 .left-box {
522 position: absolute;
523 top: 0;
524 left: -32px;
525 bottom: 0;
526 width: 25px;
527 border: 1px solid var(--border-color);
528 background-color: var(--bg-secondary-color);
529 border-radius: var(--border-radius);
530
531 .pan-area {
532 position: absolute;
533 top: 50%;
534 left: 2px;
535 transform: translateY(-50%);
536 cursor: move;
537 }
538
539 .delete-box {
540 position: absolute;
541 top: 0px;
542 left: 0px;
543 right: 0;
544 background-color: rgba(var(--error-color-rgb) / 0.1);
545 border-top-left-radius: var(--border-radius);
546 border-top-right-radius: var(--border-radius);
547 text-align: center;
548 padding-top: 4px;
549 }
550
551 .settings-box {
552 position: absolute;
553 bottom: 0px;
554 left: 0px;
555 right: 0;
556 background-color: rgba(var(--hover-color-rgb) / 0.05);
557 border-bottom-left-radius: var(--border-radius);
558 border-bottom-right-radius: var(--border-radius);
559 text-align: center;
560 padding-top: 4px;
561 }
562
563 &:hover {
564 border-color: rgba(var(--primary-color-rgb) / 0.2);
565 }
566 }
567
568 .panel {
569 height: 100%;
570 aspect-ratio: unset;
571 flex: 1 1 0px;
572 position: relative;
573
574 .delete-box {
575 position: absolute;
576 top: 4px;
577 right: 4px;
578 }
579
580 .dashboard-title {
581 font-size: 10px;
582 position: absolute;
583 top: 0px;
584 left: 0px;
585 right: 28px;
586 padding: 5px 10px;
587 color: var(--fg-secondary-color);
588 white-space: nowrap;
589 overflow: hidden;
590 text-overflow: ellipsis;
591 }
592 }
593
594 &:hover {
595 border-color: rgba(var(--primary-color-rgb) / 0.2);
596 }
597
598 &.ghost-row {
599 border: 2px dashed rgba(var(--primary-color-rgb) / 0.4) !important;
600 }
601 }
602 }
603 }
604
605 .panels-sidebar {
606 border-radius: var(--border-radius);
607 border: 1px solid var(--border-color);
608 width: 200px;
609
610 :deep() {
611 .n-scrollbar {
612 .n-scrollbar-rail {
613 right: 3px;
614 }
615 }
616 }
617
618 .panel {
619 width: 100%;
620 }
621 }
622 }
623 </style>