Browse Source

feat: enhance `SystemSettingKey` and refactor Alpine plugins

- Add `CURRENT_TERM` to `SystemSettingKey` with dynamic selection using `SelectTermQuery`.
- Refactor Alpine plugins (`chart`, `quill`, `select2`) to improve configurability, compatibility with Livewire, and dynamic updates.
- Clean up Blade views for streamlined script injection.
master
Syarif Ubaidillah 3 months ago
parent
commit
33da380c20
  1. 11
      app/Domains/System/Enums/SystemSettingKey.php
  2. 223
      resources/js/alpine/chart.js
  3. 136
      resources/js/alpine/quill.js
  4. 274
      resources/js/alpine/select2.js
  5. 40
      resources/js/alpinejs.js
  6. 4
      resources/views/pages/finance/invoice/⚡form-modal/form-modal.blade.php
  7. 3
      resources/views/pages/finance/product-mapping/⚡form-modal/form-modal.blade.php
  8. 2
      resources/views/pages/identity/users/index.blade.php
  9. 4
      resources/views/pages/identity/users/⚡role-selection-modal/role-selection-modal.blade.php
  10. 87
      resources/views/widgets/charts/⚡role-distribution.blade.php
  11. 56
      resources/views/widgets/charts/⚡user-growth.blade.php

11
app/Domains/System/Enums/SystemSettingKey.php

@ -2,6 +2,7 @@
namespace App\Domains\System\Enums; namespace App\Domains\System\Enums;
use App\Domains\Academic\Queries\Selection\SelectTermQuery;
use App\Domains\System\Traits\Enum\HasPredicateMethod; use App\Domains\System\Traits\Enum\HasPredicateMethod;
use App\UI\Enums\Concerns\InteractsWithLabels; use App\UI\Enums\Concerns\InteractsWithLabels;
use App\UI\Enums\Contracts\HasLabel; use App\UI\Enums\Contracts\HasLabel;
@ -25,6 +26,7 @@ enum SystemSettingKey: string implements HasLabel, HasSchema
case WEB_ADDRESS = 'web-address'; case WEB_ADDRESS = 'web-address';
case DEFAULT_LANGUAGE = 'default_language'; case DEFAULT_LANGUAGE = 'default_language';
case TIMEZONE = 'timezone'; case TIMEZONE = 'timezone';
case CURRENT_TERM = 'current_term';
case GOOGLE_TAG_MANAGER_ID = 'google-tag_manager_id'; case GOOGLE_TAG_MANAGER_ID = 'google-tag_manager_id';
case GOOGLE_WEBMASTER_ID = 'google-webmaster_id'; case GOOGLE_WEBMASTER_ID = 'google-webmaster_id';
@ -84,6 +86,13 @@ enum SystemSettingKey: string implements HasLabel, HasSchema
'id' => __('domains/system/enum.system_setting_key_options.default_language.id'), 'id' => __('domains/system/enum.system_setting_key_options.default_language.id'),
]), ]),
self::CURRENT_TERM => InputSchema::make()
->type(InputType::SELECT)
->default('en')
->label($this->label())
->select2()
->options(fn() => SelectTermQuery::fetch()),
self::TIMEZONE => InputSchema::make() self::TIMEZONE => InputSchema::make()
->type(InputType::SELECT) ->type(InputType::SELECT)
->default('UTC') ->default('UTC')
@ -112,7 +121,7 @@ enum SystemSettingKey: string implements HasLabel, HasSchema
], ],
], ],
[ [
__('domains/system/pages.settings.sections.general') => [self::DEFAULT_LANGUAGE, self::TIMEZONE], __('domains/system/pages.settings.sections.general') => [self::DEFAULT_LANGUAGE, self::TIMEZONE, self::CURRENT_TERM],
__('domains/system/pages.settings.sections.webmaster') => [self::GOOGLE_TAG_MANAGER_ID, self::GOOGLE_WEBMASTER_ID], __('domains/system/pages.settings.sections.webmaster') => [self::GOOGLE_TAG_MANAGER_ID, self::GOOGLE_WEBMASTER_ID],
], ],
]; ];

223
resources/js/alpine/chart.js

@ -1,25 +1,212 @@
import '../plugin/apexchart.js'
export default function AlpineChart(Alpine) { export default function AlpineChart(Alpine) {
Alpine.directive('chart', function (el, { expression }, { evaluate, cleanup }) { Alpine.directive(
let config = evaluate(expression); "chart",
function (el, { expression }, { evaluateLater, cleanup }) {
const getContent = evaluateLater(expression);
import("apexcharts").then(({ default: ApexCharts }) => {
getContent((userConfig) => {
if (!userConfig || typeof userConfig !== "object") return;
// 1. Fetch live computed CSS variables directly from :root (document.documentElement)
const getCoreUIColors = () => {
const style = getComputedStyle(document.body);
// CoreUI v4/v5 & Bootstrap 5 theme variable names
const bodyColor =
style.getPropertyValue("--cui-body-color").trim() ||
style.getPropertyValue("--bs-body-color").trim() ||
"#a6b0cf";
const secondaryColor =
style
.getPropertyValue("--cui-secondary-color")
.trim() ||
style
.getPropertyValue("--bs-secondary-color")
.trim() ||
"#8a93a2";
const borderColor =
style
.getPropertyValue("--cui-border-color")
.trim() ||
style
.getPropertyValue(
"--bs-border-color-translucent",
)
.trim() ||
style
.getPropertyValue("--bs-border-color")
.trim() ||
"rgba(255, 255, 255, 0.08)";
const primaryColor =
style.getPropertyValue("--cui-primary").trim() ||
style.getPropertyValue("--bs-primary").trim() ||
"#6366f1";
const theme =
document.documentElement.getAttribute(
"data-coreui-theme",
) ||
document.documentElement.getAttribute(
"data-bs-theme",
) ||
"dark";
return {
bodyColor,
secondaryColor,
borderColor,
primaryColor,
theme,
};
};
const colors = getCoreUIColors();
// 2. Base Configuration explicitly overriding ApexCharts SVG text fills
const baseConfig = {
chart: {
type: "line",
height: 360,
foreColor: colors.secondaryColor, // Sets default text/legend colors
background: "transparent",
},
colors: [colors.primaryColor],
theme: {
mode: colors.theme,
},
tooltip: {
theme: colors.theme,
},
grid: {
borderColor: colors.borderColor,
},
xaxis: {
labels: {
style: {
colors: colors.secondaryColor,
fontSize: "12px",
},
},
axisBorder: { color: colors.borderColor },
axisTicks: { color: colors.borderColor },
},
yaxis: {
labels: {
style: {
colors: colors.secondaryColor,
fontSize: "12px",
},
},
},
};
// Deep merge user options with base configuration
const options = Object.assign({}, baseConfig, userConfig);
options.chart = Object.assign(
{},
baseConfig.chart,
userConfig.chart,
);
options.theme = Object.assign(
{},
baseConfig.theme,
userConfig.theme,
);
options.tooltip = Object.assign(
{},
baseConfig.tooltip,
userConfig.tooltip,
);
// Preserve user primary colors if explicitly provided in PHP, otherwise use CoreUI's
if (!userConfig.colors) {
options.colors = baseConfig.colors;
}
requestAnimationFrame(() => {
const chart = new ApexCharts(el, options);
chart.render();
// 3. Theme switch handler
const observer = new MutationObserver(() => {
const updated = getCoreUIColors();
const chart = new window.ApexCharts(el, config); chart.updateOptions(
chart.render(); {
chart: {
foreColor: updated.secondaryColor,
},
theme: { mode: updated.theme },
tooltip: { theme: updated.theme },
grid: { borderColor: updated.borderColor },
xaxis: {
labels: {
style: {
colors: updated.secondaryColor,
},
},
axisBorder: {
color: updated.borderColor,
},
axisTicks: {
color: updated.borderColor,
},
},
yaxis: {
labels: {
style: {
colors: updated.secondaryColor,
},
},
},
},
false,
true,
);
});
const event = function(event) { observer.observe(document.documentElement, {
const newData = event.detail attributes: true,
chart.updateSeries(newData.series, true, true); attributeFilter: [
chart.updateOptions(newData.options, true, true); "data-coreui-theme",
} "data-bs-theme",
"class",
],
});
name = el.getAttribute('id'); // 4. Dynamic update event listener
const name = el.getAttribute("id");
const eventHandler = (event) => {
const detail = event.detail || event;
if (detail.series)
chart.updateSeries(detail.series, true);
if (detail.options)
chart.updateOptions(detail.options, true);
};
window.addEventListener(`chart-${name}-update`, event) if (name) {
window.addEventListener(
`chart-${name}-update`,
eventHandler,
);
}
cleanup(() => { cleanup(() => {
chart.destroy(); observer.disconnect();
window.removeEventListener(`chart-${name}-update`, event) if (name)
}); window.removeEventListener(
}) `chart-${name}-update`,
eventHandler,
);
chart.destroy();
});
});
});
});
},
);
} }

136
resources/js/alpine/quill.js

@ -1,46 +1,94 @@
import Quill from "quill";
import "quill/dist/quill.snow.css";
export default function alpineQuill(Alpine) { export default function alpineQuill(Alpine) {
Alpine.directive('quill', function (el, { expression }, { evaluate, cleanup }) { Alpine.directive(
const options = expression ? evaluate(expression) : {}; "quill",
const defaultOptions = { theme: 'snow', ...options }; (el, { expression }, { evaluateLater, cleanup }) => {
// 1. Fallback gracefully if x-quill has no parameters
if (typeof window.Quill === 'undefined') { const getContent = expression
throw new Error('Quill must be loaded'); ? evaluateLater(expression)
} : (cb) => cb({});
const container = document.createElement('div'); getContent((userOptions) => {
el.appendChild(container); const options = Object.assign(
const quill = new window.Quill(container, defaultOptions); {
theme: "snow",
let isUpdating = false; placeholder: "Write something...",
},
// 1. Text changes handler typeof userOptions === "object" ? userOptions : {},
quill.on('text-change', () => { );
if (isUpdating) return;
isUpdating = true; // 2. Identify or create container DIV (Quill crashes on <textarea>)
const htmlContent = quill.root.innerHTML; let editorContainer;
el.value = htmlContent === '<p><br></p>' ? '' : htmlContent; let targetInput = el;
el.dispatchEvent(new Event('input', { bubbles: true }));
isUpdating = false; if (el.tagName === "TEXTAREA" || el.tagName === "INPUT") {
}); // Create a container DIV for Quill right before the textarea
editorContainer = document.createElement("div");
// 2. Element Value Getter/Setter el.parentNode.insertBefore(editorContainer, el);
Object.defineProperty(el, 'value', {
get() { return quill.root.innerHTML; }, // Hide the textarea without breaking Livewire bindings
set(newValue) { el.style.display = "none";
if (isUpdating || newValue === quill.root.innerHTML) return; } else {
isUpdating = true; editorContainer = el;
quill.root.innerHTML = newValue || ''; }
isUpdating = false;
}, // 3. Instantiate Quill on the container DIV
configurable: true const quill = new Quill(editorContainer, options);
});
// Populate initial content from textarea / Alpine model
quill.on('selection-change', (range) => { if (targetInput.value && targetInput.value !== "<p><br></p>") {
if (range == null) { quill.root.innerHTML = targetInput.value;
el.dispatchEvent(new Event('blur', { bubbles: true })); }
}
}); let isUpdating = false;
cleanup(() => el.innerHTML = '') // 4. Sync Quill edits -> hidden input -> Livewire
}) quill.on("text-change", () => {
if (isUpdating) return;
isUpdating = true;
const html = quill.root.innerHTML;
const value = html === "<p><br></p>" ? "" : html;
targetInput.value = value;
// Dispatch input & change events on the target input so Livewire detects it
targetInput.dispatchEvent(
new Event("input", { bubbles: true }),
);
targetInput.dispatchEvent(
new Event("change", { bubbles: true }),
);
isUpdating = false;
});
// 5. Sync Livewire dynamic resets -> Quill
const handleExternalUpdate = () => {
if (isUpdating) return;
if (targetInput.value !== quill.root.innerHTML) {
isUpdating = true;
quill.root.innerHTML = targetInput.value || "";
isUpdating = false;
}
};
targetInput.addEventListener("input", handleExternalUpdate);
// 6. Cleanup DOM when Livewire removes the element
cleanup(() => {
targetInput.removeEventListener(
"input",
handleExternalUpdate,
);
if (editorContainer !== el) {
editorContainer.remove();
el.style.display = "";
}
});
});
},
);
} }

274
resources/js/alpine/select2.js

@ -4,137 +4,157 @@ export default function alpineSelect2(Alpine) {
function (el, { expression }, { evaluate, cleanup }) { function (el, { expression }, { evaluate, cleanup }) {
let config = evaluate(expression) || {}; let config = evaluate(expression) || {};
if ( Promise.all([import("../plugin/select2.js")]).then(
(window.jQuery || window.jquery || window.$) && ([select2]) => {
typeof window.$.fn.select2 !== "undefined" if (
) { (window.jQuery || window.jquery || window.$) &&
let modalContainer = el.closest(".modal"); typeof window.$.fn.select2 !== "undefined"
let select2Default = { ) {
placeholder: config.placeholder || "Select an option", let modalContainer = el.closest(".modal");
dropdownParent: config.dropdownParent || modalContainer || el.parentElement, let select2Default = {
allowClear: config.allowClear ?? true, placeholder:
ajax: config.url config.placeholder || "Select an option",
? { dropdownParent:
url: config.url, config.dropdownParent ||
dataType: "json", modalContainer ||
delay: 250, el.parentElement,
xhrFields: { allowClear: config.allowClear ?? true,
withCredentials: true, ajax: config.url
}, ? {
data: function (params) { url: config.url,
return { dataType: "json",
search: params.terms, delay: 250,
}; xhrFields: {
}, withCredentials: true,
processResults: function (data) { },
return { data: function (params) {
results: data.data || data, return {
}; search: params.terms,
}, };
} },
: undefined, processResults: function (data) {
}; return {
results: data.data || data,
const container = document.createElement("div"); };
el.parentElement.insertBefore(container, el); },
}
// Keep wire:ignore on container so Livewire DOM diffing does not destroy Select2 UI : undefined,
container.setAttribute("wire:ignore", ""); };
container.appendChild(el);
const container = document.createElement("div");
config = Object.assign(select2Default, config); el.parentElement.insertBefore(container, el);
let $el = $(el).select2(config);
// Keep wire:ignore on container so Livewire DOM diffing does not destroy Select2 UI
const $select2Container = $el.next(".select2-container"); container.setAttribute("wire:ignore", "");
container.appendChild(el);
const modelName =
el.getAttribute("wire:model") || config = Object.assign(select2Default, config);
el.getAttribute("wire:model.live") || let $el = $(el).select2(config);
el.getAttribute("wire:model.blur") ||
"select2"; const $select2Container =
$el.next(".select2-container");
let isUpdating = false;
const modelName =
$el.on("change", (e) => { el.getAttribute("wire:model") ||
if (isUpdating) return; el.getAttribute("wire:model.live") ||
isUpdating = true; el.getAttribute("wire:model.blur") ||
el.dispatchEvent(new Event("change", { bubbles: true })); "select2";
isUpdating = false;
}); let isUpdating = false;
// Fix for allowClear: prevent dropdown from opening when clicking the 'x' $el.on("change", (e) => {
$el.on("select2:unselecting", function (e) { if (isUpdating) return;
$(this).data("unselecting", true);
}).on("select2:opening", function (e) {
if ($(this).data("unselecting")) {
$(this).removeData("unselecting");
e.preventDefault();
}
});
const event = (e) => {
$el.val(null).trigger("change", { bubbles: true });
};
window.addEventListener(`${modelName}-clear`, event);
// Sync error state (is-invalid) from native select to Select2 container
const syncErrorState = () => {
if (el.classList.contains("is-invalid")) {
container.classList.add("is-invalid");
$select2Container
.find(".select2-selection")
.addClass("is-invalid border-danger");
} else {
container.classList.remove("is-invalid");
$select2Container
.find(".select2-selection")
.removeClass("is-invalid border-danger");
}
};
// Run initial check on load
syncErrorState();
// Observe attribute changes on the native <select> element
const observer = new MutationObserver((mutations) => {
if (isUpdating) return;
mutations.forEach((mutation) => {
// Update Select2 UI if value attribute changes
if (mutation.attributeName === "value") {
isUpdating = true; isUpdating = true;
$el.trigger("change.select2"); el.dispatchEvent(
new Event("change", { bubbles: true }),
);
isUpdating = false; isUpdating = false;
});
// Fix for allowClear: prevent dropdown from opening when clicking the 'x'
$el.on("select2:unselecting", function (e) {
$(this).data("unselecting", true);
}).on("select2:opening", function (e) {
if ($(this).data("unselecting")) {
$(this).removeData("unselecting");
e.preventDefault();
}
});
const event = (e) => {
$el.val(null).trigger("change", { bubbles: true });
};
window.addEventListener(`${modelName}-clear`, event);
// Sync error state (is-invalid) from native select to Select2 container
const syncErrorState = () => {
if (el.classList.contains("is-invalid")) {
container.classList.add("is-invalid");
$select2Container
.find(".select2-selection")
.addClass("is-invalid border-danger");
} else {
container.classList.remove("is-invalid");
$select2Container
.find(".select2-selection")
.removeClass("is-invalid border-danger");
}
};
// Run initial check on load
syncErrorState();
// Observe attribute changes on the native <select> element
const observer = new MutationObserver((mutations) => {
if (isUpdating) return;
mutations.forEach((mutation) => {
// Update Select2 UI if value attribute changes
if (mutation.attributeName === "value") {
isUpdating = true;
$el.trigger("change.select2");
isUpdating = false;
}
// Sync error styling if class attribute changes (e.g. is-invalid added/removed)
if (mutation.attributeName === "class") {
syncErrorState();
}
});
});
observer.observe(el, {
attributes: true,
attributeFilter: ["value", "class"],
});
// Listen to Livewire DOM updates to force error styling inside wire:ignore wrapper
if (window.Livewire) {
Livewire.hook(
"morph.updated",
({ el: updatedEl }) => {
if (
updatedEl === el ||
updatedEl.contains(el)
) {
syncErrorState();
}
},
);
} }
// Sync error styling if class attribute changes (e.g. is-invalid added/removed) cleanup(() => {
if (mutation.attributeName === "class") { $el.select2("destroy");
syncErrorState(); window.removeEventListener(
} `${modelName}-clear`,
}); event,
}); );
observer.disconnect();
observer.observe(el, { });
attributes: true, }
attributeFilter: ["value", "class"], },
}); );
// Listen to Livewire DOM updates to force error styling inside wire:ignore wrapper
if (window.Livewire) {
Livewire.hook("morph.updated", ({ el: updatedEl }) => {
if (updatedEl === el || updatedEl.contains(el)) {
syncErrorState();
}
});
}
cleanup(() => {
$el.select2("destroy");
window.removeEventListener(`${modelName}-clear`, event);
observer.disconnect();
});
}
}, },
); );
} }

40
resources/js/alpinejs.js

@ -1,23 +1,23 @@
// resources/js/app.js // resources/js/app.js
import { Alpine, Livewire } from '../../vendor/livewire/livewire/dist/livewire.esm'; import { Alpine, Livewire } from '../../vendor/livewire/livewire/dist/livewire.esm';
import bs from './alpine/bs'; import bs from './alpine/bs';
import alpineAxios from './alpine/axios'; import alpineAxios from './alpine/axios';
import alpineSelect2 from "./alpine/select2.js"; import alpineSelect2 from "./alpine/select2.js";
import alpineQuill from "./alpine/quill.js"; import alpineQuill from "./alpine/quill.js";
import alpineAsk from "./alpine/ask.js"; import alpineAsk from "./alpine/ask.js";
import AlpineChart from "./alpine/chart.js"; import AlpineChart from "./alpine/chart.js";
// 1. Expose both instances globally so Blade directives can see them // 1. Expose both instances globally so Blade directives can see them
window.Alpine = Alpine; window.Alpine = Alpine;
window.Livewire = Livewire; window.Livewire = Livewire;
// 2. Wrap plugin registration inside the initialization event listener // 2. Wrap plugin registration inside the initialization event listener
Alpine.plugin(bs); Alpine.plugin(bs);
Alpine.plugin(alpineAxios); Alpine.plugin(alpineAxios);
Alpine.plugin(alpineAsk); Alpine.plugin(alpineAsk);
Alpine.plugin(alpineSelect2); Alpine.plugin(alpineSelect2);
Alpine.plugin(alpineQuill); Alpine.plugin(alpineQuill);
Alpine.plugin(AlpineChart); Alpine.plugin(AlpineChart);
// 3. Boot Livewire // 3. Boot Livewire
Livewire.start(); Livewire.start();

4
resources/views/pages/finance/invoice/⚡form-modal/form-modal.blade.php

@ -57,10 +57,6 @@
@endif @endif
</div> </div>
@push('scripts')
@vite(['resources/js/plugin/select2.js'])
@endpush
<x-slot:footer> <x-slot:footer>
<x-button type="button" theme="secondary" :label="__('ui/button.cancel')" data-bs-dismiss="modal"/> <x-button type="button" theme="secondary" :label="__('ui/button.cancel')" data-bs-dismiss="modal"/>
<x-button type="submit" theme="primary" :label="__('ui/button.save')"/> <x-button type="submit" theme="primary" :label="__('ui/button.save')"/>

3
resources/views/pages/finance/product-mapping/⚡form-modal/form-modal.blade.php

@ -20,9 +20,6 @@
/> />
</div> </div>
@push('scripts')
@vite('resources/js/plugin/select2.js')
@endpush
<x-slot:footer> <x-slot:footer>
<x-button type="button" theme="secondary" :label="__('ui/button.cancel')" data-bs-dismiss="modal" /> <x-button type="button" theme="secondary" :label="__('ui/button.cancel')" data-bs-dismiss="modal" />
<x-button type="submit" theme="primary" :label="__('ui/button.save')" /> <x-button type="submit" theme="primary" :label="__('ui/button.save')" />

2
resources/views/pages/identity/users/index.blade.php

@ -41,7 +41,7 @@
/> />
@push('page-scripts') @push('page-scripts')
@vite(['resources/js/plugin/datatables.js', 'resources/js/plugin/select2.js']) @vite(['resources/js/plugin/datatables.js'])
{{ $dataTable->scripts(attributes: ['type' => 'module']) }} {{ $dataTable->scripts(attributes: ['type' => 'module']) }}
@endpush @endpush
</x-layouts.app> </x-layouts.app>

4
resources/views/pages/identity/users/⚡role-selection-modal/role-selection-modal.blade.php

@ -16,8 +16,4 @@
<x-button type="button" theme="secondary" :label="__('ui/button.cancel')" data-bs-dismiss="modal" /> <x-button type="button" theme="secondary" :label="__('ui/button.cancel')" data-bs-dismiss="modal" />
<x-button type="submit" theme="primary" :label="__('ui/button.save')" /> <x-button type="submit" theme="primary" :label="__('ui/button.save')" />
</x-slot:footer> </x-slot:footer>
@push('scripts')
@vite(['resources/js/plugin/select2.js'])
@endpush
</x-modal> </x-modal>

87
resources/views/widgets/charts/⚡role-distribution.blade.php

@ -6,26 +6,49 @@ use Livewire\Component;
new class extends Component new class extends Component
{ {
public function mount(): void #[Computed]
public function roleDistributions(): array
{ {
$this->dispatch('chart-role-distribution-update', return app(GetRoleDistributions::class)->fetch();
series: $this->roleDistributions['series'],
options: [
'labels' => $this->roleDistributions['categories'],
]
);
} }
#[Computed] #[Computed]
public function roleDistributions(): array public function chartOptions(): array
{ {
return app(GetRoleDistributions::class)->fetch(); return [
'chart' => [
'type' => 'pie',
'width' => '100%',
'height' => 360,
],
'series' => $this->roleDistributions['series'],
'labels' => $this->roleDistributions['categories'],
'plotOptions' => [
'pie' => [
'dataLabels' => [
'offset' => 1,
],
],
],
'theme' => [
'monochrome' => [
'enabled' => true,
],
],
'dataLabels' => [
'formatter' => 'JS_RAW_FORMATTER', // Replaced in Blade wrapper
],
'legend' => [
'show' => false,
],
];
} }
}; };
?> ?>
<div class="card bg-opacity-10 border-opacity-25 rounded-2 border"> <div class="card bg-opacity-10 border-opacity-25 rounded-2 border">
<div class="card-body"> <div class="card-body">
<div class="d-flex gap-3"> <div class="d-flex gap-3 mb-3">
<div class="icon-shape icon-md bg-secondary rounded-2 text-white"> <div class="icon-shape icon-md bg-secondary rounded-2 text-white">
@svg('tabler-user-shield', [ @svg('tabler-user-shield', [
'class' => 'fs-4', 'class' => 'fs-4',
@ -38,43 +61,19 @@ new class extends Component
</p> </p>
</div> </div>
</div> </div>
{{-- Direct JSON data binding with JS closure support for dataLabels --}}
<div <div
id="role-distribution" id="role-distribution"
x-chart="{ x-chart="{
series: [], ...{{ json_encode($this->chartOptions) }},
chart: { dataLabels: {
width: 450, formatter(val, opts) {
type: 'pie', const name = opts.w.globals.labels[opts.seriesIndex];
}, return [name, val.toFixed(1) + '%'];
labels: [], }
plotOptions: { }
pie: { }"
dataLabels: {
offset: 1,
},
},
},
theme: {
monochrome: {
enabled: true,
},
},
dataLabels: {
formatter(val, opts) {
const name = opts.w.globals.labels[opts.seriesIndex];
return [name, val.toFixed(1)+'%'];
},
},
legend: {
show: false
}
}"
></div> ></div>
</div> </div>
</div> </div>
@once
@push('page-scripts')
@vite('resources/js/plugin/apexchart.js')
@endpush
@endonce

56
resources/views/widgets/charts/⚡user-growth.blade.php

@ -6,35 +6,39 @@ use Livewire\Component;
new class extends Component new class extends Component
{ {
public function mount(): void #[Computed]
public function userGrowth(): array
{ {
$this->dispatch('chart-user-growth-update', return app(GetUserGrowthTrends::class)->fetch();
series: [[
'name' => __('domains/identity/dashboard.user_growth.series_name'),
'data' => $this->userGrowth['series'],
]],
options: [
'xaxis' => [
'categories' => $this->userGrowth['categories'],
],
]
);
} }
#[Computed] #[Computed]
public function userGrowth(): array public function chartOptions(): array
{ {
return app(GetUserGrowthTrends::class)->fetch(); return [
'chart' => [
'type' => 'line',
'height' => 360,
'toolbar' => ['show' => false],
],
'series' => [[
'name' => __('domains/identity/dashboard.user_growth.series_name'),
'data' => $this->userGrowth['series'],
]],
'xaxis' => [
'categories' => $this->userGrowth['categories'],
],
'colors' => ['#4f46e5'],
];
} }
}; };
?> ?>
<div class="card bg-opacity-10 border-opacity-25 rounded-2 border"> <div class="card bg-opacity-10 border-opacity-25 rounded-2 border">
<div class="card-body"> <div class="card-body">
<div class="d-flex gap-3"> <div class="d-flex gap-3">
<div class="icon-shape icon-md bg-secondary rounded-2 text-white"> <div class="icon-shape icon-md bg-secondary rounded-2 text-white">
@svg('tabler-user-pin', [ @svg('tabler-user-pin', ['class' => 'fs-4'])
'class' => 'fs-4',
])
</div> </div>
<div> <div>
<h2 class="fs-4">{{ __('domains/identity/dashboard.user_growth.title') }}</h2> <h2 class="fs-4">{{ __('domains/identity/dashboard.user_growth.title') }}</h2>
@ -43,23 +47,11 @@ new class extends Component
</p> </p>
</div> </div>
</div> </div>
{{-- Direct JSON binding ensures initial render --}}
<div <div
id="user-growth" id="user-growth"
x-chart="{ x-chart='@json($this->chartOptions)'
chart: {
type: 'line',
height: 360,
toolbar: {show: false}
},
series: [],
colors: ['#4f46e5']
}"
></div> ></div>
</div> </div>
</div> </div>
@once
@push('page-scripts')
@vite('resources/js/plugin/apexchart.js')
@endpush
@endonce

Loading…
Cancel
Save