@omnitend/dashboard-for-laravel 0.10.0 → 0.13.1

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,79 @@
1
+ <!--
2
+ @component
3
+ A themed line chart. Thin wrapper around vue-chartjs (chart.js) with the
4
+ Bootstrap palette applied (themed stroke + translucent fill, smoothed line)
5
+ and dashboard-friendly defaults. Pass `labels` + `datasets`; `options`
6
+ deep-merges over the defaults.
7
+
8
+ Requires the optional peer deps `chart.js` and `vue-chartjs`.
9
+ -->
10
+ <template>
11
+ <div class="dx-chart">
12
+ <Line :data="chartData" :options="mergedOptions" />
13
+ </div>
14
+ </template>
15
+
16
+ <script setup lang="ts">
17
+ import { computed } from "vue";
18
+ import { Line } from "vue-chartjs";
19
+ import {
20
+ registerCharts,
21
+ baseOptions,
22
+ applyPalette,
23
+ mergeOptions,
24
+ type ChartValueFormat,
25
+ } from "./chartTheme";
26
+
27
+ registerCharts();
28
+
29
+ interface Props {
30
+ /** X-axis category labels. */
31
+ labels: string[];
32
+ /** Chart.js datasets. Colours are themed when a dataset omits them. */
33
+ datasets: any[];
34
+ /** Chart.js options; deep-merged over the themed defaults. */
35
+ options?: Record<string, any>;
36
+ /** Format value-axis ticks and tooltips. */
37
+ valueFormat?: ChartValueFormat;
38
+ /** Currency symbol for valueFormat: "currency" (default "£"). */
39
+ currencySymbol?: string;
40
+ /** Locale for number formatting. */
41
+ locale?: string;
42
+ /** Show the legend (default: only when there is more than one dataset). */
43
+ showLegend?: boolean;
44
+ }
45
+
46
+ // `showLegend` defaults to `undefined` (not Vue's Boolean-cast `false`) so the
47
+ // `?? datasets.length > 1` default can actually fire when it isn't passed.
48
+ const props = withDefaults(defineProps<Props>(), {
49
+ currencySymbol: "£",
50
+ showLegend: undefined,
51
+ });
52
+
53
+ const chartData = computed(() => ({
54
+ labels: props.labels,
55
+ datasets: applyPalette(props.datasets, "line", props.labels.length),
56
+ }));
57
+
58
+ const mergedOptions = computed(() =>
59
+ mergeOptions(
60
+ baseOptions({
61
+ valueFormat: props.valueFormat,
62
+ currencySymbol: props.currencySymbol,
63
+ locale: props.locale,
64
+ showLegend: props.showLegend ?? props.datasets.length > 1,
65
+ hasValueAxis: true,
66
+ }),
67
+ props.options ?? {},
68
+ ),
69
+ );
70
+ </script>
71
+
72
+ <style scoped>
73
+ .dx-chart {
74
+ position: relative;
75
+ width: 100%;
76
+ height: 100%;
77
+ min-height: 240px;
78
+ }
79
+ </style>
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Shared theming for the DX*Chart wrappers: a categorical palette pulled from
3
+ * the live Bootstrap theme, dashboard-friendly default options (no gridlines /
4
+ * legend clutter, responsive, formatted ticks), and a one-time Chart.js
5
+ * registration. chart.js / vue-chartjs are OPTIONAL peer deps.
6
+ */
7
+ import { Chart, registerables } from "chart.js";
8
+
9
+ export type ChartValueFormat = "number" | "currency" | "percent";
10
+
11
+ const isPlainObject = (value: unknown): value is Record<string, any> =>
12
+ !!value && typeof value === "object" && !Array.isArray(value);
13
+
14
+ /** Deep-merge consumer `options` over the themed defaults (arrays replace). */
15
+ export function mergeOptions(
16
+ base: Record<string, any>,
17
+ override: Record<string, any>,
18
+ ): Record<string, any> {
19
+ const result: Record<string, any> = { ...base };
20
+ for (const [key, value] of Object.entries(override)) {
21
+ if (isPlainObject(value) && isPlainObject(result[key])) {
22
+ result[key] = mergeOptions(result[key], value);
23
+ } else {
24
+ result[key] = value;
25
+ }
26
+ }
27
+ return result;
28
+ }
29
+
30
+ let registered = false;
31
+
32
+ /**
33
+ * Register Chart.js (idempotent). Uses the full `registerables` set so every
34
+ * controller / scale / element / plugin (incl. Legend, Tooltip, Filler) is
35
+ * available — chart.js is an external peer dep, so this adds no bundle weight.
36
+ */
37
+ export function registerCharts(): void {
38
+ if (registered) return;
39
+ Chart.register(...registerables);
40
+ registered = true;
41
+ }
42
+
43
+ // Bootstrap CSS variables to read, in a sensible categorical order. Fallbacks
44
+ // mirror Bootstrap 5 defaults so charts still render without the theme (SSR /
45
+ // tests / a consumer who didn't import the CSS).
46
+ const PALETTE_VARS: Array<[string, string]> = [
47
+ ["--bs-primary", "#0d6efd"],
48
+ ["--bs-success", "#198754"],
49
+ ["--bs-info", "#0dcaf0"],
50
+ ["--bs-warning", "#ffc107"],
51
+ ["--bs-danger", "#dc3545"],
52
+ ["--bs-purple", "#6f42c1"],
53
+ ["--bs-teal", "#20c997"],
54
+ ["--bs-orange", "#fd7e14"],
55
+ ["--bs-pink", "#d63384"],
56
+ ["--bs-secondary", "#6c757d"],
57
+ ];
58
+
59
+ /** Resolve the themed categorical palette from the document root. */
60
+ export function getPalette(): string[] {
61
+ if (typeof document === "undefined" || typeof getComputedStyle === "undefined") {
62
+ return PALETTE_VARS.map(([, fallback]) => fallback);
63
+ }
64
+ const styles = getComputedStyle(document.documentElement);
65
+ return PALETTE_VARS.map(([name, fallback]) => {
66
+ const value = styles.getPropertyValue(name).trim();
67
+ return value || fallback;
68
+ });
69
+ }
70
+
71
+ /** Read a single theme colour (e.g. the body text colour) with a fallback. */
72
+ function themeColor(name: string, fallback: string): string {
73
+ if (typeof document === "undefined" || typeof getComputedStyle === "undefined") {
74
+ return fallback;
75
+ }
76
+ const value = getComputedStyle(document.documentElement)
77
+ .getPropertyValue(name)
78
+ .trim();
79
+ return value || fallback;
80
+ }
81
+
82
+ /** Add an alpha channel to a hex or rgb() colour (best-effort). */
83
+ export function withAlpha(color: string, alpha: number): string {
84
+ const hex = color.replace(/^#/, "");
85
+ if (/^[0-9a-f]{6}$/i.test(hex)) {
86
+ const r = parseInt(hex.slice(0, 2), 16);
87
+ const g = parseInt(hex.slice(2, 4), 16);
88
+ const b = parseInt(hex.slice(4, 6), 16);
89
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
90
+ }
91
+ const rgbMatch = color.match(/^rgba?\(([^)]+)\)$/);
92
+ if (rgbMatch) {
93
+ const [r, g, b] = rgbMatch[1].split(",").map((n) => n.trim());
94
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
95
+ }
96
+ return color;
97
+ }
98
+
99
+ /** Format a numeric value per the chart's value format (for tick/tooltips). */
100
+ export function formatValue(
101
+ value: number,
102
+ format: ChartValueFormat | undefined,
103
+ currencySymbol: string,
104
+ locale?: string,
105
+ ): string {
106
+ const num = new Intl.NumberFormat(locale, {
107
+ maximumFractionDigits: 2,
108
+ }).format(value);
109
+ if (format === "currency") return `${currencySymbol}${num}`;
110
+ if (format === "percent") return `${num}%`;
111
+ return num;
112
+ }
113
+
114
+ export interface BaseOptionArgs {
115
+ valueFormat?: ChartValueFormat;
116
+ currencySymbol?: string;
117
+ locale?: string;
118
+ /** Show the legend (default: false for a single dataset, true for many). */
119
+ showLegend?: boolean;
120
+ /** Whether the chart has value axes (bar/line) vs none (doughnut). */
121
+ hasValueAxis?: boolean;
122
+ }
123
+
124
+ /** Dashboard default Chart.js options merged under any consumer `options`. */
125
+ export function baseOptions(args: BaseOptionArgs): Record<string, any> {
126
+ const {
127
+ valueFormat,
128
+ currencySymbol = "£",
129
+ locale,
130
+ showLegend = false,
131
+ hasValueAxis = true,
132
+ } = args;
133
+
134
+ const gridColor = withAlpha(themeColor("--bs-border-color", "#dee2e6"), 0.6);
135
+ const tickColor = themeColor("--bs-secondary-color", "#6c757d");
136
+
137
+ const options: Record<string, any> = {
138
+ responsive: true,
139
+ maintainAspectRatio: false,
140
+ plugins: {
141
+ legend: { display: showLegend, labels: { color: tickColor } },
142
+ tooltip: {
143
+ callbacks: {
144
+ label: (ctx: any) => {
145
+ const raw = ctx.parsed?.y ?? ctx.parsed ?? ctx.raw;
146
+ const value = typeof raw === "number" ? raw : Number(raw);
147
+ const formatted = Number.isFinite(value)
148
+ ? formatValue(value, valueFormat, currencySymbol, locale)
149
+ : String(raw);
150
+ const label = ctx.dataset?.label ?? ctx.label ?? "";
151
+ return label ? `${label}: ${formatted}` : formatted;
152
+ },
153
+ },
154
+ },
155
+ },
156
+ };
157
+
158
+ if (hasValueAxis) {
159
+ options.scales = {
160
+ x: {
161
+ grid: { display: false },
162
+ border: { color: gridColor },
163
+ ticks: { color: tickColor },
164
+ },
165
+ y: {
166
+ beginAtZero: true,
167
+ grid: { color: gridColor, drawBorder: false },
168
+ border: { display: false },
169
+ ticks: {
170
+ color: tickColor,
171
+ callback: (value: number | string) =>
172
+ formatValue(Number(value), valueFormat, currencySymbol, locale),
173
+ },
174
+ },
175
+ };
176
+ }
177
+
178
+ return options;
179
+ }
180
+
181
+ /**
182
+ * Apply the themed palette to any dataset that doesn't set its own colours.
183
+ * Bar: one colour per dataset (or per bar for a single dataset). Line: a themed
184
+ * stroke + translucent fill. Doughnut/pie: one colour per slice.
185
+ */
186
+ export function applyPalette(
187
+ datasets: any[],
188
+ kind: "bar" | "line" | "doughnut",
189
+ labelsCount: number,
190
+ ): any[] {
191
+ const palette = getPalette();
192
+ const single = datasets.length === 1;
193
+
194
+ return datasets.map((dataset, index) => {
195
+ const next = { ...dataset };
196
+ const color = palette[index % palette.length];
197
+
198
+ if (kind === "doughnut") {
199
+ if (next.backgroundColor === undefined) {
200
+ next.backgroundColor = Array.from(
201
+ { length: labelsCount },
202
+ (_, i) => palette[i % palette.length],
203
+ );
204
+ }
205
+ return next;
206
+ }
207
+
208
+ if (kind === "line") {
209
+ if (next.borderColor === undefined) next.borderColor = color;
210
+ if (next.backgroundColor === undefined) {
211
+ next.backgroundColor = withAlpha(color, 0.15);
212
+ }
213
+ if (next.tension === undefined) next.tension = 0.3;
214
+ if (next.fill === undefined) next.fill = true;
215
+ if (next.pointRadius === undefined) next.pointRadius = 2;
216
+ return next;
217
+ }
218
+
219
+ // bar
220
+ if (next.backgroundColor === undefined) {
221
+ next.backgroundColor =
222
+ single && labelsCount > 1
223
+ ? Array.from({ length: labelsCount }, () => color)
224
+ : color;
225
+ }
226
+ if (next.borderRadius === undefined) next.borderRadius = 4;
227
+ return next;
228
+ });
229
+ }
@@ -52,10 +52,15 @@
52
52
 
53
53
  <!-- Page Content -->
54
54
  <main class="dashboard-main p-4">
55
- <DContainer fluid>
55
+ <!-- Fluid: full-width, left-aligned content (for wide tables / admin
56
+ pages). Default: a centred reading-width column. -->
57
+ <DContainer v-if="fluid" fluid :class="contentClass">
58
+ <!-- @slot Default slot for the main page content. Full-width when `fluid`, otherwise a centred reading-width column. -->
59
+ <slot />
60
+ </DContainer>
61
+ <DContainer v-else fluid>
56
62
  <DRow class="justify-content-center">
57
- <DCol cols="12" xl="10">
58
- <!-- @slot Default slot for the main page content, rendered in the centred content column. -->
63
+ <DCol cols="12" xl="10" :class="contentClass">
59
64
  <slot />
60
65
  </DCol>
61
66
  </DRow>
@@ -89,6 +94,16 @@ interface Props {
89
94
  /** Page title shown in navbar */
90
95
  pageTitle?: string;
91
96
 
97
+ /**
98
+ * Render the page content full-width and left-aligned instead of the default
99
+ * centred, reading-width (`col-xl-10`) column. Use for data-heavy admin pages
100
+ * (wide tables).
101
+ */
102
+ fluid?: boolean;
103
+
104
+ /** Extra class(es) applied to the content container/column. */
105
+ contentClass?: string;
106
+
92
107
  /** User object for navbar dropdown */
93
108
  user?: { name: string; email: string } | null;
94
109
 
@@ -0,0 +1,211 @@
1
+ <!--
2
+ @component
3
+ A KPI / stat tile: a big formatted value with a title, optional subtitle, an
4
+ optional up/down delta badge (auto-coloured + arrowed) versus a comparison,
5
+ and an optional trend/sparkline slot. Themed with Bootstrap variables.
6
+ -->
7
+ <template>
8
+ <DCard class="dx-stat-card" body-class="dx-stat-card__body">
9
+ <div class="dx-stat-card__head">
10
+ <div class="dx-stat-card__heading">
11
+ <div class="dx-stat-card__title">{{ title }}</div>
12
+ <div v-if="subtitle" class="dx-stat-card__subtitle">{{ subtitle }}</div>
13
+ </div>
14
+ <div v-if="$slots.icon" class="dx-stat-card__icon">
15
+ <!-- @slot Optional icon/visual in the top-right of the card. -->
16
+ <slot name="icon" />
17
+ </div>
18
+ </div>
19
+
20
+ <div class="dx-stat-card__value">
21
+ <!--
22
+ @slot Override the rendered value.
23
+ @binding {string} formatted The value formatted per `format`.
24
+ @binding {string | number} value The raw value prop.
25
+ -->
26
+ <slot name="value" :formatted="formattedValue" :value="value">
27
+ {{ formattedValue }}
28
+ </slot>
29
+ </div>
30
+
31
+ <div v-if="hasDelta || deltaLabel || $slots.trend" class="dx-stat-card__foot">
32
+ <DBadge
33
+ v-if="hasDelta"
34
+ :variant="deltaVariant"
35
+ class="dx-stat-card__delta"
36
+ >
37
+ <span class="dx-stat-card__delta-arrow" aria-hidden="true">{{ deltaArrow }}</span>
38
+ {{ formattedDelta }}
39
+ </DBadge>
40
+ <span v-if="deltaLabel" class="dx-stat-card__delta-label text-muted">
41
+ {{ deltaLabel }}
42
+ </span>
43
+ <div v-if="$slots.trend" class="dx-stat-card__trend">
44
+ <!-- @slot Trend visual (e.g. a sparkline) shown in the card footer. -->
45
+ <slot name="trend" />
46
+ </div>
47
+ </div>
48
+ </DCard>
49
+ </template>
50
+
51
+ <script setup lang="ts">
52
+ import { computed } from "vue";
53
+ import DCard from "../base/DCard.vue";
54
+ import DBadge from "../base/DBadge.vue";
55
+
56
+ type StatFormat = "number" | "currency" | "percent";
57
+ type DeltaDirection = "up" | "down" | "neutral";
58
+
59
+ interface Props {
60
+ /** Metric label, e.g. "Revenue". */
61
+ title: string;
62
+ /** Optional secondary line under the title, e.g. "This week". */
63
+ subtitle?: string;
64
+ /** The metric value. Numbers are formatted per `format`; strings pass through. */
65
+ value: string | number;
66
+ /** How to format a numeric `value` (default: plain number with grouping). */
67
+ format?: StatFormat;
68
+ /** Currency symbol for `format: "currency"` (default "£"). */
69
+ currencySymbol?: string;
70
+ /** Decimal places (default: 2 for currency, 0 otherwise). */
71
+ precision?: number;
72
+ /** Locale for number grouping/formatting (default: the runtime's). */
73
+ locale?: string;
74
+
75
+ /** Change versus a comparison period. Its sign drives the arrow/colour. */
76
+ delta?: number;
77
+ /** How to format the delta (default "percent"). */
78
+ deltaFormat?: StatFormat;
79
+ /** Force the delta direction instead of deriving it from `delta`'s sign. */
80
+ deltaDirection?: DeltaDirection;
81
+ /** Caption beside the delta, e.g. "vs last week". */
82
+ deltaLabel?: string;
83
+ /**
84
+ * Invert the delta colour: by default an up delta is positive (success/green)
85
+ * and a down delta negative (danger/red). Set for metrics where down is good
86
+ * (costs, bounce rate, …).
87
+ */
88
+ invertDelta?: boolean;
89
+ }
90
+
91
+ const props = withDefaults(defineProps<Props>(), {
92
+ format: "number",
93
+ currencySymbol: "£",
94
+ deltaFormat: "percent",
95
+ invertDelta: false,
96
+ });
97
+
98
+ function formatNumber(value: number, fmt: StatFormat): string {
99
+ const fractionDigits =
100
+ props.precision ?? (fmt === "currency" ? 2 : 0);
101
+ const formatted = new Intl.NumberFormat(props.locale, {
102
+ minimumFractionDigits: fractionDigits,
103
+ maximumFractionDigits: fractionDigits,
104
+ }).format(value);
105
+
106
+ if (fmt === "currency") return `${props.currencySymbol}${formatted}`;
107
+ if (fmt === "percent") return `${formatted}%`;
108
+ return formatted;
109
+ }
110
+
111
+ const formattedValue = computed<string>(() => {
112
+ const raw = props.value;
113
+ if (typeof raw !== "number" || !Number.isFinite(raw)) return String(raw);
114
+ return formatNumber(raw, props.format);
115
+ });
116
+
117
+ const hasDelta = computed(
118
+ () => typeof props.delta === "number" && Number.isFinite(props.delta),
119
+ );
120
+
121
+ const deltaDirection = computed<DeltaDirection>(() => {
122
+ if (props.deltaDirection) return props.deltaDirection;
123
+ if (!hasDelta.value || props.delta === 0) return "neutral";
124
+ return (props.delta as number) > 0 ? "up" : "down";
125
+ });
126
+
127
+ const deltaArrow = computed(() => {
128
+ if (deltaDirection.value === "up") return "▲";
129
+ if (deltaDirection.value === "down") return "▼";
130
+ return "→";
131
+ });
132
+
133
+ const deltaVariant = computed<string>(() => {
134
+ if (deltaDirection.value === "neutral") return "secondary";
135
+ const isPositive = deltaDirection.value === "up";
136
+ const good = props.invertDelta ? !isPositive : isPositive;
137
+ return good ? "success" : "danger";
138
+ });
139
+
140
+ // Show the magnitude; the arrow conveys direction.
141
+ const formattedDelta = computed<string>(() => {
142
+ if (!hasDelta.value) return "";
143
+ return formatNumber(Math.abs(props.delta as number), props.deltaFormat);
144
+ });
145
+ </script>
146
+
147
+ <style scoped>
148
+ .dx-stat-card :deep(.dx-stat-card__body) {
149
+ display: flex;
150
+ flex-direction: column;
151
+ gap: 0.5rem;
152
+ }
153
+
154
+ .dx-stat-card__head {
155
+ display: flex;
156
+ align-items: flex-start;
157
+ justify-content: space-between;
158
+ gap: 0.75rem;
159
+ }
160
+
161
+ .dx-stat-card__title {
162
+ font-size: 0.8125rem;
163
+ font-weight: 600;
164
+ text-transform: uppercase;
165
+ letter-spacing: 0.03em;
166
+ color: var(--bs-secondary-color);
167
+ }
168
+
169
+ .dx-stat-card__subtitle {
170
+ font-size: 0.8125rem;
171
+ color: var(--bs-secondary-color);
172
+ }
173
+
174
+ .dx-stat-card__icon {
175
+ color: var(--bs-secondary-color);
176
+ flex-shrink: 0;
177
+ }
178
+
179
+ .dx-stat-card__value {
180
+ font-size: 1.75rem;
181
+ font-weight: 700;
182
+ line-height: 1.1;
183
+ color: var(--bs-emphasis-color, var(--bs-body-color));
184
+ }
185
+
186
+ .dx-stat-card__foot {
187
+ display: flex;
188
+ align-items: center;
189
+ gap: 0.5rem;
190
+ flex-wrap: wrap;
191
+ }
192
+
193
+ .dx-stat-card__delta {
194
+ display: inline-flex;
195
+ align-items: center;
196
+ gap: 0.2rem;
197
+ }
198
+
199
+ .dx-stat-card__delta-arrow {
200
+ font-size: 0.7em;
201
+ line-height: 1;
202
+ }
203
+
204
+ .dx-stat-card__delta-label {
205
+ font-size: 0.8125rem;
206
+ }
207
+
208
+ .dx-stat-card__trend {
209
+ margin-left: auto;
210
+ }
211
+ </style>