GWM : dashboard redesin
This commit is contained in:
parent
912042e054
commit
5deb9ce809
@ -34,6 +34,7 @@ $routes->group('', ['filter' => 'auth'], static function ($routes) {
|
||||
$routes->post('delete/(:num)', 'Dashboard\DashboardController::delete/$1');
|
||||
$routes->post('pin/(:num)', 'Dashboard\DashboardController::pinToggle/$1');
|
||||
$routes->post('(:num)/layout', 'Dashboard\DashboardController::saveLayout/$1');
|
||||
$routes->get('(:num)/variables', 'Dashboard\DashboardController::variables/$1');
|
||||
$routes->post('(:num)/widget', 'Dashboard\DashboardController::addWidget/$1');
|
||||
$routes->post('widget/(:num)/update', 'Dashboard\DashboardController::updateWidget/$1');
|
||||
$routes->post('widget/(:num)/delete', 'Dashboard\DashboardController::removeWidget/$1');
|
||||
@ -153,6 +154,8 @@ $routes->get('invite/(:segment)', 'Workspace\WorkspaceInvitationController::acce
|
||||
$routes->get('share/(:segment)', 'Share\PublicController::show/$1');
|
||||
$routes->post('share/(:segment)/unlock', 'Share\PublicController::unlock/$1');
|
||||
$routes->post('share/(:segment)/chart/(:num)/data', 'Share\PublicController::chartData/$1/$2');
|
||||
$routes->get('share/(:segment)/dashboard/variables', 'Share\PublicController::dashboardVariables/$1');
|
||||
$routes->get('share/(:segment)/saved-query/(:num)/variables', 'Share\PublicController::savedQueryVariables/$1/$2');
|
||||
|
||||
// API v1 routes
|
||||
$routes->group('api/v1', ['filter' => 'apiauth'], static function ($routes) {
|
||||
|
||||
@ -7,6 +7,7 @@ use App\Libraries\AuditLogger;
|
||||
use App\Models\ChartModel;
|
||||
use App\Models\DashboardModel;
|
||||
use App\Models\DashboardWidgetModel;
|
||||
use App\Models\QueryVariableModel;
|
||||
|
||||
class DashboardController extends BaseController
|
||||
{
|
||||
@ -104,6 +105,7 @@ class DashboardController extends BaseController
|
||||
'chartData' => rtrim(base_url(), '/') . '/chart/',
|
||||
'chartEdit' => rtrim(base_url(), '/') . '/chart/edit/',
|
||||
'chartExportBase' => rtrim(base_url(), '/') . '/chart/',
|
||||
'dashboardVariables' => rtrim(base_url(), '/') . '/dashboard/' . $id . '/variables',
|
||||
'saveLayout' => rtrim(base_url(), '/') . '/dashboard/' . $id . '/layout',
|
||||
'addWidget' => rtrim(base_url(), '/') . '/dashboard/' . $id . '/widget',
|
||||
'delWidget' => rtrim(base_url(), '/') . '/dashboard/widget/',
|
||||
@ -166,6 +168,94 @@ class DashboardController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return merged query variable definitions used by dashboard chart widgets.
|
||||
*/
|
||||
public function variables(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
$dashboard = (new DashboardModel())->findForWorkspace($id, $workspaceId);
|
||||
if (! $dashboard) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Dashboard not found.'])->setStatusCode(404);
|
||||
}
|
||||
|
||||
$widgets = (new DashboardWidgetModel())->forDashboard($id);
|
||||
$queryIds = [];
|
||||
foreach ($widgets as $w) {
|
||||
if (($w['widget_type'] ?? '') !== 'chart') {
|
||||
continue;
|
||||
}
|
||||
$qid = (int) ($w['saved_query_id'] ?? 0);
|
||||
if ($qid > 0) {
|
||||
$queryIds[$qid] = true;
|
||||
}
|
||||
}
|
||||
$queryIds = array_keys($queryIds);
|
||||
if ($queryIds === []) {
|
||||
return $this->response->setJSON(['success' => true, 'variables' => []]);
|
||||
}
|
||||
|
||||
$rows = (new QueryVariableModel())
|
||||
->whereIn('saved_query_id', $queryIds)
|
||||
->where('workspace_id', $workspaceId)
|
||||
->orderBy('saved_query_id', 'ASC')
|
||||
->orderBy('sort_order', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$merged = [];
|
||||
foreach ($rows as $row) {
|
||||
$name = (string) ($row['name'] ?? '');
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$options = [];
|
||||
$optsRaw = $row['options_json'] ?? null;
|
||||
if ($optsRaw !== null && $optsRaw !== '') {
|
||||
$decoded = json_decode((string) $optsRaw, true);
|
||||
if (is_array($decoded)) {
|
||||
$options = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
if (! isset($merged[$name])) {
|
||||
$merged[$name] = [
|
||||
'name' => $name,
|
||||
'label' => (string) ($row['label'] ?? $name),
|
||||
'type' => (string) ($row['type'] ?? 'text'),
|
||||
'default_value' => (string) ($row['default_value'] ?? ''),
|
||||
'is_required' => ! empty($row['is_required']),
|
||||
'options' => $options,
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$merged[$name]['is_required'] = $merged[$name]['is_required'] || ! empty($row['is_required']);
|
||||
if ($merged[$name]['default_value'] === '' && (string) ($row['default_value'] ?? '') !== '') {
|
||||
$merged[$name]['default_value'] = (string) $row['default_value'];
|
||||
}
|
||||
if (($merged[$name]['label'] ?? '') === '' && (string) ($row['label'] ?? '') !== '') {
|
||||
$merged[$name]['label'] = (string) $row['label'];
|
||||
}
|
||||
if (($merged[$name]['type'] ?? 'text') === 'text' && (string) ($row['type'] ?? 'text') !== 'text') {
|
||||
$merged[$name]['type'] = (string) $row['type'];
|
||||
}
|
||||
if (is_array($options) && $options !== []) {
|
||||
$existing = is_array($merged[$name]['options']) ? $merged[$name]['options'] : [];
|
||||
foreach ($options as $opt) {
|
||||
if (! in_array($opt, $existing, true)) {
|
||||
$existing[] = $opt;
|
||||
}
|
||||
}
|
||||
$merged[$name]['options'] = $existing;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'variables' => array_values($merged),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateSettings(int $id)
|
||||
{
|
||||
$workspaceId = (int) $this->session->get('active_workspace_id');
|
||||
|
||||
@ -9,6 +9,7 @@ use App\Models\ChartModel;
|
||||
use App\Models\DashboardModel;
|
||||
use App\Models\DashboardWidgetModel;
|
||||
use App\Models\DataSourceModel;
|
||||
use App\Models\QueryVariableModel;
|
||||
use App\Models\SavedQueryModel;
|
||||
use App\Models\SharedLinkModel;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
@ -180,6 +181,163 @@ class PublicController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
public function dashboardVariables(string $token)
|
||||
{
|
||||
$this->applyEmbedHeaders();
|
||||
$model = new SharedLinkModel();
|
||||
$link = $model->findActiveByToken($token);
|
||||
if (! $link || ($link['type'] ?? '') !== 'dashboard') {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Invalid or expired link.'])->setStatusCode(403);
|
||||
}
|
||||
|
||||
$hash = $link['password_hash'] ?? null;
|
||||
if ($hash && ! session()->get('share_unlocked_' . $token)) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Password required.'])->setStatusCode(403);
|
||||
}
|
||||
|
||||
$dashId = (int) ($link['resource_id'] ?? 0);
|
||||
$workspaceId = (int) ($link['workspace_id'] ?? 0);
|
||||
if ($dashId <= 0 || $workspaceId <= 0) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Invalid dashboard.'])->setStatusCode(422);
|
||||
}
|
||||
|
||||
$widgets = (new DashboardWidgetModel())->forDashboard($dashId);
|
||||
$queryIds = [];
|
||||
foreach ($widgets as $w) {
|
||||
if (($w['widget_type'] ?? '') !== 'chart') {
|
||||
continue;
|
||||
}
|
||||
$qid = (int) ($w['saved_query_id'] ?? 0);
|
||||
if ($qid > 0) {
|
||||
$queryIds[$qid] = true;
|
||||
}
|
||||
}
|
||||
$queryIds = array_keys($queryIds);
|
||||
if ($queryIds === []) {
|
||||
return $this->response->setJSON(['success' => true, 'variables' => []]);
|
||||
}
|
||||
|
||||
$rows = (new QueryVariableModel())
|
||||
->whereIn('saved_query_id', $queryIds)
|
||||
->where('workspace_id', $workspaceId)
|
||||
->orderBy('saved_query_id', 'ASC')
|
||||
->orderBy('sort_order', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$merged = [];
|
||||
foreach ($rows as $row) {
|
||||
$name = (string) ($row['name'] ?? '');
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$options = [];
|
||||
$optsRaw = $row['options_json'] ?? null;
|
||||
if ($optsRaw !== null && $optsRaw !== '') {
|
||||
$decoded = json_decode((string) $optsRaw, true);
|
||||
if (is_array($decoded)) {
|
||||
$options = $decoded;
|
||||
}
|
||||
}
|
||||
if (! isset($merged[$name])) {
|
||||
$merged[$name] = [
|
||||
'name' => $name,
|
||||
'label' => (string) ($row['label'] ?? $name),
|
||||
'type' => (string) ($row['type'] ?? 'text'),
|
||||
'default_value' => (string) ($row['default_value'] ?? ''),
|
||||
'is_required' => ! empty($row['is_required']),
|
||||
'options' => $options,
|
||||
];
|
||||
continue;
|
||||
}
|
||||
$merged[$name]['is_required'] = $merged[$name]['is_required'] || ! empty($row['is_required']);
|
||||
if ($merged[$name]['default_value'] === '' && (string) ($row['default_value'] ?? '') !== '') {
|
||||
$merged[$name]['default_value'] = (string) $row['default_value'];
|
||||
}
|
||||
if (($merged[$name]['label'] ?? '') === '' && (string) ($row['label'] ?? '') !== '') {
|
||||
$merged[$name]['label'] = (string) $row['label'];
|
||||
}
|
||||
if (($merged[$name]['type'] ?? 'text') === 'text' && (string) ($row['type'] ?? 'text') !== 'text') {
|
||||
$merged[$name]['type'] = (string) $row['type'];
|
||||
}
|
||||
if (is_array($options) && $options !== []) {
|
||||
$existing = is_array($merged[$name]['options']) ? $merged[$name]['options'] : [];
|
||||
foreach ($options as $opt) {
|
||||
if (! in_array($opt, $existing, true)) {
|
||||
$existing[] = $opt;
|
||||
}
|
||||
}
|
||||
$merged[$name]['options'] = $existing;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'variables' => array_values($merged),
|
||||
]);
|
||||
}
|
||||
|
||||
public function savedQueryVariables(string $token, int $savedQueryId)
|
||||
{
|
||||
$this->applyEmbedHeaders();
|
||||
$model = new SharedLinkModel();
|
||||
$link = $model->findActiveByToken($token);
|
||||
if (! $link || ($link['type'] ?? '') !== 'dashboard') {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Invalid or expired link.'])->setStatusCode(403);
|
||||
}
|
||||
|
||||
$hash = $link['password_hash'] ?? null;
|
||||
if ($hash && ! session()->get('share_unlocked_' . $token)) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Password required.'])->setStatusCode(403);
|
||||
}
|
||||
|
||||
$dashId = (int) ($link['resource_id'] ?? 0);
|
||||
$workspaceId = (int) ($link['workspace_id'] ?? 0);
|
||||
if ($dashId <= 0 || $workspaceId <= 0 || $savedQueryId <= 0) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Invalid request.'])->setStatusCode(422);
|
||||
}
|
||||
|
||||
$widgets = (new DashboardWidgetModel())->forDashboard($dashId);
|
||||
$allowed = false;
|
||||
foreach ($widgets as $w) {
|
||||
if (($w['widget_type'] ?? '') !== 'chart') {
|
||||
continue;
|
||||
}
|
||||
if ((int) ($w['saved_query_id'] ?? 0) === $savedQueryId) {
|
||||
$allowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (! $allowed) {
|
||||
return $this->response->setJSON(['success' => false, 'message' => 'Query not in shared dashboard.'])->setStatusCode(403);
|
||||
}
|
||||
|
||||
$rows = (new QueryVariableModel())
|
||||
->where('saved_query_id', $savedQueryId)
|
||||
->where('workspace_id', $workspaceId)
|
||||
->orderBy('sort_order', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$vars = [];
|
||||
foreach ($rows as $row) {
|
||||
$optsRaw = $row['options_json'] ?? null;
|
||||
$options = [];
|
||||
if ($optsRaw !== null && $optsRaw !== '') {
|
||||
$decoded = json_decode((string) $optsRaw, true);
|
||||
$options = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
$vars[] = [
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
'label' => (string) ($row['label'] ?? ''),
|
||||
'type' => (string) ($row['type'] ?? 'text'),
|
||||
'default_value' => (string) ($row['default_value'] ?? ''),
|
||||
'is_required' => ! empty($row['is_required']),
|
||||
'options' => $options,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->response->setJSON(['success' => true, 'variables' => $vars]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $link
|
||||
* @return string
|
||||
@ -234,10 +392,11 @@ class PublicController extends BaseController
|
||||
'charts' => [],
|
||||
'urls' => [
|
||||
'chartData' => rtrim(base_url(), '/') . '/share/' . $token . '/chart/',
|
||||
'dashboardVariables' => rtrim(base_url(), '/') . '/share/' . $token . '/dashboard/variables',
|
||||
'saveLayout' => '',
|
||||
'addWidget' => '',
|
||||
'delWidget' => '',
|
||||
'savedQueryVars' => '',
|
||||
'savedQueryVars' => rtrim(base_url(), '/') . '/share/' . $token . '/saved-query/',
|
||||
],
|
||||
'csrf' => [
|
||||
'name' => '',
|
||||
|
||||
@ -145,7 +145,7 @@
|
||||
return rows.filter((r) => cols.some((c) => lower(r && r[c] != null ? r[c] : '').includes(q)));
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
const render = (focusState) => {
|
||||
const filtered = buildFiltered();
|
||||
const total = filtered.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / state.perPage));
|
||||
@ -199,9 +199,11 @@
|
||||
const searchEl = el.querySelector('[data-cb-table-search]');
|
||||
if (searchEl) {
|
||||
searchEl.addEventListener('input', (ev) => {
|
||||
const selStart = Number.isInteger(ev.target.selectionStart) ? ev.target.selectionStart : null;
|
||||
const selEnd = Number.isInteger(ev.target.selectionEnd) ? ev.target.selectionEnd : selStart;
|
||||
state.q = ev.target.value || '';
|
||||
state.page = 1;
|
||||
render();
|
||||
render({ focusSearch: true, selStart, selEnd });
|
||||
});
|
||||
}
|
||||
const perPageEl = el.querySelector('[data-cb-table-per-page]');
|
||||
@ -222,6 +224,18 @@
|
||||
if (csvBtn) csvBtn.addEventListener('click', () => exportRowsAsCsv('table-preview.csv', cols, filtered));
|
||||
const excelBtn = el.querySelector('[data-cb-table-export="excel"]');
|
||||
if (excelBtn) excelBtn.addEventListener('click', () => exportRowsAsCsv('table-preview-excel.csv', cols, filtered));
|
||||
if (focusState && focusState.focusSearch) {
|
||||
const nextSearch = el.querySelector('[data-cb-table-search]');
|
||||
if (nextSearch) {
|
||||
nextSearch.focus();
|
||||
if (focusState.selStart != null && focusState.selEnd != null && typeof nextSearch.setSelectionRange === 'function') {
|
||||
const len = String(nextSearch.value || '').length;
|
||||
const s = Math.max(0, Math.min(len, focusState.selStart));
|
||||
const e = Math.max(s, Math.min(len, focusState.selEnd));
|
||||
nextSearch.setSelectionRange(s, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render();
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
|
||||
let CB_CSRF = boot.csrf || { name: '', hash: '' };
|
||||
const globalVars = {};
|
||||
let dashVarDefs = [];
|
||||
let grid = null;
|
||||
let editing = false;
|
||||
let refreshTimer = null;
|
||||
@ -23,11 +24,75 @@
|
||||
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
function prettyHeaderName(raw) {
|
||||
const t = String(raw ?? '').trim();
|
||||
if (!t) return '';
|
||||
return t
|
||||
.replace(/[_\-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\b\w/g, (m) => m.toUpperCase());
|
||||
}
|
||||
|
||||
function exportRowsAsCsv(filename, cols, rows) {
|
||||
const esc = (v) => {
|
||||
const s = String(v ?? '');
|
||||
if (/[",\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
|
||||
return s;
|
||||
};
|
||||
const lines = [];
|
||||
lines.push(cols.map((c) => esc(prettyHeaderName(c))).join(','));
|
||||
rows.forEach((r) => {
|
||||
lines.push(cols.map((c) => esc(r && r[c] != null ? r[c] : '')).join(','));
|
||||
});
|
||||
const blob = new Blob(["\uFEFF" + lines.join('\n')], { type: 'text/csv;charset=utf-8' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
function patchCsrf(j) {
|
||||
if (!j || !j.csrf) return;
|
||||
CB_CSRF = j.csrf;
|
||||
}
|
||||
|
||||
function readUrlVars() {
|
||||
const out = {};
|
||||
try {
|
||||
const usp = new URLSearchParams(window.location.search || '');
|
||||
usp.forEach((v, k) => {
|
||||
if (!k.startsWith('var_')) return;
|
||||
out[k.slice(4)] = v;
|
||||
});
|
||||
} catch (e) { /* ignore */ }
|
||||
return out;
|
||||
}
|
||||
|
||||
function writeUrlVars() {
|
||||
if (!window.history || !window.history.replaceState) return;
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
Array.from(url.searchParams.keys()).forEach((k) => {
|
||||
if (k.startsWith('var_')) url.searchParams.delete(k);
|
||||
});
|
||||
Object.keys(globalVars).forEach((k) => {
|
||||
const v = globalVars[k];
|
||||
if (v === null || v === undefined) return;
|
||||
if (Array.isArray(v)) {
|
||||
if (!v.length) return;
|
||||
url.searchParams.set('var_' + k, v.join(','));
|
||||
return;
|
||||
}
|
||||
const s = String(v).trim();
|
||||
if (s !== '') url.searchParams.set('var_' + k, s);
|
||||
});
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function formatAxisNumber(val, fmt, dec) {
|
||||
const n = Number(val);
|
||||
if (Number.isNaN(n)) return val;
|
||||
@ -116,16 +181,102 @@
|
||||
if (payload.engine === 'table') {
|
||||
const cols = payload.columns || [];
|
||||
const rows = payload.rows || [];
|
||||
let html = '<div class="table-responsive"><table class="table table-sm mb-0"><thead><tr>';
|
||||
cols.forEach((c) => { html += '<th>' + escapeHtml(c) + '</th>'; });
|
||||
html += '</tr></thead><tbody>';
|
||||
rows.forEach((r) => {
|
||||
html += '<tr>';
|
||||
cols.forEach((c) => { html += '<td>' + escapeHtml(r[c] != null ? String(r[c]) : '') + '</td>'; });
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
el.innerHTML = html;
|
||||
const state = { q: '', page: 1, perPage: 10 };
|
||||
const lower = (s) => String(s ?? '').toLowerCase();
|
||||
const buildFiltered = () => {
|
||||
if (!state.q) return rows.slice();
|
||||
const q = lower(state.q);
|
||||
return rows.filter((r) => cols.some((c) => lower(r && r[c] != null ? r[c] : '').includes(q)));
|
||||
};
|
||||
|
||||
const renderTable = (focusState) => {
|
||||
const filtered = buildFiltered();
|
||||
const total = filtered.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / state.perPage));
|
||||
if (state.page > totalPages) state.page = totalPages;
|
||||
if (state.page < 1) state.page = 1;
|
||||
const start = (state.page - 1) * state.perPage;
|
||||
const paged = filtered.slice(start, start + state.perPage);
|
||||
|
||||
let html = '<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between mb-2">';
|
||||
html += '<div class="d-flex gap-2 align-items-center">';
|
||||
html += '<input type="search" class="form-control form-control-sm" style="min-width:220px;" placeholder="Search..." data-cb-table-search value="' + escapeHtml(state.q) + '">';
|
||||
html += '<select class="form-select form-select-sm" style="width:86px;" data-cb-table-per-page>';
|
||||
[10, 25, 50, 100].forEach((n) => {
|
||||
html += '<option value="' + n + '"' + (state.perPage === n ? ' selected' : '') + '>' + n + '</option>';
|
||||
});
|
||||
html += '</select><span class="small text-muted">rows / page</span></div>';
|
||||
html += '<div class="d-flex gap-2 align-items-center">';
|
||||
html += '<button type="button" class="btn btn-outline-secondary btn-sm" data-cb-table-export="csv">Export CSV</button>';
|
||||
html += '<button type="button" class="btn btn-outline-secondary btn-sm" data-cb-table-export="excel">Export Excel</button>';
|
||||
html += '</div></div>';
|
||||
|
||||
html += '<div class="cb-dash-table-wrap table-responsive"><table class="table table-sm mb-0"><thead><tr>';
|
||||
cols.forEach((c) => { html += '<th>' + escapeHtml(prettyHeaderName(c)) + '</th>'; });
|
||||
html += '</tr></thead><tbody>';
|
||||
if (!paged.length) {
|
||||
html += '<tr><td class="text-muted small" colspan="' + Math.max(cols.length, 1) + '">No matching rows.</td></tr>';
|
||||
} else {
|
||||
paged.forEach((r) => {
|
||||
html += '<tr>';
|
||||
cols.forEach((c) => { html += '<td>' + escapeHtml(r && r[c] != null ? String(r[c]) : '') + '</td>'; });
|
||||
html += '</tr>';
|
||||
});
|
||||
}
|
||||
html += '</tbody></table></div>';
|
||||
|
||||
const from = total === 0 ? 0 : start + 1;
|
||||
const to = Math.min(start + state.perPage, total);
|
||||
html += '<div class="d-flex align-items-center justify-content-between mt-2">';
|
||||
html += '<span class="small text-muted">Showing ' + from + ' to ' + to + ' of ' + total + ' entries</span>';
|
||||
html += '<div class="btn-group btn-group-sm">';
|
||||
html += '<button type="button" class="btn btn-outline-secondary" data-cb-table-page="prev"' + (state.page <= 1 ? ' disabled' : '') + '>Prev</button>';
|
||||
html += '<button type="button" class="btn btn-outline-secondary disabled">Page ' + state.page + ' / ' + totalPages + '</button>';
|
||||
html += '<button type="button" class="btn btn-outline-secondary" data-cb-table-page="next"' + (state.page >= totalPages ? ' disabled' : '') + '>Next</button>';
|
||||
html += '</div></div>';
|
||||
|
||||
el.innerHTML = html;
|
||||
el.classList.add('cb-dash-table-host');
|
||||
|
||||
el.querySelector('[data-cb-table-search]')?.addEventListener('input', (ev) => {
|
||||
const selStart = Number.isInteger(ev.target.selectionStart) ? ev.target.selectionStart : null;
|
||||
const selEnd = Number.isInteger(ev.target.selectionEnd) ? ev.target.selectionEnd : selStart;
|
||||
state.q = ev.target.value || '';
|
||||
state.page = 1;
|
||||
renderTable({ focusSearch: true, selStart, selEnd });
|
||||
});
|
||||
el.querySelector('[data-cb-table-per-page]')?.addEventListener('change', (ev) => {
|
||||
const next = Number(ev.target.value || 10);
|
||||
state.perPage = [10, 25, 50, 100].includes(next) ? next : 10;
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
});
|
||||
el.querySelector('[data-cb-table-page="prev"]')?.addEventListener('click', () => {
|
||||
state.page -= 1;
|
||||
renderTable();
|
||||
});
|
||||
el.querySelector('[data-cb-table-page="next"]')?.addEventListener('click', () => {
|
||||
state.page += 1;
|
||||
renderTable();
|
||||
});
|
||||
el.querySelector('[data-cb-table-export="csv"]')?.addEventListener('click', () => exportRowsAsCsv('dashboard-table.csv', cols, filtered));
|
||||
el.querySelector('[data-cb-table-export="excel"]')?.addEventListener('click', () => exportRowsAsCsv('dashboard-table-excel.csv', cols, filtered));
|
||||
|
||||
if (focusState && focusState.focusSearch) {
|
||||
const nextSearch = el.querySelector('[data-cb-table-search]');
|
||||
if (nextSearch) {
|
||||
nextSearch.focus();
|
||||
if (focusState.selStart != null && focusState.selEnd != null && typeof nextSearch.setSelectionRange === 'function') {
|
||||
const len = String(nextSearch.value || '').length;
|
||||
const s = Math.max(0, Math.min(len, focusState.selStart));
|
||||
const e = Math.max(s, Math.min(len, focusState.selEnd));
|
||||
nextSearch.setSelectionRange(s, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
renderTable();
|
||||
return;
|
||||
}
|
||||
if (payload.engine === 'kpi') {
|
||||
@ -156,11 +307,20 @@
|
||||
}
|
||||
|
||||
function setGlobalVar(key, val) {
|
||||
globalVars[String(key)] = val;
|
||||
const k = String(key).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
const normKey = String(key || '').trim();
|
||||
if (!normKey) return;
|
||||
globalVars[normKey] = val;
|
||||
const k = normKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
document.querySelectorAll('[data-cb-var-key="' + k + '"]').forEach((inp) => {
|
||||
if (inp && inp.value !== undefined && document.activeElement !== inp) inp.value = val;
|
||||
if (!inp || document.activeElement === inp) return;
|
||||
if (inp.tagName === 'SELECT' && inp.multiple && Array.isArray(val)) {
|
||||
const sv = new Set(val.map((x) => String(x)));
|
||||
Array.from(inp.options).forEach((o) => { o.selected = sv.has(String(o.value)); });
|
||||
return;
|
||||
}
|
||||
if (inp.value !== undefined) inp.value = Array.isArray(val) ? val.join(',') : val;
|
||||
});
|
||||
writeUrlVars();
|
||||
scheduleRefreshCharts();
|
||||
}
|
||||
|
||||
@ -209,16 +369,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVariableDefs(savedQueryId) {
|
||||
if (!savedQueryId || !boot.urls || !boot.urls.savedQueryVars) return [];
|
||||
try {
|
||||
const r = await fetch(boot.urls.savedQueryVars + savedQueryId + '/variables', { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
const j = await r.json();
|
||||
if (!j.success) return [];
|
||||
return j.variables || [];
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
|
||||
function renderMarkdown(el, md) {
|
||||
if (!el || !md) { el.innerHTML = ''; return; }
|
||||
if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') {
|
||||
@ -229,35 +379,121 @@
|
||||
el.innerHTML = DOMPurify.sanitize(raw, { USE_PROFILES: { html: true } });
|
||||
}
|
||||
|
||||
function buildVarInput(def) {
|
||||
function renderVarInput(def) {
|
||||
const k = def.name;
|
||||
const lab = def.label || def.name;
|
||||
const req = def.is_required ? ' required' : '';
|
||||
if (globalVars[k] === undefined && def.default_value != null) globalVars[k] = String(def.default_value);
|
||||
const current = globalVars[k] ?? '';
|
||||
const labelHtml = '<label class="cb-dash-var-label">' + escapeHtml(lab) + '</label>';
|
||||
|
||||
if (def.type === 'number') {
|
||||
return '<label class="form-label small mb-0">' + escapeHtml(lab) + '</label><input type="number" class="form-control form-control-sm" data-cb-var-key="' + escapeHtml(k) + '" value="' + escapeHtml(globalVars[k] ?? '') + '"' + req + '>';
|
||||
return '<div class="cb-dash-var-item">' + labelHtml + '<input type="number" class="form-control form-control-sm" data-cb-var-key="' + escapeHtml(k) + '" value="' + escapeHtml(current) + '"' + req + '></div>';
|
||||
}
|
||||
if (def.type === 'date') {
|
||||
return '<label class="form-label small mb-0">' + escapeHtml(lab) + '</label><input type="date" class="form-control form-control-sm" data-cb-var-key="' + escapeHtml(k) + '" value="' + escapeHtml(globalVars[k] ?? '') + '"' + req + '>';
|
||||
return '<div class="cb-dash-var-item">' + labelHtml + '<input type="date" class="form-control form-control-sm" data-cb-var-key="' + escapeHtml(k) + '" value="' + escapeHtml(current) + '"' + req + '></div>';
|
||||
}
|
||||
if (def.type === 'date_range') {
|
||||
const parts = String(globalVars[k] || '').split('|');
|
||||
return '<label class="form-label small mb-0">' + escapeHtml(lab) + '</label><div class="d-flex gap-1"><input type="date" class="form-control form-control-sm" data-cb-var-range="start" data-cb-var-parent="' + escapeHtml(k) + '" value="' + escapeHtml(parts[0] || '') + '"><input type="date" class="form-control form-control-sm" data-cb-var-range="end" data-cb-var-parent="' + escapeHtml(k) + '" value="' + escapeHtml(parts[1] || '') + '"></div>';
|
||||
const parts = String(current || '').split('|');
|
||||
return '<div class="cb-dash-var-item">' + labelHtml + '<div class="cb-dash-var-range"><input type="date" class="form-control form-control-sm" data-cb-var-range="start" data-cb-var-parent="' + escapeHtml(k) + '" value="' + escapeHtml(parts[0] || '') + '"><input type="date" class="form-control form-control-sm" data-cb-var-range="end" data-cb-var-parent="' + escapeHtml(k) + '" value="' + escapeHtml(parts[1] || '') + '"></div></div>';
|
||||
}
|
||||
if (def.type === 'select' || def.type === 'multi_select') {
|
||||
const opts = Array.isArray(def.options) ? def.options : [];
|
||||
let h = '<label class="form-label small mb-0">' + escapeHtml(lab) + '</label><select class="form-select form-select-sm" data-cb-var-key="' + escapeHtml(k) + '"' + (def.type === 'multi_select' ? ' multiple' : '') + '>';
|
||||
const currentSet = def.type === 'multi_select'
|
||||
? new Set((Array.isArray(current) ? current : String(current).split(',')).map((x) => String(x).trim()).filter(Boolean))
|
||||
: null;
|
||||
let h = '<div class="cb-dash-var-item">' + labelHtml + '<select class="form-select form-select-sm" data-cb-var-key="' + escapeHtml(k) + '"' + (def.type === 'multi_select' ? ' multiple' : '') + '>';
|
||||
if (def.type === 'select') h += '<option value="">—</option>';
|
||||
opts.forEach((o) => {
|
||||
const v = typeof o === 'object' ? (o.value ?? o.label) : o;
|
||||
const t = typeof o === 'object' ? (o.label ?? o.value) : o;
|
||||
h += '<option value="' + escapeHtml(String(v)) + '">' + escapeHtml(String(t)) + '</option>';
|
||||
const vv = String(v);
|
||||
const sel = def.type === 'multi_select'
|
||||
? (currentSet && currentSet.has(vv) ? ' selected' : '')
|
||||
: (String(current) === vv ? ' selected' : '');
|
||||
h += '<option value="' + escapeHtml(vv) + '"' + sel + '>' + escapeHtml(String(t)) + '</option>';
|
||||
});
|
||||
h += '</select>';
|
||||
h += '</select></div>';
|
||||
return h;
|
||||
}
|
||||
return '<label class="form-label small mb-0">' + escapeHtml(lab) + '</label><input type="text" class="form-control form-control-sm" data-cb-var-key="' + escapeHtml(k) + '" value="' + escapeHtml(globalVars[k] ?? '') + '"' + req + '>';
|
||||
return '<div class="cb-dash-var-item">' + labelHtml + '<input type="text" class="form-control form-control-sm" data-cb-var-key="' + escapeHtml(k) + '" value="' + escapeHtml(current) + '"' + req + '></div>';
|
||||
}
|
||||
|
||||
async function loadDashboardVariables() {
|
||||
if (!boot.urls || !boot.urls.dashboardVariables) return [];
|
||||
try {
|
||||
const r = await fetch(boot.urls.dashboardVariables, {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!j.success) return [];
|
||||
return Array.isArray(j.variables) ? j.variables : [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVariableDefs(savedQueryId) {
|
||||
if (!savedQueryId || !boot.urls || !boot.urls.savedQueryVars) return [];
|
||||
try {
|
||||
const r = await fetch(boot.urls.savedQueryVars + savedQueryId + '/variables', {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!j.success) return [];
|
||||
return Array.isArray(j.variables) ? j.variables : [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function bindVarInputs(root) {
|
||||
if (!root) return;
|
||||
root.querySelectorAll('[data-cb-var-key]').forEach((inp) => {
|
||||
const key = inp.getAttribute('data-cb-var-key');
|
||||
const apply = () => {
|
||||
if (!inp.multiple) {
|
||||
setGlobalVar(key, inp.value);
|
||||
return;
|
||||
}
|
||||
const vals = Array.from(inp.selectedOptions || []).map((o) => o.value);
|
||||
setGlobalVar(key, vals);
|
||||
};
|
||||
inp.addEventListener('change', apply);
|
||||
inp.addEventListener('input', apply);
|
||||
});
|
||||
root.querySelectorAll('[data-cb-var-range]').forEach((inp) => {
|
||||
inp.addEventListener('change', () => {
|
||||
const parent = inp.getAttribute('data-cb-var-parent');
|
||||
const start = root.querySelector('[data-cb-var-range="start"][data-cb-var-parent="' + parent + '"]')?.value || '';
|
||||
const end = root.querySelector('[data-cb-var-range="end"][data-cb-var-parent="' + parent + '"]')?.value || '';
|
||||
setGlobalVar(parent, start + '|' + end);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderDashboardVariablePanel() {
|
||||
const host = document.getElementById('cbDashGlobalVars');
|
||||
const btn = document.getElementById('cbBtnVarsModal');
|
||||
if (!host) return;
|
||||
if (READ_ONLY) {
|
||||
if (btn) btn.classList.add('d-none');
|
||||
host.classList.add('d-none');
|
||||
host.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
if (!dashVarDefs.length) {
|
||||
if (btn) btn.classList.add('d-none');
|
||||
host.classList.add('d-none');
|
||||
host.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
if (btn) btn.classList.remove('d-none');
|
||||
host.classList.remove('d-none');
|
||||
host.innerHTML = dashVarDefs.map((v) => renderVarInput(v)).join('');
|
||||
bindVarInputs(host);
|
||||
}
|
||||
|
||||
function sanitizeFileName(raw) {
|
||||
@ -538,45 +774,23 @@
|
||||
const exportExcel = canExcel
|
||||
? '<a class="cb-dash-widget-export" href="' + escapeHtml(excelHref) + '" title="Export Excel" aria-label="Export Excel" target="_blank" rel="noopener noreferrer"><span class="material-symbols-outlined" style="font-size:18px;">table_view</span></a>'
|
||||
: '';
|
||||
const chartActions = exportPng + exportPdf + exportExcel + chartEditLink + rm;
|
||||
const vars = await loadVariableDefs(w.saved_query_id);
|
||||
let varsHtml = '';
|
||||
if (vars.length) {
|
||||
varsHtml = '<div class="cb-dash-vars px-2 pt-1">' + vars.map(renderVarInput).join('') + '</div>';
|
||||
let varWarnHtml = '';
|
||||
if (READ_ONLY && w.saved_query_id) {
|
||||
const vars = await loadVariableDefs(w.saved_query_id);
|
||||
if (Array.isArray(vars) && vars.length) {
|
||||
varWarnHtml = '<span class="cb-dash-widget-var-hint" title="This widget uses URL variables (var_*)" aria-label="Widget uses URL variables"><span class="material-symbols-outlined" aria-hidden="true">warning</span></span>';
|
||||
}
|
||||
}
|
||||
const chartActions = varWarnHtml + exportPng + exportPdf + exportExcel + chartEditLink + rm;
|
||||
mount.innerHTML =
|
||||
'<div class="cb-dash-widget-card h-100">' +
|
||||
'<div class="cb-dash-widget-head"><span class="text-truncate">' + escapeHtml(title) + '</span><div class="cb-dash-widget-head-actions">' + chartActions + '</div></div>' +
|
||||
'<div class="cb-dash-widget-body">' +
|
||||
varsHtml +
|
||||
'<div class="cb-dash-chart-inner position-relative">' +
|
||||
'<div id="cb-chart-' + w.id + '" class="w-100" style="min-height:220px;"></div>' +
|
||||
'<div class="cb-dash-loading" id="cb-load-' + w.id + '">Loading…</div>' +
|
||||
'</div></div></div>';
|
||||
|
||||
mount.querySelectorAll('[data-cb-var-key]').forEach((inp) => {
|
||||
const key = inp.getAttribute('data-cb-var-key');
|
||||
inp.addEventListener('change', () => setGlobalVar(key, inp.value));
|
||||
inp.addEventListener('input', () => setGlobalVar(key, inp.value));
|
||||
});
|
||||
mount.querySelectorAll('[data-cb-var-range]').forEach((inp) => {
|
||||
inp.addEventListener('change', () => {
|
||||
const parent = inp.getAttribute('data-cb-var-parent');
|
||||
const box = mount.querySelector('[data-cb-var-parent="' + parent + '"]')?.closest('.cb-dash-vars');
|
||||
if (!box) return;
|
||||
const start = box.querySelector('[data-cb-var-range="start"]')?.value || '';
|
||||
const end = box.querySelector('[data-cb-var-range="end"]')?.value || '';
|
||||
setGlobalVar(parent, start + '|' + end);
|
||||
});
|
||||
});
|
||||
mount.querySelectorAll('select[multiple][data-cb-var-key]').forEach((sel) => {
|
||||
const key = sel.getAttribute('data-cb-var-key');
|
||||
sel.addEventListener('change', () => {
|
||||
const vals = Array.from(sel.selectedOptions).map((o) => o.value);
|
||||
setGlobalVar(key, vals);
|
||||
});
|
||||
});
|
||||
|
||||
await loadChartData(w);
|
||||
mount.querySelector('.cb-dash-export-png')?.addEventListener('click', () => exportChartPng(w));
|
||||
mount.querySelector('.cb-dash-export-pdf')?.addEventListener('click', () => exportChartPdf(w));
|
||||
@ -611,16 +825,28 @@
|
||||
const cfg = w.widget_config || {};
|
||||
const sv = cfg.start_var || 'date_from';
|
||||
const ev = cfg.end_var || 'date_to';
|
||||
const overlayHtml = (!READ_ONLY)
|
||||
? ('<div class="cb-dash-widget-filter-actions--overlay">' + actions + '</div>')
|
||||
: '';
|
||||
mount.innerHTML =
|
||||
'<div class="cb-dash-widget-card h-100">' +
|
||||
'<div class="cb-dash-widget-head"><span class="text-truncate">' + escapeHtml(title) + '</span><div class="cb-dash-widget-head-actions">' + actions + '</div></div>' +
|
||||
'<div class="cb-dash-widget-body">' +
|
||||
'<div class="cb-dash-filter-label">Date range → queries</div>' +
|
||||
'<div class="row g-2">' +
|
||||
'<div class="col-6"><label class="small text-muted">Start <code>' + escapeHtml(sv) + '</code></label><input type="date" class="form-control form-control-sm cb-fdate" data-fk="' + escapeHtml(sv) + '"></div>' +
|
||||
'<div class="col-6"><label class="small text-muted">End <code>' + escapeHtml(ev) + '</code></label><input type="date" class="form-control form-control-sm cb-fdate" data-fk="' + escapeHtml(ev) + '"></div>' +
|
||||
'</div></div></div>';
|
||||
'<div class="cb-dash-widget-card cb-dash-widget-card--filter-date h-100">' +
|
||||
overlayHtml +
|
||||
'<div class="cb-dash-widget-body cb-dash-widget-body--filter-date">' +
|
||||
'<div class="cb-dash-filter-title">Date Range</div>' +
|
||||
'<div class="cb-dash-filter-row cb-dash-filter-row--compact">' +
|
||||
'<div class="cb-dash-filter-col cb-dash-filter-col--compact">' +
|
||||
'<label class="small text-muted mb-1">Start</label>' +
|
||||
'<input type="date" class="form-control form-control-sm cb-fdate cb-fdate--compact" data-fk="' + escapeHtml(sv) + '">' +
|
||||
'</div>' +
|
||||
'<div class="cb-dash-filter-col cb-dash-filter-col--compact">' +
|
||||
'<label class="small text-muted mb-1">End</label>' +
|
||||
'<input type="date" class="form-control form-control-sm cb-fdate cb-fdate--compact" data-fk="' + escapeHtml(ev) + '">' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div></div>';
|
||||
mount.querySelectorAll('.cb-fdate').forEach((inp) => {
|
||||
const fk = inp.getAttribute('data-fk');
|
||||
if (fk && globalVars[fk]) inp.value = String(globalVars[fk]);
|
||||
inp.addEventListener('change', () => {
|
||||
const fk = inp.getAttribute('data-fk');
|
||||
globalVars[fk] = inp.value;
|
||||
@ -635,12 +861,16 @@
|
||||
const vn = cfg.var_name || 'filter';
|
||||
const opts = Array.isArray(cfg.options) ? cfg.options : [];
|
||||
let optsHtml = opts.map((o) => '<option value="' + escapeHtml(String(o)) + '">' + escapeHtml(String(o)) + '</option>').join('');
|
||||
const overlayHtml = (!READ_ONLY)
|
||||
? ('<div class="cb-dash-widget-filter-actions--overlay">' + actions + '</div>')
|
||||
: '';
|
||||
mount.innerHTML =
|
||||
'<div class="cb-dash-widget-card h-100">' +
|
||||
'<div class="cb-dash-widget-head"><span class="text-truncate">' + escapeHtml(title) + '</span><div class="cb-dash-widget-head-actions">' + actions + '</div></div>' +
|
||||
'<div class="cb-dash-widget-body">' +
|
||||
'<div class="cb-dash-filter-label">Dropdown · <code>' + escapeHtml(vn) + '</code></div>' +
|
||||
'<select class="form-select form-select-sm cb-fdrop" data-fk="' + escapeHtml(vn) + '"><option value="">All</option>' + optsHtml + '</select>' +
|
||||
'<div class="cb-dash-widget-card cb-dash-widget-card--filter-dropdown h-100">' +
|
||||
overlayHtml +
|
||||
'<div class="cb-dash-widget-body cb-dash-widget-body--filter-dropdown">' +
|
||||
'<div class="cb-dash-filter-title">Dropdown</div>' +
|
||||
'<label class="small text-muted mb-1">Select</label>' +
|
||||
'<select class="form-select form-select-sm cb-fdrop cb-fdrop--compact" data-fk="' + escapeHtml(vn) + '"><option value="">All</option>' + optsHtml + '</select>' +
|
||||
'</div></div>';
|
||||
const sel = mount.querySelector('.cb-fdrop');
|
||||
if (sel) sel.addEventListener('change', () => {
|
||||
@ -859,7 +1089,7 @@
|
||||
if (gs) gs.classList.toggle('cb-dash-editing', editing);
|
||||
document.querySelectorAll('.cb-dash-widget-remove').forEach((b) => b.classList.toggle('d-none', !editing));
|
||||
document.querySelectorAll('.cb-dash-widget-edit').forEach((b) => b.classList.toggle('d-none', !editing));
|
||||
document.querySelectorAll('.cb-dash-widget-text-actions--overlay').forEach((box) => {
|
||||
document.querySelectorAll('.cb-dash-widget-text-actions--overlay, .cb-dash-widget-filter-actions--overlay').forEach((box) => {
|
||||
const any = box.querySelector('.cb-dash-widget-edit:not(.d-none), .cb-dash-widget-remove:not(.d-none)');
|
||||
box.classList.toggle('d-none', !any);
|
||||
});
|
||||
@ -1110,7 +1340,20 @@
|
||||
|
||||
initGrid();
|
||||
layoutDirty = false;
|
||||
initMounts().then(() => {
|
||||
loadDashboardVariables().then((defs) => {
|
||||
dashVarDefs = defs;
|
||||
const qVars = readUrlVars();
|
||||
dashVarDefs.forEach((v) => {
|
||||
const name = String(v.name || '');
|
||||
if (!name) return;
|
||||
if (qVars[name] !== undefined) {
|
||||
globalVars[name] = qVars[name];
|
||||
} else if (globalVars[name] === undefined) {
|
||||
globalVars[name] = v.default_value != null ? String(v.default_value) : '';
|
||||
}
|
||||
});
|
||||
renderDashboardVariablePanel();
|
||||
}).finally(() => initMounts().then(() => {
|
||||
finalizeLayoutFromServer();
|
||||
/* GridStack may run resizeToContentCheck on a ~300ms timer after first layout; re-lock layout once after that. */
|
||||
setTimeout(() => finalizeLayoutFromServer(), 360);
|
||||
@ -1121,6 +1364,6 @@
|
||||
if (sec > 0) {
|
||||
refreshTimer = setInterval(() => refreshAllCharts(), sec * 1000);
|
||||
}
|
||||
});
|
||||
}));
|
||||
})();
|
||||
</script>
|
||||
|
||||
@ -38,6 +38,9 @@ $themeClass = match ($theme) {
|
||||
</button>
|
||||
</div>
|
||||
<div class="cb-dash-toolbar-secondary d-flex flex-wrap align-items-center gap-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary cb-dash-icon-btn d-none" id="cbBtnVarsModal" data-bs-toggle="modal" data-bs-target="#modalDashboardVariables" title="Dashboard variables" aria-label="Dashboard variables">
|
||||
<span class="material-symbols-outlined cb-btn-icon" aria-hidden="true">tune</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary cb-dash-icon-btn cb-dash-fs-btn" id="cbBtnFs" title="Fullscreen" aria-label="Fullscreen">
|
||||
<span class="material-symbols-outlined cb-btn-icon" aria-hidden="true">open_in_full</span>
|
||||
</button>
|
||||
@ -98,6 +101,20 @@ $themeClass = match ($theme) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="modalDashboardVariables" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content border-0 shadow-lg rounded-4">
|
||||
<div class="modal-header border-0">
|
||||
<h2 class="modal-title h5 fw-bold">Dashboard variables</h2>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body pt-0">
|
||||
<div class="cb-dash-global-vars d-none" id="cbDashGlobalVars"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add widget -->
|
||||
<div class="modal fade" id="modalAddWidget" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
|
||||
@ -341,6 +341,46 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.cb-dash-global-vars {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 10px 12px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.cb-dash-global-vars .cb-dash-var-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.cb-dash-global-vars .cb-dash-var-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #475569;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.cb-dash-global-vars .cb-dash-var-range {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cb-dash-shell.cb-theme-dark .cb-dash-global-vars {
|
||||
background: rgba(30, 41, 59, 0.85);
|
||||
border-color: rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.cb-dash-shell.cb-theme-dark .cb-dash-global-vars .cb-dash-var-label {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.cb-dash-grid-wrap .grid-stack {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@ -398,11 +438,34 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cb-dash-widget-var-hint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
color: #d97706;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
.cb-dash-widget-var-hint .material-symbols-outlined {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cb-dash-shell.cb-theme-dark .cb-dash-widget-head {
|
||||
border-bottom-color: rgba(148, 163, 184, 0.15);
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.cb-dash-shell.cb-theme-dark .cb-dash-widget-var-hint {
|
||||
color: #fbbf24;
|
||||
background: rgba(120, 53, 15, 0.35);
|
||||
border-color: rgba(251, 191, 36, 0.35);
|
||||
}
|
||||
|
||||
.cb-dash-widget-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
@ -426,6 +489,20 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Table chart inside dashboard widgets: keep controls visible and scroll rows inside tile. */
|
||||
.cb-dash-table-host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cb-dash-table-host .cb-dash-table-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.cb-dash-widget-edit,
|
||||
.cb-dash-widget-remove {
|
||||
display: inline-flex;
|
||||
@ -520,6 +597,117 @@
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
/* Compact date-range filter widget (headerless like text widget) */
|
||||
.cb-dash-widget-card--filter-date {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cb-dash-widget-filter-actions--overlay {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 6px;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: 0 1px 6px rgba(15, 23, 42, 0.08);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.cb-dash-shell.cb-theme-dark .cb-dash-widget-filter-actions--overlay {
|
||||
background: rgba(30, 41, 59, 0.94);
|
||||
box-shadow: 0 1px 8px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.cb-dash-widget-body--filter-date {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 7px 10px 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cb-dash-filter-title {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cb-dash-filter-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cb-dash-filter-row--compact {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cb-dash-filter-col {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cb-dash-filter-col--compact label {
|
||||
font-size: 11px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.cb-dash-widget-body--filter-date label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.cb-fdate--compact {
|
||||
height: 30px;
|
||||
padding-top: 3px;
|
||||
padding-bottom: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.grid-stack.cb-dash-editing .cb-dash-widget-card--filter-date .cb-dash-widget-body--filter-date {
|
||||
padding-right: 52px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cb-dash-filter-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Compact dropdown filter widget (headerless) */
|
||||
.cb-dash-widget-card--filter-dropdown {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cb-dash-widget-body--filter-dropdown {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 7px 10px 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cb-fdrop--compact {
|
||||
height: 30px;
|
||||
padding-top: 3px;
|
||||
padding-bottom: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.grid-stack.cb-dash-editing .cb-dash-widget-card--filter-dropdown .cb-dash-widget-body--filter-dropdown {
|
||||
padding-right: 52px;
|
||||
}
|
||||
|
||||
.cb-dash-prose.cb-dash-prose--bare {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user