From 809e57cc28bb2704441e34f64a28b2866f177859 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Thu, 11 Jun 2026 16:29:01 +0100 Subject: [PATCH] Updates --- app/Services/Charts/DashboardChartService.php | 348 ++++++++++++++++++ public/css/wms-dashboard.css | 157 ++++++++ public/js/wms-dashboard-charts.js | 135 +++++++ resources/css/wms-dashboard.css | 157 ++++++++ resources/js/charts/wms-dashboard-charts.js | 135 +++++++ .../components/dashboard/chart-card.blade.php | 34 ++ .../dashboard/metric-card.blade.php | 23 ++ 7 files changed, 989 insertions(+) create mode 100644 app/Services/Charts/DashboardChartService.php create mode 100644 public/css/wms-dashboard.css create mode 100644 public/js/wms-dashboard-charts.js create mode 100644 resources/css/wms-dashboard.css create mode 100644 resources/js/charts/wms-dashboard-charts.js create mode 100644 resources/views/components/dashboard/chart-card.blade.php create mode 100644 resources/views/components/dashboard/metric-card.blade.php diff --git a/app/Services/Charts/DashboardChartService.php b/app/Services/Charts/DashboardChartService.php new file mode 100644 index 00000000..66ea656e --- /dev/null +++ b/app/Services/Charts/DashboardChartService.php @@ -0,0 +1,348 @@ + + */ + public function dashboard(): array + { + return [ + 'metrics' => $this->metrics(), + 'charts' => [ + 'pickingLastSevenDays' => $this->pickingLastSevenDays(), + 'dispatchesLastSevenDays' => $this->dispatchesLastSevenDays(), + 'stockMovementsByType' => $this->stockMovementsByType(), + 'lowStockByWarehouse' => $this->lowStockByWarehouse(), + 'warehouseUtilisation' => $this->warehouseUtilisation(), + 'systemHealth' => $this->systemHealth(), + ], + ]; + } + + /** + * @return array + */ + private function metrics(): array + { + return [ + [ + 'label' => 'Open pick waves', + 'value' => $this->safeCount('ICM688M1', "A4PICKSTS IN ('B', 'P')"), + 'hint' => 'Active or in-progress pick work', + 'icon' => 'fa-box-open', + ], + [ + 'label' => 'Pending manifests', + 'value' => $this->safeCount('ICM705M1', "APSPARE2 IN ('', 'P')"), + 'hint' => 'Waiting for close or confirmation', + 'icon' => 'fa-truck-loading', + ], + [ + 'label' => 'M3 errors', + 'value' => $this->safeCount('ICM699M', "SHERROR = 'X'"), + 'hint' => 'Messages needing attention', + 'icon' => 'fa-exclamation-triangle', + ], + [ + 'label' => 'Warehouses', + 'value' => $this->safeCount('IC670M'), + 'hint' => 'Known warehouse records', + 'icon' => 'fa-warehouse', + ], + ]; + } + + /** + * @return array + */ + private function pickingLastSevenDays(): array + { + $labels = $this->lastDaysLabels(7); + $data = $this->emptySeries($labels); + + try { + $rows = DB::connection('odbc')->select( + "SELECT DATE(A5PICKCMTS) AS PICK_DATE, COUNT(*) AS TOTAL + FROM EWDAT.ICM688M2 + WHERE A5PICKCMTS >= CURRENT DATE - 7 DAYS + GROUP BY DATE(A5PICKCMTS) + ORDER BY PICK_DATE" + ); + + foreach ($rows as $row) { + $key = Carbon::parse($row->PICK_DATE)->format('d M'); + if (array_key_exists($key, $data)) { + $data[$key] = (int) $row->TOTAL; + } + } + } catch (\Throwable $exception) { + $this->logChartException($exception); + } + + return $this->lineChart($labels, array_values($data), 'Pick lines completed'); + } + + /** + * @return array + */ + private function dispatchesLastSevenDays(): array + { + $labels = $this->lastDaysLabels(7); + $data = $this->emptySeries($labels); + + try { + $rows = DB::connection('odbc')->select( + "SELECT SHDT AS SHIP_DATE, COUNT(DISTINCT SHMACPKR) AS TOTAL + FROM EWDAT.ICM699M + WHERE SHTYPE = 'M3-Res-out' + AND SHSOURCE = 'CloseShipmentCoChargeAPI' + AND SHDATA LIKE 'Success%' + AND SHDT >= VARCHAR_FORMAT(CURRENT DATE - 7 DAYS, 'YYYYMMDD') + GROUP BY SHDT + ORDER BY SHDT" + ); + + foreach ($rows as $row) { + $key = Carbon::createFromFormat('Ymd', (string) $row->SHIP_DATE)->format('d M'); + if (array_key_exists($key, $data)) { + $data[$key] = (int) $row->TOTAL; + } + } + } catch (\Throwable $exception) { + $this->logChartException($exception); + } + + return $this->barChart($labels, array_values($data), 'Dispatches closed'); + } + + /** + * @return array + */ + private function stockMovementsByType(): array + { + $labels = ['Transfer', 'Replenishment', 'Packing', 'Shipment', 'Adjustment']; + $data = [0, 0, 0, 0, 0]; + + try { + $rows = DB::connection('odbc')->select( + "SELECT TRIM(SHSOURCE) AS SOURCE_NAME, COUNT(*) AS TOTAL + FROM EWDAT.ICM699M + WHERE SHDT >= VARCHAR_FORMAT(CURRENT DATE - 30 DAYS, 'YYYYMMDD') + GROUP BY TRIM(SHSOURCE) + ORDER BY TOTAL DESC + FETCH FIRST 5 ROWS ONLY" + ); + + if (! empty($rows)) { + $labels = []; + $data = []; + foreach ($rows as $row) { + $labels[] = ucwords(strtolower(str_replace(['API', '_', '-'], [' API', ' ', ' '], trim($row->SOURCE_NAME)))); + $data[] = (int) $row->TOTAL; + } + } + } catch (\Throwable $exception) { + $this->logChartException($exception); + } + + return $this->doughnutChart($labels, $data, 'Movement messages'); + } + + /** + * @return array + */ + private function lowStockByWarehouse(): array + { + $labels = ['WH 1', 'WH 2', 'WH 3', 'WH 4', 'WH 5']; + $data = [0, 0, 0, 0, 0]; + + try { + $rows = DB::connection('odbc')->select( + "SELECT TRIM(AT9WHS) AS WAREHOUSE, COUNT(*) AS TOTAL + FROM EWDAT.ICM709M + WHERE AT9QTY <= AT9WRN + GROUP BY TRIM(AT9WHS) + ORDER BY TOTAL DESC + FETCH FIRST 6 ROWS ONLY" + ); + + if (! empty($rows)) { + $labels = []; + $data = []; + foreach ($rows as $row) { + $labels[] = 'WH '.trim($row->WAREHOUSE); + $data[] = (int) $row->TOTAL; + } + } + } catch (\Throwable $exception) { + $this->logChartException($exception); + } + + return $this->barChart($labels, $data, 'Low-stock lines'); + } + + /** + * @return array + */ + private function warehouseUtilisation(): array + { + $labels = ['Available', 'Allocated', 'Packed']; + $data = [0, 0, 0]; + + try { + $rows = DB::connection('odbc')->select( + "SELECT + SUM(A5RSVQTY) AS RESERVED_QTY, + SUM(A5PACKED) AS PACKED_QTY, + SUM(A5RSVQTY - A5PACKED) AS REMAINING_QTY + FROM EWDAT.ICM688M2 + WHERE A5RSVQTY > 0" + ); + + if (! empty($rows)) { + $data = [ + max(0, (int) $rows[0]->REMAINING_QTY), + max(0, (int) $rows[0]->RESERVED_QTY), + max(0, (int) $rows[0]->PACKED_QTY), + ]; + } + } catch (\Throwable $exception) { + $this->logChartException($exception); + } + + return $this->doughnutChart($labels, $data, 'Quantity status'); + } + + /** + * @return array + */ + private function systemHealth(): array + { + $labels = ['Success', 'Errors', 'Pending']; + $data = [0, 0, 0]; + + try { + $success = $this->safeCount('ICM699M', "SHTYPE = 'M3-Res-out' AND SHDATA LIKE 'Success%'"); + $errors = $this->safeCount('ICM699M', "SHERROR = 'X'"); + $pending = $this->safeCount('ICM705M1', "APSPARE2 IN ('', 'P')"); + $data = [$success, $errors, $pending]; + } catch (\Throwable $exception) { + $this->logChartException($exception); + } + + return $this->doughnutChart($labels, $data, 'Operational status'); + } + + /** + * @return array + */ + private function lastDaysLabels(int $days): array + { + $labels = []; + for ($offset = $days - 1; $offset >= 0; $offset--) { + $labels[] = Carbon::today()->subDays($offset)->format('d M'); + } + + return $labels; + } + + /** + * @param array $labels + * @return array + */ + private function emptySeries(array $labels): array + { + return array_fill_keys($labels, 0); + } + + private function safeCount(string $table, ?string $where = null): int + { + try { + $sql = 'SELECT COUNT(*) AS TOTAL FROM EWDAT.'.$table; + if ($where !== null && trim($where) !== '') { + $sql .= ' WHERE '.$where; + } + + $rows = DB::connection('odbc')->select($sql); + + return isset($rows[0]->TOTAL) ? (int) $rows[0]->TOTAL : 0; + } catch (\Throwable $exception) { + $this->logChartException($exception); + + return 0; + } + } + + /** + * @param array $labels + * @param array $data + * @return array + */ + private function lineChart(array $labels, array $data, string $label): array + { + return [ + 'labels' => $labels, + 'datasets' => [[ + 'label' => $label, + 'data' => $data, + 'tension' => 0.35, + 'fill' => true, + ]], + ]; + } + + /** + * @param array $labels + * @param array $data + * @return array + */ + private function barChart(array $labels, array $data, string $label): array + { + return [ + 'labels' => $labels, + 'datasets' => [[ + 'label' => $label, + 'data' => $data, + 'borderRadius' => 8, + ]], + ]; + } + + /** + * @param array $labels + * @param array $data + * @return array + */ + private function doughnutChart(array $labels, array $data, string $label): array + { + return [ + 'labels' => $labels, + 'datasets' => [[ + 'label' => $label, + 'data' => $data, + 'borderWidth' => 2, + 'hoverOffset' => 8, + ]], + ]; + } + + private function logChartException(\Throwable $exception): void + { + Log::debug('Dashboard chart data unavailable.', [ + 'message' => $exception->getMessage(), + ]); + } +} diff --git a/public/css/wms-dashboard.css b/public/css/wms-dashboard.css new file mode 100644 index 00000000..abb8964e --- /dev/null +++ b/public/css/wms-dashboard.css @@ -0,0 +1,157 @@ +:root { + --wms-chart-primary: #0f766e; + --wms-chart-secondary: #2563eb; + --wms-chart-accent: #f59e0b; + --wms-chart-danger: #dc2626; + --wms-chart-muted: #64748b; + --wms-chart-purple: #7c3aed; + --wms-chart-text: #334155; + --wms-chart-grid: rgba(148, 163, 184, 0.25); +} + +.wms-dashboard { + color: #0f172a; +} + +.wms-dashboard-hero { + align-items: center; + background: linear-gradient(135deg, #0f766e 0%, #0f172a 100%); + border-radius: 24px; + box-shadow: 0 20px 40px rgba(15, 23, 42, 0.18); + color: #ffffff; + display: flex; + justify-content: space-between; + overflow: hidden; + padding: 32px; + position: relative; +} + +.wms-dashboard-hero::after { + background: radial-gradient(circle, rgba(255, 255, 255, 0.18), transparent 62%); + content: ''; + height: 260px; + position: absolute; + right: -80px; + top: -100px; + width: 260px; +} + +.wms-dashboard-hero__eyebrow { + color: rgba(255, 255, 255, 0.72); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.12em; + margin-bottom: 8px; + text-transform: uppercase; +} + +.wms-dashboard-hero__title { + font-size: clamp(2rem, 3vw, 3rem); + font-weight: 800; + margin-bottom: 10px; +} + +.wms-dashboard-hero__text { + color: rgba(255, 255, 255, 0.82); + font-size: 1rem; + margin: 0; + max-width: 760px; +} + +.wms-dashboard-hero__badge { + background: rgba(255, 255, 255, 0.14); + border: 1px solid rgba(255, 255, 255, 0.22); + border-radius: 999px; + font-weight: 700; + padding: 10px 16px; + position: relative; + z-index: 1; + white-space: nowrap; +} + +.wms-metric-card, +.wms-chart-card { + border: 0; + border-radius: 20px; + box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08); + overflow: hidden; +} + +.wms-metric-card__label { + color: #64748b; + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.wms-metric-card__value { + color: #0f172a; + font-size: 2rem; + font-weight: 850; + line-height: 1.1; + margin: 8px 0; +} + +.wms-metric-card__hint { + color: #64748b; + font-size: 0.85rem; +} + +.wms-metric-card__icon { + align-items: center; + background: linear-gradient(135deg, rgba(15, 118, 110, 0.14), rgba(37, 99, 235, 0.12)); + border-radius: 18px; + color: #0f766e; + display: flex; + font-size: 1.35rem; + height: 56px; + justify-content: center; + width: 56px; +} + +.wms-chart-card__header { + background: #ffffff; + border-bottom: 1px solid rgba(148, 163, 184, 0.22); + padding: 22px 24px 12px; +} + +.wms-chart-card__eyebrow { + color: #0f766e; + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.12em; + margin-bottom: 6px; + text-transform: uppercase; +} + +.wms-chart-card__title { + color: #0f172a; + font-size: 1.15rem; + font-weight: 800; + margin: 0 0 8px; +} + +.wms-chart-card__description { + color: #64748b; + font-size: 0.9rem; + margin: 0; +} + +.wms-chart-card__canvas { + min-height: 260px; + position: relative; +} + +@media (max-width: 991.98px) { + .wms-dashboard-hero { + align-items: flex-start; + flex-direction: column; + gap: 18px; + padding: 24px; + } + + .wms-dashboard-hero__badge { + white-space: normal; + } +} diff --git a/public/js/wms-dashboard-charts.js b/public/js/wms-dashboard-charts.js new file mode 100644 index 00000000..a67d6b26 --- /dev/null +++ b/public/js/wms-dashboard-charts.js @@ -0,0 +1,135 @@ +(function () { + 'use strict'; + + function cssVar(name, fallback) { + var value = getComputedStyle(document.documentElement).getPropertyValue(name); + return value && value.trim() !== '' ? value.trim() : fallback; + } + + function palette() { + return [ + cssVar('--wms-chart-primary', '#0f766e'), + cssVar('--wms-chart-secondary', '#2563eb'), + cssVar('--wms-chart-accent', '#f59e0b'), + cssVar('--wms-chart-danger', '#dc2626'), + cssVar('--wms-chart-muted', '#64748b'), + cssVar('--wms-chart-purple', '#7c3aed') + ]; + } + + function enrichDataset(dataset, index, type) { + var colours = palette(); + var colour = colours[index % colours.length]; + + if (type === 'doughnut' || type === 'pie') { + dataset.backgroundColor = colours; + dataset.borderColor = '#ffffff'; + dataset.borderWidth = dataset.borderWidth || 2; + return dataset; + } + + dataset.borderColor = colour; + dataset.backgroundColor = type === 'line' ? 'rgba(15, 118, 110, 0.14)' : colour; + dataset.pointBackgroundColor = colour; + dataset.pointBorderColor = '#ffffff'; + dataset.pointHoverRadius = 6; + dataset.borderWidth = 2; + + return dataset; + } + + function optionsFor(type) { + var textColour = cssVar('--wms-chart-text', '#334155'); + var gridColour = cssVar('--wms-chart-grid', 'rgba(148, 163, 184, 0.25)'); + + var options = { + responsive: true, + maintainAspectRatio: false, + interaction: { + intersect: false, + mode: 'index' + }, + plugins: { + legend: { + position: type === 'doughnut' || type === 'pie' ? 'bottom' : 'top', + labels: { + color: textColour, + boxWidth: 12, + usePointStyle: true, + padding: 18 + } + }, + tooltip: { + backgroundColor: 'rgba(15, 23, 42, 0.94)', + padding: 12, + titleColor: '#ffffff', + bodyColor: '#ffffff', + cornerRadius: 10 + } + } + }; + + if (type !== 'doughnut' && type !== 'pie') { + options.scales = { + x: { + ticks: { + color: textColour + }, + grid: { + display: false + } + }, + y: { + beginAtZero: true, + ticks: { + color: textColour, + precision: 0 + }, + grid: { + color: gridColour + } + } + }; + } + + if (type === 'doughnut') { + options.cutout = '68%'; + } + + return options; + } + + function normaliseConfig(rawConfig, type) { + var config = rawConfig || {}; + config.labels = config.labels || []; + config.datasets = (config.datasets || []).map(function (dataset, index) { + return enrichDataset(dataset, index, type); + }); + + return config; + } + + document.addEventListener('DOMContentLoaded', function () { + if (typeof Chart === 'undefined') { + return; + } + + document.querySelectorAll('[data-wms-chart]').forEach(function (canvas) { + var type = canvas.getAttribute('data-chart-type') || 'line'; + var raw = canvas.getAttribute('data-chart-config') || '{}'; + var config = {}; + + try { + config = JSON.parse(raw); + } catch (error) { + config = { labels: [], datasets: [] }; + } + + new Chart(canvas, { + type: type, + data: normaliseConfig(config, type), + options: optionsFor(type) + }); + }); + }); +}()); diff --git a/resources/css/wms-dashboard.css b/resources/css/wms-dashboard.css new file mode 100644 index 00000000..abb8964e --- /dev/null +++ b/resources/css/wms-dashboard.css @@ -0,0 +1,157 @@ +:root { + --wms-chart-primary: #0f766e; + --wms-chart-secondary: #2563eb; + --wms-chart-accent: #f59e0b; + --wms-chart-danger: #dc2626; + --wms-chart-muted: #64748b; + --wms-chart-purple: #7c3aed; + --wms-chart-text: #334155; + --wms-chart-grid: rgba(148, 163, 184, 0.25); +} + +.wms-dashboard { + color: #0f172a; +} + +.wms-dashboard-hero { + align-items: center; + background: linear-gradient(135deg, #0f766e 0%, #0f172a 100%); + border-radius: 24px; + box-shadow: 0 20px 40px rgba(15, 23, 42, 0.18); + color: #ffffff; + display: flex; + justify-content: space-between; + overflow: hidden; + padding: 32px; + position: relative; +} + +.wms-dashboard-hero::after { + background: radial-gradient(circle, rgba(255, 255, 255, 0.18), transparent 62%); + content: ''; + height: 260px; + position: absolute; + right: -80px; + top: -100px; + width: 260px; +} + +.wms-dashboard-hero__eyebrow { + color: rgba(255, 255, 255, 0.72); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.12em; + margin-bottom: 8px; + text-transform: uppercase; +} + +.wms-dashboard-hero__title { + font-size: clamp(2rem, 3vw, 3rem); + font-weight: 800; + margin-bottom: 10px; +} + +.wms-dashboard-hero__text { + color: rgba(255, 255, 255, 0.82); + font-size: 1rem; + margin: 0; + max-width: 760px; +} + +.wms-dashboard-hero__badge { + background: rgba(255, 255, 255, 0.14); + border: 1px solid rgba(255, 255, 255, 0.22); + border-radius: 999px; + font-weight: 700; + padding: 10px 16px; + position: relative; + z-index: 1; + white-space: nowrap; +} + +.wms-metric-card, +.wms-chart-card { + border: 0; + border-radius: 20px; + box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08); + overflow: hidden; +} + +.wms-metric-card__label { + color: #64748b; + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.wms-metric-card__value { + color: #0f172a; + font-size: 2rem; + font-weight: 850; + line-height: 1.1; + margin: 8px 0; +} + +.wms-metric-card__hint { + color: #64748b; + font-size: 0.85rem; +} + +.wms-metric-card__icon { + align-items: center; + background: linear-gradient(135deg, rgba(15, 118, 110, 0.14), rgba(37, 99, 235, 0.12)); + border-radius: 18px; + color: #0f766e; + display: flex; + font-size: 1.35rem; + height: 56px; + justify-content: center; + width: 56px; +} + +.wms-chart-card__header { + background: #ffffff; + border-bottom: 1px solid rgba(148, 163, 184, 0.22); + padding: 22px 24px 12px; +} + +.wms-chart-card__eyebrow { + color: #0f766e; + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.12em; + margin-bottom: 6px; + text-transform: uppercase; +} + +.wms-chart-card__title { + color: #0f172a; + font-size: 1.15rem; + font-weight: 800; + margin: 0 0 8px; +} + +.wms-chart-card__description { + color: #64748b; + font-size: 0.9rem; + margin: 0; +} + +.wms-chart-card__canvas { + min-height: 260px; + position: relative; +} + +@media (max-width: 991.98px) { + .wms-dashboard-hero { + align-items: flex-start; + flex-direction: column; + gap: 18px; + padding: 24px; + } + + .wms-dashboard-hero__badge { + white-space: normal; + } +} diff --git a/resources/js/charts/wms-dashboard-charts.js b/resources/js/charts/wms-dashboard-charts.js new file mode 100644 index 00000000..a67d6b26 --- /dev/null +++ b/resources/js/charts/wms-dashboard-charts.js @@ -0,0 +1,135 @@ +(function () { + 'use strict'; + + function cssVar(name, fallback) { + var value = getComputedStyle(document.documentElement).getPropertyValue(name); + return value && value.trim() !== '' ? value.trim() : fallback; + } + + function palette() { + return [ + cssVar('--wms-chart-primary', '#0f766e'), + cssVar('--wms-chart-secondary', '#2563eb'), + cssVar('--wms-chart-accent', '#f59e0b'), + cssVar('--wms-chart-danger', '#dc2626'), + cssVar('--wms-chart-muted', '#64748b'), + cssVar('--wms-chart-purple', '#7c3aed') + ]; + } + + function enrichDataset(dataset, index, type) { + var colours = palette(); + var colour = colours[index % colours.length]; + + if (type === 'doughnut' || type === 'pie') { + dataset.backgroundColor = colours; + dataset.borderColor = '#ffffff'; + dataset.borderWidth = dataset.borderWidth || 2; + return dataset; + } + + dataset.borderColor = colour; + dataset.backgroundColor = type === 'line' ? 'rgba(15, 118, 110, 0.14)' : colour; + dataset.pointBackgroundColor = colour; + dataset.pointBorderColor = '#ffffff'; + dataset.pointHoverRadius = 6; + dataset.borderWidth = 2; + + return dataset; + } + + function optionsFor(type) { + var textColour = cssVar('--wms-chart-text', '#334155'); + var gridColour = cssVar('--wms-chart-grid', 'rgba(148, 163, 184, 0.25)'); + + var options = { + responsive: true, + maintainAspectRatio: false, + interaction: { + intersect: false, + mode: 'index' + }, + plugins: { + legend: { + position: type === 'doughnut' || type === 'pie' ? 'bottom' : 'top', + labels: { + color: textColour, + boxWidth: 12, + usePointStyle: true, + padding: 18 + } + }, + tooltip: { + backgroundColor: 'rgba(15, 23, 42, 0.94)', + padding: 12, + titleColor: '#ffffff', + bodyColor: '#ffffff', + cornerRadius: 10 + } + } + }; + + if (type !== 'doughnut' && type !== 'pie') { + options.scales = { + x: { + ticks: { + color: textColour + }, + grid: { + display: false + } + }, + y: { + beginAtZero: true, + ticks: { + color: textColour, + precision: 0 + }, + grid: { + color: gridColour + } + } + }; + } + + if (type === 'doughnut') { + options.cutout = '68%'; + } + + return options; + } + + function normaliseConfig(rawConfig, type) { + var config = rawConfig || {}; + config.labels = config.labels || []; + config.datasets = (config.datasets || []).map(function (dataset, index) { + return enrichDataset(dataset, index, type); + }); + + return config; + } + + document.addEventListener('DOMContentLoaded', function () { + if (typeof Chart === 'undefined') { + return; + } + + document.querySelectorAll('[data-wms-chart]').forEach(function (canvas) { + var type = canvas.getAttribute('data-chart-type') || 'line'; + var raw = canvas.getAttribute('data-chart-config') || '{}'; + var config = {}; + + try { + config = JSON.parse(raw); + } catch (error) { + config = { labels: [], datasets: [] }; + } + + new Chart(canvas, { + type: type, + data: normaliseConfig(config, type), + options: optionsFor(type) + }); + }); + }); +}()); diff --git a/resources/views/components/dashboard/chart-card.blade.php b/resources/views/components/dashboard/chart-card.blade.php new file mode 100644 index 00000000..1fd6c0ee --- /dev/null +++ b/resources/views/components/dashboard/chart-card.blade.php @@ -0,0 +1,34 @@ +@props([ + 'title', + 'description' => '', + 'type' => 'line', + 'config' => [], + 'height' => 300, + 'icon' => 'fa-chart-line', +]) + +
+
+
+
Operational insight
+

+ {{ $title }} +

+ @if($description !== '') +

{{ $description }}

+ @endif +
+
+ +
+
+ +
+
+
diff --git a/resources/views/components/dashboard/metric-card.blade.php b/resources/views/components/dashboard/metric-card.blade.php new file mode 100644 index 00000000..c27edbba --- /dev/null +++ b/resources/views/components/dashboard/metric-card.blade.php @@ -0,0 +1,23 @@ +@props([ + 'label', + 'value' => 0, + 'hint' => '', + 'icon' => 'fa-chart-simple', +]) + +
+
+
+
+
{{ $label }}
+
{{ is_numeric($value) ? number_format((int) $value) : $value }}
+ @if($hint !== '') +
{{ $hint }}
+ @endif +
+
+ +
+
+
+