GWM : ui improvement
This commit is contained in:
parent
5deb9ce809
commit
6a4c35b0be
@ -75,7 +75,7 @@ class ChartController extends BaseController
|
||||
$rules = [
|
||||
'name' => 'required|min_length[2]|max_length[200]',
|
||||
'saved_query_id' => 'required|integer',
|
||||
'chart_type' => 'required|in_list[bar,line,area,pie,donut,scatter,table,kpi_card,funnel,gauge,heatmap,combo,spline,stepline,radar,bubble,polar_area]',
|
||||
'chart_type' => 'required|in_list[bar,line,area,pie,donut,scatter,table,kpi_card,funnel,gauge,heatmap,combo,spline,stepline,radar,bubble,polar_area,ranked_progress]',
|
||||
'x_field' => 'permit_empty|max_length[150]',
|
||||
'y_field' => 'permit_empty|max_length[150]',
|
||||
'group_field' => 'permit_empty|max_length[150]',
|
||||
@ -155,7 +155,7 @@ class ChartController extends BaseController
|
||||
$rules = [
|
||||
'name' => 'required|min_length[2]|max_length[200]',
|
||||
'saved_query_id' => 'required|integer',
|
||||
'chart_type' => 'required|in_list[bar,line,area,pie,donut,scatter,table,kpi_card,funnel,gauge,heatmap,combo,spline,stepline,radar,bubble,polar_area]',
|
||||
'chart_type' => 'required|in_list[bar,line,area,pie,donut,scatter,table,kpi_card,funnel,gauge,heatmap,combo,spline,stepline,radar,bubble,polar_area,ranked_progress]',
|
||||
'x_field' => 'permit_empty|max_length[150]',
|
||||
'y_field' => 'permit_empty|max_length[150]',
|
||||
'group_field' => 'permit_empty|max_length[150]',
|
||||
@ -533,6 +533,7 @@ class ChartController extends BaseController
|
||||
'x_axis_label', 'y_axis_label', 'y_min', 'y_max', 'stacked',
|
||||
'smooth', 'stepline', 'horizontal_bar', 'legend_position',
|
||||
'secondary_y_field', 'gauge_max', 'kpi_columns', 'table_preview_limit',
|
||||
'ranked_progress_limit', 'ranked_progress_footer_count',
|
||||
];
|
||||
|
||||
$out = [];
|
||||
@ -557,6 +558,13 @@ class ChartController extends BaseController
|
||||
$out['palette_colors'] = array_slice($clean, 0, 12);
|
||||
}
|
||||
|
||||
if (isset($out['ranked_progress_limit'])) {
|
||||
$out['ranked_progress_limit'] = max(1, min(200, (int) $out['ranked_progress_limit']));
|
||||
}
|
||||
if (isset($out['ranked_progress_footer_count'])) {
|
||||
$out['ranked_progress_footer_count'] = max(0, min(5, (int) $out['ranked_progress_footer_count']));
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
/**
|
||||
* Adds ranked_progress chart type (horizontal ranked progress bars).
|
||||
*/
|
||||
class AddRankedProgressChartType extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$this->db->query("ALTER TABLE `charts` MODIFY `chart_type` ENUM(
|
||||
'bar','line','area','pie','donut',
|
||||
'scatter','table','kpi_card','funnel',
|
||||
'gauge','heatmap','combo',
|
||||
'spline','stepline','radar','bubble','polar_area',
|
||||
'ranked_progress'
|
||||
) NOT NULL");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Dropping enum values can fail if in use; leave extended ENUM.
|
||||
}
|
||||
}
|
||||
@ -125,6 +125,10 @@ class ChartRenderer
|
||||
return array_merge($this->buildKpiPayload($chart, $rows, $display), ['format_hints' => $hints]);
|
||||
}
|
||||
|
||||
if ($type === 'ranked_progress') {
|
||||
return array_merge($this->buildRankedProgressPayload($chart, $rows, $display), ['format_hints' => $hints]);
|
||||
}
|
||||
|
||||
$options = $this->buildApexOptions($chart, $rows, $display);
|
||||
if ($options === null) {
|
||||
return [
|
||||
@ -150,7 +154,7 @@ class ChartRenderer
|
||||
public function buildApexOptions(array $chart, array $rows, array $display): ?array
|
||||
{
|
||||
$type = (string) ($chart['chart_type'] ?? 'line');
|
||||
if ($rows === [] || $type === 'table' || $type === 'kpi_card') {
|
||||
if ($rows === [] || $type === 'table' || $type === 'kpi_card' || $type === 'ranked_progress') {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -252,8 +256,8 @@ class ChartRenderer
|
||||
}
|
||||
$data = [];
|
||||
foreach ($rows as $idx => $r) {
|
||||
$xNum = $this->coercePlotNumber($r[$x] ?? null);
|
||||
$yNum = $this->coercePlotNumber($r[$y] ?? null);
|
||||
$xNum = $this->coercePlotNumber($this->rowField($r, $x));
|
||||
$yNum = $this->coercePlotNumber($this->rowField($r, $y));
|
||||
|
||||
// Bubble charts are often switched from categorical charts in builder.
|
||||
// When X is not numeric, use row index so points still render.
|
||||
@ -270,7 +274,7 @@ class ChartRenderer
|
||||
'y' => (float) $yNum,
|
||||
];
|
||||
if ($type === 'bubble') {
|
||||
$zRaw = $this->coercePlotNumber($r[$valueF] ?? null);
|
||||
$zRaw = $this->coercePlotNumber($this->rowField($r, $valueF));
|
||||
$z = $zRaw !== null ? (float) $zRaw : 0.0;
|
||||
$pt['z'] = $z > 0 ? $z : 1.0;
|
||||
}
|
||||
@ -282,25 +286,46 @@ class ChartRenderer
|
||||
|
||||
$scatterType = $type === 'bubble' ? 'bubble' : 'scatter';
|
||||
|
||||
return [
|
||||
// Coordinate pairs need numeric axes; default category x-axis breaks scatter/bubble rendering.
|
||||
$xaxisScatter = [
|
||||
'type' => 'numeric',
|
||||
'title' => ['text' => (string) ($display['x_axis_label'] ?? $x), 'style' => ['color' => '#64748b']],
|
||||
'labels' => ['style' => ['colors' => '#64748b', 'fontSize' => '11px']],
|
||||
'axisBorder' => ['show' => false],
|
||||
'axisTicks' => ['show' => false],
|
||||
];
|
||||
$yaxisScatter = [
|
||||
'type' => 'numeric',
|
||||
'title' => ['text' => (string) ($display['y_axis_label'] ?? $y), 'style' => ['color' => '#64748b']],
|
||||
'labels' => ['style' => ['colors' => '#64748b', 'fontSize' => '11px']],
|
||||
];
|
||||
|
||||
$out = [
|
||||
'chart' => array_merge($chartBase, ['type' => $scatterType, 'height' => 360, 'zoom' => ['enabled' => true, 'type' => 'xy']]),
|
||||
'title' => $titleText !== '' ? ['text' => $titleText, 'align' => 'left', 'style' => ['fontSize' => '15px', 'fontWeight' => 600]] : ['text' => ''],
|
||||
'series' => [['name' => $y, 'data' => $data]],
|
||||
'colors' => [$colors[0]],
|
||||
'grid' => $grid,
|
||||
'xaxis' => [
|
||||
'title' => ['text' => (string) ($display['x_axis_label'] ?? $x), 'style' => ['color' => '#64748b']],
|
||||
'labels' => ['style' => ['colors' => '#64748b', 'fontSize' => '11px']],
|
||||
'axisBorder' => ['show' => false],
|
||||
'axisTicks' => ['show' => false],
|
||||
],
|
||||
'yaxis' => [
|
||||
'title' => ['text' => (string) ($display['y_axis_label'] ?? $y), 'style' => ['color' => '#64748b']],
|
||||
'labels' => ['style' => ['colors' => '#64748b', 'fontSize' => '11px']],
|
||||
],
|
||||
'markers' => ['size' => 6],
|
||||
'tooltip' => ['theme' => 'light'],
|
||||
'xaxis' => $xaxisScatter,
|
||||
'yaxis' => $yaxisScatter,
|
||||
'tooltip' => ['theme' => 'light', 'shared' => false, 'intersect' => true],
|
||||
];
|
||||
|
||||
if ($type === 'scatter') {
|
||||
$out['markers'] = ['size' => 6];
|
||||
} else {
|
||||
$out['markers'] = ['opacity' => 0.85];
|
||||
$out['fill'] = ['opacity' => 0.8];
|
||||
$out['stroke'] = ['width' => 0];
|
||||
$out['plotOptions'] = [
|
||||
'bubble' => [
|
||||
'minBubbleRadius' => 4,
|
||||
'maxBubbleRadius' => 36,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
if ($type === 'radar') {
|
||||
@ -653,6 +678,88 @@ class ChartRenderer
|
||||
return $yaxisOpt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal ranked progress bars: category (X) + numeric value (Y), aggregated per label.
|
||||
*
|
||||
* @param array<string, mixed> $chart
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @param array<string, mixed> $display
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildRankedProgressPayload(array $chart, array $rows, array $display): array
|
||||
{
|
||||
$x = trim((string) ($chart['x_field'] ?? ''));
|
||||
$y = trim((string) ($chart['y_field'] ?? ''));
|
||||
if ($x === '' || $y === '' || $rows === []) {
|
||||
return [
|
||||
'engine' => 'empty',
|
||||
'message' => 'Ranked progress needs X (category) and Y (numeric value) fields.',
|
||||
];
|
||||
}
|
||||
|
||||
$aggregates = [];
|
||||
foreach ($rows as $r) {
|
||||
$label = $this->stringify($this->rowField($r, $x));
|
||||
if ($label === '') {
|
||||
continue;
|
||||
}
|
||||
$aggregates[$label] = ($aggregates[$label] ?? 0.0) + (float) $this->coerceNumber($this->rowField($r, $y));
|
||||
}
|
||||
|
||||
if ($aggregates === []) {
|
||||
return [
|
||||
'engine' => 'empty',
|
||||
'message' => 'No data to render this chart.',
|
||||
];
|
||||
}
|
||||
|
||||
arsort($aggregates, SORT_NUMERIC);
|
||||
|
||||
$limit = isset($display['ranked_progress_limit']) ? max(1, min(200, (int) $display['ranked_progress_limit'])) : 50;
|
||||
$aggregates = array_slice($aggregates, 0, $limit, true);
|
||||
|
||||
$maxVal = max($aggregates);
|
||||
$total = array_sum($aggregates);
|
||||
$total = abs($total) < 1e-12 ? 0.0 : $total;
|
||||
|
||||
$palette = $this->ensureColorCount($this->resolveColors($display), max(count($aggregates), 1));
|
||||
|
||||
$items = [];
|
||||
$i = 0;
|
||||
foreach ($aggregates as $label => $val) {
|
||||
$color = $palette[$i % count($palette)];
|
||||
$barPct = $maxVal > 0 ? round(($val / $maxVal) * 100, 4) : 0.0;
|
||||
$items[] = [
|
||||
'label' => $label,
|
||||
'value' => $val,
|
||||
'value_label' => $this->formatNumber($val, $display),
|
||||
'color' => $color,
|
||||
'bar_pct' => $barPct,
|
||||
];
|
||||
$i++;
|
||||
}
|
||||
|
||||
$footerCount = isset($display['ranked_progress_footer_count']) ? max(0, min(5, (int) $display['ranked_progress_footer_count'])) : 2;
|
||||
$footer = [];
|
||||
if ($footerCount > 0 && $total > 0) {
|
||||
foreach (array_slice($items, 0, $footerCount) as $row) {
|
||||
$footer[] = [
|
||||
'label' => $row['label'],
|
||||
'pct' => (int) round(($row['value'] / $total) * 100),
|
||||
'color' => $row['color'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'engine' => 'ranked_progress',
|
||||
'title' => (string) ($display['title'] ?? ''),
|
||||
'subtitle' => (string) ($display['subtitle'] ?? ''),
|
||||
'items' => $items,
|
||||
'footer' => $footer,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $display
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
@ -948,6 +1055,24 @@ class ChartRenderer
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|object $row
|
||||
*/
|
||||
private function rowField(mixed $row, string $key): mixed
|
||||
{
|
||||
if ($key === '') {
|
||||
return null;
|
||||
}
|
||||
if (is_array($row)) {
|
||||
return $row[$key] ?? null;
|
||||
}
|
||||
if (is_object($row)) {
|
||||
return $row->{$key} ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $display
|
||||
*/
|
||||
|
||||
@ -52,7 +52,7 @@ class ChartModel extends Model
|
||||
'workspace_id' => 'required|integer',
|
||||
'data_source_id' => 'required|integer',
|
||||
'name' => 'required|min_length[2]|max_length[200]',
|
||||
'chart_type' => 'required|in_list[bar,line,area,pie,donut,scatter,table,kpi_card,funnel,gauge,heatmap,combo,spline,stepline,radar,bubble,polar_area]',
|
||||
'chart_type' => 'required|in_list[bar,line,area,pie,donut,scatter,table,kpi_card,funnel,gauge,heatmap,combo,spline,stepline,radar,bubble,polar_area,ranked_progress]',
|
||||
'query_type' => 'permit_empty|in_list[visual,raw_sql,api]',
|
||||
];
|
||||
|
||||
|
||||
@ -86,11 +86,28 @@ $bootstrapJson = json_encode($bootstrap, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX
|
||||
<div class="cb-cb-query-vars mt-2" x-show="variableDefs.length" x-cloak>
|
||||
<div class="p-2 rounded-3 bg-light border">
|
||||
<div class="small fw-bold mb-2">Query variables</div>
|
||||
<div class="row g-2">
|
||||
<div class="cb-cb-var-grid">
|
||||
<template x-for="v in variableDefs" :key="v.name">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small mb-0" x-text="v.label || v.name"></label>
|
||||
<input type="text" class="form-control form-control-sm" :placeholder="v.default_value || ''" x-model="variables[v.name]" @input="queueRenderPreview()">
|
||||
<div class="cb-cb-var-pill" :class="{ 'is-filled': String(variables[v.name] || '').trim() !== '' }">
|
||||
<span class="cb-cb-var-key" x-text="v.label || v.name"></span>
|
||||
<input
|
||||
type="text"
|
||||
class="cb-cb-var-input"
|
||||
:placeholder="v.default_value || ''"
|
||||
x-model="variables[v.name]"
|
||||
@input="queueRenderPreview()"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="cb-cb-var-clear"
|
||||
x-show="String(variables[v.name] || '').trim() !== ''"
|
||||
x-cloak
|
||||
@click="variables[v.name] = ''; queueRenderPreview()"
|
||||
title="Clear"
|
||||
aria-label="Clear variable value"
|
||||
>
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@ -251,6 +251,42 @@
|
||||
el.innerHTML = html || '<p class="text-muted small mb-0">No KPI columns.</p>';
|
||||
return;
|
||||
}
|
||||
if (payload.engine === 'ranked_progress') {
|
||||
const items = payload.items || [];
|
||||
const footer = payload.footer || [];
|
||||
let html = '<div class="cb-ranked-progress cb-ranked-progress--builder">';
|
||||
if (payload.title) {
|
||||
html += '<div class="cb-ranked-progress-title">' + escapeHtml(payload.title) + '</div>';
|
||||
}
|
||||
if (payload.subtitle) {
|
||||
html += '<div class="cb-ranked-progress-subtitle text-muted small">' + escapeHtml(payload.subtitle) + '</div>';
|
||||
}
|
||||
items.forEach((it) => {
|
||||
const pct = Math.max(0, Math.min(100, Number(it.bar_pct) || 0));
|
||||
const col = String(it.color || '#36a2eb');
|
||||
html += '<div class="cb-ranked-row">';
|
||||
html += '<span class="cb-ranked-dot" style="background-color:' + escapeHtml(col) + '"></span>';
|
||||
html += '<span class="cb-ranked-label">' + escapeHtml(it.label) + '</span>';
|
||||
html += '<div class="cb-ranked-bar-wrap"><div class="cb-ranked-bar-track">';
|
||||
html += '<div class="cb-ranked-bar-fill" style="width:' + pct + '%;background-color:' + escapeHtml(col) + '"></div>';
|
||||
html += '</div></div>';
|
||||
html += '<span class="cb-ranked-val">' + escapeHtml(it.value_label != null ? String(it.value_label) : String(it.value)) + '</span>';
|
||||
html += '</div>';
|
||||
});
|
||||
if (footer.length) {
|
||||
html += '<div class="cb-ranked-footer">';
|
||||
footer.forEach((f) => {
|
||||
const c = String(f.color || '#64748b');
|
||||
html += '<span class="cb-ranked-pill" style="border-color:' + escapeHtml(c) + '40;color:' + escapeHtml(c) + '">';
|
||||
html += '<span class="cb-ranked-pill-dot" style="background:' + escapeHtml(c) + '"></span>';
|
||||
html += escapeHtml(f.label) + ' ' + (f.pct != null ? f.pct : 0) + '%</span>';
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
el.innerHTML = html;
|
||||
return;
|
||||
}
|
||||
if (payload.engine === 'apex' && payload.options && typeof ApexCharts !== 'undefined') {
|
||||
const opts = JSON.parse(JSON.stringify(payload.options));
|
||||
applyFormatHints(opts, hints || payload.format_hints);
|
||||
@ -321,6 +357,7 @@
|
||||
{ id: 'stepline', label: 'Step line', icon: '📶' },
|
||||
{ id: 'area', label: 'Area', icon: '📉' },
|
||||
{ id: 'bar', label: 'Bar', icon: '📊' },
|
||||
{ id: 'ranked_progress', label: 'Ranked list', icon: '📋' },
|
||||
{ id: 'combo', label: 'Combo', icon: '⚡' },
|
||||
{ id: 'pie', label: 'Pie', icon: '🥧' },
|
||||
{ id: 'donut', label: 'Donut', icon: '🍩' },
|
||||
@ -491,7 +528,7 @@
|
||||
const cats = this.columns.filter((c) => this.inferColumnKind(c) === 'category');
|
||||
const out = [];
|
||||
if (dates.length && nums.length) out.push('line', 'area');
|
||||
if (cats.length && nums.length) out.push('bar');
|
||||
if (cats.length && nums.length) out.push('bar', 'ranked_progress');
|
||||
if (cats.length && nums.length === 1) out.push('donut', 'pie');
|
||||
if (nums.length >= 2) out.push('scatter');
|
||||
out.push('table', 'kpi_card');
|
||||
@ -556,7 +593,7 @@
|
||||
return [
|
||||
'bar', 'line', 'area', 'spline', 'stepline',
|
||||
'pie', 'donut', 'polar_area', 'scatter', 'bubble', 'radar',
|
||||
'funnel', 'heatmap', 'combo',
|
||||
'funnel', 'heatmap', 'combo', 'ranked_progress',
|
||||
].includes(this.chartType);
|
||||
},
|
||||
|
||||
@ -572,6 +609,7 @@
|
||||
stepline: 'Step line: values change in steps; same mapping as line.',
|
||||
area: 'Area: same as line; great for volume over time.',
|
||||
bar: 'Bar: X = category, Y = numeric. Toggle horizontal in Style.',
|
||||
ranked_progress: 'Ranked list: X = category label, Y = numeric value. Rows aggregate by label; bars scale to the largest value; footer shows top shares of total.',
|
||||
combo: 'Combo: columns from Y, line from second metric. Pick Line series in Fields.',
|
||||
pie: 'Pie: X = slice labels, Y = numeric values.',
|
||||
donut: 'Donut: same as pie with a center cutout.',
|
||||
|
||||
@ -17,44 +17,60 @@
|
||||
<div class="alert alert-danger"><?= esc(session()->getFlashdata('error')) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($charts === []): ?>
|
||||
<div class="cb-alert-empty-panel">
|
||||
<div>
|
||||
<span class="material-symbols-outlined cb-alert-empty-icon d-block">bar_chart</span>
|
||||
<p class="cb-alert-empty-msg">No charts yet. Create a query first, then build a chart from it.</p>
|
||||
<a href="<?= base_url('chart/create') ?>" class="btn cb-page-primary-btn mt-3">Create chart</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="cb-chart-grid">
|
||||
<?php foreach ($charts as $c): ?>
|
||||
<div class="cb-chart-card h-100">
|
||||
<div class="cb-chart-card-accent"></div>
|
||||
<div class="cb-chart-card-inner">
|
||||
<div class="d-flex justify-content-between align-items-start gap-2 mb-2">
|
||||
<div class="min-w-0">
|
||||
<div class="cb-chart-card-name"><?= esc($c['name']) ?></div>
|
||||
<div class="cb-chart-meta"><?= esc($c['saved_query_name'] ?? 'Query') ?> · <?= esc($c['data_source_name'] ?? '') ?></div>
|
||||
<div class="cb-table-shell">
|
||||
<div class="table-responsive">
|
||||
<table class="table cb-data-table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Data Source</th>
|
||||
<th>Chart type</th>
|
||||
<th>Updated</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if ($charts === []): ?>
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted py-5">No charts yet. Create a query first, then build a chart from it.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php foreach ($charts as $c): ?>
|
||||
<?php
|
||||
$ct = strtoupper((string) ($c['chart_type'] ?? ''));
|
||||
$typeClass = match ($ct) {
|
||||
'TABLE' => 'cb-badge-type--sql',
|
||||
default => 'cb-badge-type--visual',
|
||||
};
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="cb-cell-name"><?= esc($c['name']) ?></div>
|
||||
<div class="cb-cell-sub"><?= esc($c['saved_query_name'] ?? 'Query') ?></div>
|
||||
</td>
|
||||
<td><?= esc($c['data_source_name'] ?: '—') ?></td>
|
||||
<td><span class="cb-badge-type <?= esc($typeClass, 'attr') ?>"><?= esc($ct) ?></span></td>
|
||||
<td class="text-muted small"><?= esc($c['updated_at'] ?? '—') ?></td>
|
||||
<td class="text-end">
|
||||
<div class="cb-table-actions">
|
||||
<button type="button" class="cb-action-btn cb-action-btn--ghost" data-bs-toggle="modal" data-bs-target="#modalShare" data-share-type="chart" data-resource-id="<?= (int) $c['id'] ?>">Share</button>
|
||||
<a href="<?= base_url('chart/edit/' . (int) $c['id']) ?>" class="cb-action-btn cb-action-btn--edit">Edit</a>
|
||||
<form action="<?= base_url('chart/duplicate/' . (int) $c['id']) ?>" method="post" class="d-inline">
|
||||
<?= csrf_field() ?>
|
||||
<button type="submit" class="cb-action-btn cb-action-btn--ghost">Duplicate</button>
|
||||
</form>
|
||||
<form action="<?= base_url('chart/delete/' . (int) $c['id']) ?>" method="post" class="d-inline" onsubmit="return confirm('Delete this chart?');">
|
||||
<?= csrf_field() ?>
|
||||
<button type="submit" class="cb-action-btn cb-action-btn--delete">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
<span class="cb-chart-type-badge flex-shrink-0"><?= esc($c['chart_type']) ?></span>
|
||||
</div>
|
||||
<div class="cb-chart-meta mb-0">Updated <?= esc($c['updated_at'] ?? '') ?></div>
|
||||
<div class="cb-chart-card-actions">
|
||||
<button type="button" class="btn btn-sm cb-chart-btn-share" data-bs-toggle="modal" data-bs-target="#modalShare" data-share-type="chart" data-resource-id="<?= (int) $c['id'] ?>">Share</button>
|
||||
<a href="<?= base_url('chart/edit/' . (int) $c['id']) ?>" class="btn btn-sm btn-primary cb-chart-btn-edit">Edit</a>
|
||||
<form action="<?= base_url('chart/duplicate/' . (int) $c['id']) ?>" method="post" class="d-inline">
|
||||
<?= csrf_field() ?>
|
||||
<button type="submit" class="btn btn-sm cb-chart-btn-dup">Duplicate</button>
|
||||
</form>
|
||||
<form action="<?= base_url('chart/delete/' . (int) $c['id']) ?>" method="post" class="d-inline" onsubmit="return confirm('Delete this chart?');">
|
||||
<?= csrf_field() ?>
|
||||
<button type="submit" class="btn btn-sm cb-chart-btn-del">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
@ -174,6 +174,7 @@
|
||||
el._apexChart = null;
|
||||
}
|
||||
el.innerHTML = '';
|
||||
el.classList.remove('cb-dash-table-host', 'cb-ranked-progress-host');
|
||||
if (!payload || payload.engine === 'empty') {
|
||||
el.innerHTML = '<div class="d-flex align-items-center justify-content-center h-100 text-muted small p-4">' + escapeHtml(payload?.message || 'No data') + '</div>';
|
||||
return;
|
||||
@ -289,6 +290,43 @@
|
||||
el.innerHTML = html || '<p class="text-muted small mb-0">No KPI data.</p>';
|
||||
return;
|
||||
}
|
||||
if (payload.engine === 'ranked_progress') {
|
||||
const items = payload.items || [];
|
||||
const footer = payload.footer || [];
|
||||
let html = '<div class="cb-ranked-progress cb-ranked-progress--dash">';
|
||||
if (payload.title) {
|
||||
html += '<div class="cb-ranked-progress-title">' + escapeHtml(payload.title) + '</div>';
|
||||
}
|
||||
if (payload.subtitle) {
|
||||
html += '<div class="cb-ranked-progress-subtitle text-muted small">' + escapeHtml(payload.subtitle) + '</div>';
|
||||
}
|
||||
items.forEach((it) => {
|
||||
const pct = Math.max(0, Math.min(100, Number(it.bar_pct) || 0));
|
||||
const col = String(it.color || '#36a2eb');
|
||||
html += '<div class="cb-ranked-row">';
|
||||
html += '<span class="cb-ranked-dot" style="background-color:' + escapeHtml(col) + '"></span>';
|
||||
html += '<span class="cb-ranked-label">' + escapeHtml(it.label) + '</span>';
|
||||
html += '<div class="cb-ranked-bar-wrap"><div class="cb-ranked-bar-track">';
|
||||
html += '<div class="cb-ranked-bar-fill" style="width:' + pct + '%;background-color:' + escapeHtml(col) + '"></div>';
|
||||
html += '</div></div>';
|
||||
html += '<span class="cb-ranked-val">' + escapeHtml(it.value_label != null ? String(it.value_label) : String(it.value)) + '</span>';
|
||||
html += '</div>';
|
||||
});
|
||||
if (footer.length) {
|
||||
html += '<div class="cb-ranked-footer">';
|
||||
footer.forEach((f) => {
|
||||
const c = String(f.color || '#64748b');
|
||||
html += '<span class="cb-ranked-pill" style="border-color:' + escapeHtml(c) + '40;color:' + escapeHtml(c) + '">';
|
||||
html += '<span class="cb-ranked-pill-dot" style="background:' + escapeHtml(c) + '"></span>';
|
||||
html += escapeHtml(f.label) + ' ' + (f.pct != null ? f.pct : 0) + '%</span>';
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
el.innerHTML = html;
|
||||
el.classList.add('cb-ranked-progress-host');
|
||||
return;
|
||||
}
|
||||
if (payload.engine === 'apex' && payload.options && typeof ApexCharts !== 'undefined') {
|
||||
const opts = JSON.parse(JSON.stringify(payload.options));
|
||||
applyFormatHints(opts, hints || payload.format_hints);
|
||||
@ -386,12 +424,25 @@
|
||||
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>';
|
||||
const wrapPill = (innerHtml, val, canClear) => {
|
||||
const filled = String(val ?? '').trim() !== '';
|
||||
let h = '<div class="cb-dash-var-item"><div class="cb-dash-var-pill' + (filled ? ' is-filled' : '') + '">';
|
||||
h += '<span class="cb-dash-var-key" title="' + escapeHtml(lab) + '">' + escapeHtml(lab) + '</span>';
|
||||
h += innerHtml;
|
||||
if (canClear) {
|
||||
h += '<button type="button" class="cb-dash-var-clear' + (filled ? '' : ' d-none') + '" data-cb-var-clear="' + escapeHtml(k) + '" aria-label="Clear variable"><span class="material-symbols-outlined">close</span></button>';
|
||||
}
|
||||
h += '</div></div>';
|
||||
return h;
|
||||
};
|
||||
|
||||
if (def.type === 'number') {
|
||||
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>';
|
||||
const inp = '<input type="number" class="cb-dash-var-input" data-cb-var-key="' + escapeHtml(k) + '" value="' + escapeHtml(current) + '"' + req + '>';
|
||||
return wrapPill(inp, current, true);
|
||||
}
|
||||
if (def.type === 'date') {
|
||||
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>';
|
||||
const inp = '<input type="date" class="cb-dash-var-input" data-cb-var-key="' + escapeHtml(k) + '" value="' + escapeHtml(current) + '"' + req + '>';
|
||||
return wrapPill(inp, current, true);
|
||||
}
|
||||
if (def.type === 'date_range') {
|
||||
const parts = String(current || '').split('|');
|
||||
@ -402,7 +453,7 @@
|
||||
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' : '') + '>';
|
||||
let h = '<select class="cb-dash-var-input cb-dash-var-select" 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;
|
||||
@ -413,10 +464,11 @@
|
||||
: (String(current) === vv ? ' selected' : '');
|
||||
h += '<option value="' + escapeHtml(vv) + '"' + sel + '>' + escapeHtml(String(t)) + '</option>';
|
||||
});
|
||||
h += '</select></div>';
|
||||
return h;
|
||||
h += '</select>';
|
||||
return wrapPill(h, current, def.type !== 'multi_select');
|
||||
}
|
||||
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>';
|
||||
const txt = '<input type="text" class="cb-dash-var-input" data-cb-var-key="' + escapeHtml(k) + '" value="' + escapeHtml(current) + '"' + req + '>';
|
||||
return wrapPill(txt, current, true);
|
||||
}
|
||||
|
||||
async function loadDashboardVariables() {
|
||||
@ -451,18 +503,32 @@
|
||||
|
||||
function bindVarInputs(root) {
|
||||
if (!root) return;
|
||||
const toggleFilledState = (inp) => {
|
||||
const pill = inp.closest('.cb-dash-var-pill');
|
||||
if (!pill) return;
|
||||
const filled = inp.multiple
|
||||
? Array.from(inp.selectedOptions || []).some((o) => String(o.value || '').trim() !== '')
|
||||
: String(inp.value || '').trim() !== '';
|
||||
pill.classList.toggle('is-filled', filled);
|
||||
const clearBtn = pill.querySelector('[data-cb-var-clear]');
|
||||
if (clearBtn) clearBtn.classList.toggle('d-none', !filled);
|
||||
};
|
||||
|
||||
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);
|
||||
toggleFilledState(inp);
|
||||
return;
|
||||
}
|
||||
const vals = Array.from(inp.selectedOptions || []).map((o) => o.value);
|
||||
setGlobalVar(key, vals);
|
||||
toggleFilledState(inp);
|
||||
};
|
||||
inp.addEventListener('change', apply);
|
||||
inp.addEventListener('input', apply);
|
||||
toggleFilledState(inp);
|
||||
});
|
||||
root.querySelectorAll('[data-cb-var-range]').forEach((inp) => {
|
||||
inp.addEventListener('change', () => {
|
||||
@ -472,6 +538,24 @@
|
||||
setGlobalVar(parent, start + '|' + end);
|
||||
});
|
||||
});
|
||||
root.querySelectorAll('[data-cb-var-clear]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const key = btn.getAttribute('data-cb-var-clear');
|
||||
const inp = root.querySelector('[data-cb-var-key="' + key + '"]');
|
||||
if (!inp) return;
|
||||
if (inp.multiple) {
|
||||
Array.from(inp.options || []).forEach((o) => { o.selected = false; });
|
||||
setGlobalVar(key, []);
|
||||
} else {
|
||||
inp.value = '';
|
||||
setGlobalVar(key, '');
|
||||
}
|
||||
const pill = inp.closest('.cb-dash-var-pill');
|
||||
if (pill) pill.classList.remove('is-filled');
|
||||
btn.classList.add('d-none');
|
||||
inp.focus();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderDashboardVariablePanel() {
|
||||
@ -1226,11 +1310,38 @@
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
let fs = false;
|
||||
document.getElementById('cbBtnFs')?.addEventListener('click', () => {
|
||||
fs = !fs;
|
||||
document.body.classList.toggle('cb-dash-fs', fs);
|
||||
});
|
||||
const fsBtn = document.getElementById('cbBtnFs');
|
||||
function isElementFullscreen() {
|
||||
return !!document.fullscreenElement;
|
||||
}
|
||||
function setFsButtonState() {
|
||||
if (!fsBtn) return;
|
||||
const icon = fsBtn.querySelector('.material-symbols-outlined');
|
||||
const active = isElementFullscreen();
|
||||
fsBtn.title = active ? 'Exit fullscreen' : 'Fullscreen';
|
||||
if (icon) {
|
||||
icon.textContent = active ? 'fullscreen_exit' : 'fullscreen';
|
||||
}
|
||||
}
|
||||
async function toggleDashboardFullscreen() {
|
||||
const shellEl = document.getElementById('cbDashShell');
|
||||
if (!shellEl) return;
|
||||
try {
|
||||
if (!isElementFullscreen()) {
|
||||
await shellEl.requestFullscreen();
|
||||
} else {
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback for older browsers/contexts where Fullscreen API is unavailable.
|
||||
document.body.classList.toggle('cb-dash-fs');
|
||||
} finally {
|
||||
setFsButtonState();
|
||||
}
|
||||
}
|
||||
fsBtn?.addEventListener('click', () => { toggleDashboardFullscreen(); });
|
||||
document.addEventListener('fullscreenchange', setFsButtonState);
|
||||
setFsButtonState();
|
||||
|
||||
/* Add widget modal */
|
||||
const addType = document.getElementById('cbAddType');
|
||||
|
||||
@ -309,6 +309,84 @@ body.cb-cb-preview-fs-open {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cb-cb-var-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cb-cb-var-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
padding: 4px 8px 4px 10px;
|
||||
border: 1px solid #dbe5f1;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.cb-cb-var-pill:focus-within {
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.cb-cb-var-key {
|
||||
flex: 0 0 auto;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
white-space: nowrap;
|
||||
max-width: 45%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cb-cb-var-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 60px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
color: #0f172a;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cb-cb-var-input::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.cb-cb-var-pill.is-filled .cb-cb-var-input {
|
||||
color: #2563eb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cb-cb-var-clear {
|
||||
flex: 0 0 auto;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cb-cb-var-clear:hover {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.cb-cb-var-clear .material-symbols-outlined {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cb-cb-hint {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
@ -455,3 +533,104 @@ body.cb-cb-preview-fs-open {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ranked list (horizontal progress bars) */
|
||||
.cb-ranked-progress {
|
||||
font-size: 13px;
|
||||
color: #0f172a;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cb-ranked-progress--builder {
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.cb-ranked-progress-title {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.cb-ranked-progress-subtitle {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.cb-ranked-row {
|
||||
display: grid;
|
||||
grid-template-columns: 10px minmax(72px, 1fr) minmax(80px, 3fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #e8ecf1;
|
||||
}
|
||||
|
||||
.cb-ranked-row:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.cb-ranked-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cb-ranked-label {
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cb-ranked-bar-wrap {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cb-ranked-bar-track {
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background: #e8ecf1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cb-ranked-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
|
||||
.cb-ranked-val {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
text-align: right;
|
||||
min-width: 3ch;
|
||||
}
|
||||
|
||||
.cb-ranked-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.cb-ranked-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #f8fafc;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cb-ranked-pill-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@ -195,7 +195,6 @@
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 14px;
|
||||
margin-bottom: 18px;
|
||||
box-shadow: 0 8px 28px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
@ -359,6 +358,84 @@
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.cb-dash-global-vars .cb-dash-var-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
padding: 4px 8px 4px 10px;
|
||||
border: 1px solid #dbe5f1;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.cb-dash-global-vars .cb-dash-var-pill:focus-within {
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.cb-dash-global-vars .cb-dash-var-key {
|
||||
flex: 0 0 auto;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
white-space: nowrap;
|
||||
max-width: 42%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.cb-dash-global-vars .cb-dash-var-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 60px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
color: #0f172a;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cb-dash-global-vars .cb-dash-var-input::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.cb-dash-global-vars .cb-dash-var-pill.is-filled .cb-dash-var-input {
|
||||
color: #2563eb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cb-dash-global-vars .cb-dash-var-select {
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.cb-dash-global-vars .cb-dash-var-clear {
|
||||
flex: 0 0 auto;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cb-dash-global-vars .cb-dash-var-clear:hover {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.cb-dash-global-vars .cb-dash-var-clear .material-symbols-outlined {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cb-dash-global-vars .cb-dash-var-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
@ -381,6 +458,23 @@
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.cb-dash-shell.cb-theme-dark .cb-dash-global-vars .cb-dash-var-pill {
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
border-color: rgba(148, 163, 184, 0.35);
|
||||
}
|
||||
|
||||
.cb-dash-shell.cb-theme-dark .cb-dash-global-vars .cb-dash-var-key {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.cb-dash-shell.cb-theme-dark .cb-dash-global-vars .cb-dash-var-input {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.cb-dash-shell.cb-theme-dark .cb-dash-global-vars .cb-dash-var-pill.is-filled .cb-dash-var-input {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.cb-dash-grid-wrap .grid-stack {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@ -503,6 +597,40 @@
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.cb-dash-widget-body .cb-dash-chart-inner > div[id^="cb-chart-"].cb-ranked-progress-host {
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.cb-ranked-progress-host .cb-ranked-progress--dash {
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.cb-dash-shell.cb-theme-dark .cb-ranked-progress {
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.cb-dash-shell.cb-theme-dark .cb-ranked-row {
|
||||
border-bottom-color: rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.cb-dash-shell.cb-theme-dark .cb-ranked-label {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.cb-dash-shell.cb-theme-dark .cb-ranked-val {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.cb-dash-shell.cb-theme-dark .cb-ranked-bar-track {
|
||||
background: rgba(148, 163, 184, 0.25);
|
||||
}
|
||||
|
||||
.cb-dash-shell.cb-theme-dark .cb-ranked-pill {
|
||||
background: rgba(30, 41, 59, 0.85);
|
||||
border-color: rgba(148, 163, 184, 0.35);
|
||||
}
|
||||
|
||||
.cb-dash-widget-edit,
|
||||
.cb-dash-widget-remove {
|
||||
display: inline-flex;
|
||||
@ -806,6 +934,25 @@ body.cb-dash-fs .cb-dash-shell {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Native Fullscreen API mode: keep dashboard scrollable */
|
||||
#cbDashShell:fullscreen,
|
||||
#cbDashShell:-webkit-full-screen {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
#cbDashShell.cb-theme-dark:fullscreen,
|
||||
#cbDashShell.cb-theme-dark:-webkit-full-screen {
|
||||
background: linear-gradient(180deg, #0f172a 0%, #1e293b 100%);
|
||||
}
|
||||
|
||||
.grid-stack.cb-dash-editing .grid-stack-item {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
@ -863,124 +863,6 @@ button.cb-action-btn {
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
/* —— Charts list —— */
|
||||
.cb-chart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1199.98px) {
|
||||
.cb-chart-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.cb-chart-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.cb-chart-card {
|
||||
position: relative;
|
||||
background: var(--card-bg);
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.cb-chart-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||
border-color: #d0d3dc;
|
||||
}
|
||||
|
||||
.cb-chart-card-accent {
|
||||
display: block;
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
background: var(--accent-blue);
|
||||
border-radius: var(--card-radius) var(--card-radius) 0 0;
|
||||
}
|
||||
|
||||
.cb-chart-card-inner {
|
||||
padding: 16px 20px 20px;
|
||||
}
|
||||
|
||||
.cb-chart-type-badge {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border-radius: 5px;
|
||||
padding: 3px 9px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.cb-chart-card-name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.cb-chart-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.cb-chart-card-actions {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #f0f1f3;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cb-chart-btn {
|
||||
border-radius: 6px;
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.cb-chart-btn-share,
|
||||
.cb-chart-btn-dup {
|
||||
border: 1px solid var(--divider-color);
|
||||
background: #fff;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.cb-chart-btn-share:hover,
|
||||
.cb-chart-btn-dup:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.cb-chart-btn-edit.btn-primary {
|
||||
background: var(--accent-blue);
|
||||
border-color: var(--accent-blue);
|
||||
border-radius: 6px;
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cb-chart-btn-del {
|
||||
border: 1px solid #fca5a5;
|
||||
color: var(--danger-red);
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* —— Alerts —— */
|
||||
.cb-alert-panel {
|
||||
background: var(--card-bg);
|
||||
@ -1364,7 +1246,6 @@ html[data-cb-theme="dark"] .cb-page-title {
|
||||
html[data-cb-theme="dark"] .cb-workspace-card,
|
||||
html[data-cb-theme="dark"] .cb-table-shell,
|
||||
html[data-cb-theme="dark"] .cb-form-shell,
|
||||
html[data-cb-theme="dark"] .cb-chart-card,
|
||||
html[data-cb-theme="dark"] .cb-alert-panel,
|
||||
html[data-cb-theme="dark"] .cb-alert-empty-panel {
|
||||
border-color: var(--cb-line);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user