diff --git a/package.json b/package.json index a281784..86f936d 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "@nostr-dev-kit/ndk": "^2.10.0", "@tailwindcss/forms": "^0.5.8", "autoprefixer": "^10.4.20", + "chart.js": "^4.4.4", + "chartjs-adapter-date-fns": "^3.0.0", + "date-fns": "^4.1.0", "flatpickr": "^4.6.13", "laravel-echo": "^1.16.1", "laravel-vite-plugin": "^1.0", diff --git a/resources/js/app.js b/resources/js/app.js index 1f7bb6e..1d958cd 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -2,6 +2,7 @@ import {Alpine, Livewire} from '../../vendor/livewire/livewire/dist/livewire.esm import nostrApp from "./nostrApp.js"; import nostrLogin from "./nostrLogin.js"; +import electionAdminCharts from "./electionAdminCharts.js"; import './bootstrap'; @@ -18,5 +19,6 @@ Alpine.store('nostr', { Alpine.data('nostrApp', nostrApp); Alpine.data('nostrLogin', nostrLogin); +Alpine.data('electionAdminCharts', electionAdminCharts); Livewire.start(); diff --git a/resources/js/electionAdminCharts.js b/resources/js/electionAdminCharts.js new file mode 100644 index 0000000..e1f0969 --- /dev/null +++ b/resources/js/electionAdminCharts.js @@ -0,0 +1,169 @@ +import {Chart} from "chart.js/auto"; + +export default (livewireComponent) => ({ + plebs: livewireComponent.entangle('plebs', true), + electionConfig: livewireComponent.entangle('electionConfig', true), + votes: livewireComponent.entangle('votes', true), + charts: {}, // Store chart instances + + hexToRGB(h) { + let r = 0; + let g = 0; + let b = 0; + if (h.length === 4) { + r = `0x${h[1]}${h[1]}`; + g = `0x${h[2]}${h[2]}`; + b = `0x${h[3]}${h[3]}`; + } else if (h.length === 7) { + r = `0x${h[1]}${h[2]}`; + g = `0x${h[3]}${h[4]}`; + b = `0x${h[5]}${h[6]}`; + } + return `${+r},${+g},${+b}`; + }, + + init() { + this.createChart('chart_presidency', 'presidency'); + this.createChart('chart_vice_president', 'vice_president'); + this.createChart('chart_finances', 'finances'); + this.createChart('chart_secretary', 'secretary'); + this.createChart('chart_press_officer', 'press_officer'); + this.createChart('chart_it_manager', 'it_manager'); + + this.$watch('votes', () => { + this.createChart('chart_presidency', 'presidency'); + this.createChart('chart_vice_president', 'vice_president'); + this.createChart('chart_finances', 'finances'); + this.createChart('chart_secretary', 'secretary'); + this.createChart('chart_press_officer', 'press_officer'); + this.createChart('chart_it_manager', 'it_manager'); + }); + }, + + createChart(refName, type) { + const ctx = this.$refs[refName]; + if (!ctx) return; + + // Destroy old chart instance if it exists + if (this.charts[refName]) { + this.charts[refName].destroy(); + } + + const darkMode = localStorage.getItem('dark-mode') === 'true'; + + const textColor = { + light: '#9CA3AF', + dark: '#6B7280' + }; + + const gridColor = { + light: '#F3F4F6', + dark: `rgba(${this.hexToRGB('#374151')}, 0.6)` + }; + + const tooltipBodyColor = { + light: '#6B7280', + dark: '#9CA3AF' + }; + + const tooltipBgColor = { + light: '#ffffff', + dark: '#374151' + }; + + const tooltipBorderColor = { + light: '#E5E7EB', + dark: '#4B5563' + }; + + const config = this.electionConfig.find(config => config.type === type); + const labels = config ? config.candidates.map(candidate => candidate.name) : []; + const labelsPubkeys = config ? config.candidates.map(candidate => candidate.pubkey) : []; + const data = this.votes.find(vote => vote.type === type); + const findVoteCountInDataByLabelsPubkey = data ? labelsPubkeys.map(pubkey => data.votes[pubkey]?.count ?? 0) : labelsPubkeys.map(() => 0); + console.log('findVoteCountInDataByLabelsPubkey', findVoteCountInDataByLabelsPubkey); + + // Create new chart instance and store it + this.charts[refName] = new Chart(ctx, { + type: 'bar', + data: { + labels: labels, + datasets: [ + { + label: 'Stimmen', + data: findVoteCountInDataByLabelsPubkey, + backgroundColor: '#67BFFF', + hoverBackgroundColor: '#56B1F3', + barPercentage: 0.7, + categoryPercentage: 0.7, + borderRadius: 4, + }, + ], + }, + options: { + layout: { + padding: { + top: 12, + bottom: 16, + left: 20, + right: 20, + }, + }, + scales: { + y: { + border: {display: false}, + ticks: { + maxTicksLimit: 5, + color: darkMode ? textColor.dark : textColor.light, + }, + grid: { + color: darkMode ? gridColor.dark : gridColor.light, + }, + }, + x: { + border: {display: false}, + grid: {display: false}, + ticks: { + color: darkMode ? textColor.dark : textColor.light, + }, + }, + }, + plugins: { + legend: {display: false}, + htmlLegend: {containerID: 'dashboard-card-01-legend'}, + tooltip: { + bodyColor: darkMode ? tooltipBodyColor.dark : tooltipBodyColor.light, + backgroundColor: darkMode ? tooltipBgColor.dark : tooltipBgColor.light, + borderColor: darkMode ? tooltipBorderColor.dark : tooltipBorderColor.light, + }, + }, + interaction: { + intersect: false, + mode: 'nearest', + }, + animation: {duration: 200}, + maintainAspectRatio: false, + }, + }); + + document.addEventListener('darkMode', (e) => { + const {mode} = e.detail; + if (mode === 'on') { + this.charts[refName].options.scales.x.ticks.color = textColor.dark; + this.charts[refName].options.scales.y.ticks.color = textColor.dark; + this.charts[refName].options.scales.y.grid.color = gridColor.dark; + this.charts[refName].options.plugins.tooltip.bodyColor = tooltipBodyColor.dark; + this.charts[refName].options.plugins.tooltip.backgroundColor = tooltipBgColor.dark; + this.charts[refName].options.plugins.tooltip.borderColor = tooltipBorderColor.dark; + } else { + this.charts[refName].options.scales.x.ticks.color = textColor.light; + this.charts[refName].options.scales.y.ticks.color = textColor.light; + this.charts[refName].options.scales.y.grid.color = gridColor.light; + this.charts[refName].options.plugins.tooltip.bodyColor = tooltipBodyColor.light; + this.charts[refName].options.plugins.tooltip.backgroundColor = tooltipBgColor.light; + this.charts[refName].options.plugins.tooltip.borderColor = tooltipBorderColor.light; + } + this.charts[refName].update('none'); + }); + }, +}); diff --git a/resources/views/pages/association/election/admin/[Election:year].blade.php b/resources/views/pages/association/election/admin/[Election:year].blade.php new file mode 100644 index 0000000..f62b23c --- /dev/null +++ b/resources/views/pages/association/election/admin/[Election:year].blade.php @@ -0,0 +1,235 @@ + null]); +state(['votes' => null]); +state(['events' => null]); +state(['election' => fn() => $election]); +state(['ehrenMitgliederCount' => 0]); +state(['aktiveMitgliederCount' => 0]); +state(['signThisEvent' => '']); +state([ + 'plebs' => fn() + => \App\Models\EinundzwanzigPleb::query() + ->with([ + 'profile', + ]) + ->whereIn('association_status', [3, 4]) + ->orderBy('association_status', 'desc') + ->get() + ->toArray(), +]); +state([ + 'electionConfig' => function () { + return collect(json_decode($this->election->candidates, true, 512, JSON_THROW_ON_ERROR)) + ->map(function ($c) { + $candidates = \App\Models\Profile::query() + ->whereIn('pubkey', $c['c']) + ->get() + ->map(fn($p) + => [ + 'pubkey' => $p->pubkey, + 'name' => $p->name, + 'picture' => $p->picture, + ]); + + return [ + 'type' => $c['type'], + 'c' => $c['c'], + 'candidates' => $candidates, + ]; + }); + }, +]); + +mount(function () { + $plebsCollection = collect($this->plebs); + $this->ehrenMitgliederCount = $plebsCollection->where( + 'association_status', + \App\Enums\AssociationStatus::HONORARY(), + )->count(); + $this->aktiveMitgliederCount = $plebsCollection->where( + 'association_status', + \App\Enums\AssociationStatus::ACTIVE(), + )->count(); + $this->loadEvents(); + $this->loadVotes(); +}); + +on([ + 'nostrLoggedIn' => function ($pubkey) { + $this->currentPubkey = $pubkey; + }, +]); + +on([ + 'echo:votes,.newVote' => function () { + $this->loadEvents(); + $this->loadVotes(); + }, +]); + +$loadVotes = function () { + $votes = collect($this->events) + ->map(function ($event) { + $votedFor = \App\Models\Profile::query() + ->where('pubkey', str($event['content'])->before(',')->toString()) + ->first() + ->toArray(); + + return [ + 'created_at' => $event['created_at'], + 'pubkey' => $event['pubkey'], + 'forpubkey' => $votedFor['pubkey'], + 'type' => str($event['content'])->after(',')->toString(), + ]; + }) + ->sortByDesc('created_at') + ->unique(fn($event) => $event['pubkey'] . $event['type']) + ->values() + ->toArray(); + + $this->votes = collect($votes) + ->groupBy('type') + ->map(fn($votes) + => [ + 'type' => $votes[0]['type'], + 'votes' => collect($votes) + ->groupBy('forpubkey') + ->map(fn($group) => ['count' => $group->count()]) + ->toArray(), + ]) + ->values() + ->toArray(); +}; + +$loadEvents = function () { + $subscription = new Subscription(); + $subscriptionId = $subscription->setId(); + + $filter1 = new Filter(); + $filter1->setKinds([2121]); // You can add multiple kind numbers + $filters = [$filter1]; // You can add multiple filters. + + $requestMessage = new RequestMessage($subscriptionId, $filters); + + $relays = [ + new Relay('ws://relay:7000'), + ]; + $relaySet = new RelaySet(); + $relaySet->setRelays($relays); + + $request = new Request($relaySet, $requestMessage); + $response = $request->send(); + + $this->events = collect($response['ws://relay:7000']) + ->map(fn($event) + => [ + 'id' => $event->event->id, + 'kind' => $event->event->kind, + 'content' => $event->event->content, + 'pubkey' => $event->event->pubkey, + 'tags' => $event->event->tags, + 'created_at' => $event->event->created_at, + ]) + ->toArray(); +}; + +?> + + + @volt + @php + $positions = [ + 'presidency' => ['icon' => 'fa-crown', 'title' => 'Präsidium'], + 'vice_president' => ['icon' => 'fa-user-group-crown', 'title' => 'Vizepräsidium'], + 'finances' => ['icon' => 'fa-bitcoin-sign', 'title' => 'Finanzen'], + 'secretary' => ['icon' => 'fa-stapler', 'title' => 'Sekretär (Akurat)'], + 'press_officer' => ['icon' => 'fa-newspaper', 'title' => 'Pressewart'], + 'it_manager' => ['icon' => 'fa-server', 'title' => 'Technikwart'], + ]; + @endphp + +
+ + +
+ + +
+

+ Wahl des Vorstands {{ $election->year }} +

+
+ +
+ + +
+ + @foreach($positions as $key => $position) +
+
+

{{ $position['title'] }} +

+
+
+
    +
  • + +
  • +
  • + +
  • +
+
+
+ + +
+
+ @endforeach + +
+ +
+ + @endvolt +
diff --git a/yarn.lock b/yarn.lock index 318e522..43d4c15 100644 --- a/yarn.lock +++ b/yarn.lock @@ -166,6 +166,11 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@kurkle/color@^0.3.0": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@kurkle/color/-/color-0.3.2.tgz#5acd38242e8bde4f9986e7913c8fdf49d3aa199f" + integrity sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw== + "@noble/ciphers@^0.5.1": version "0.5.3" resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-0.5.3.tgz#48b536311587125e0d0c1535f73ec8375cd76b23" @@ -483,6 +488,18 @@ caniuse-lite@^1.0.30001646, caniuse-lite@^1.0.30001663: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001664.tgz#d588d75c9682d3301956b05a3749652a80677df4" integrity sha512-AmE7k4dXiNKQipgn7a2xg558IRqPN3jMQY/rOsbxDhrd0tyChwbITBfiwtnqz8bi2M5mIWbxAYBvk7W7QBUS2g== +chart.js@^4.4.4: + version "4.4.4" + resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-4.4.4.tgz#b682d2e7249f7a0cbb1b1d31c840266ae9db64b7" + integrity sha512-emICKGBABnxhMjUjlYRR12PmOXhJ2eJjEHL2/dZlWjxRAZT1D8xplLFq5M0tMQK8ja+wBS/tuVEJB5C6r7VxJA== + dependencies: + "@kurkle/color" "^0.3.0" + +chartjs-adapter-date-fns@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz#c25f63c7f317c1f96f9a7c44bd45eeedb8a478e5" + integrity sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg== + chokidar@^3.5.3: version "3.6.0" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" @@ -542,6 +559,11 @@ data-uri-to-buffer@^4.0.0: resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e" integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A== +date-fns@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-4.1.0.tgz#64b3d83fff5aa80438f5b1a633c2e83b8a1c2d14" + integrity sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg== + debug@^2.2.0: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"