First basic statistics graph generation using Chart.js

This commit is contained in:
Timothée Jaussoin 2023-07-27 15:23:53 +00:00
parent 0729718ccf
commit f8bde4345f
25 changed files with 496 additions and 504 deletions

View file

@ -21,6 +21,7 @@ namespace App\Http\Controllers\Admin;
use App\ContactsList;
use App\Http\Controllers\Controller;
use Illuminate\Validation\Rule;
use Illuminate\Http\Request;
class ContactsListController extends Controller
@ -49,8 +50,7 @@ class ContactsListController extends Controller
public function store(Request $request)
{
$request->validate([
'title' => 'required',
'description' => 'required'
'title' => 'required|unique:contacts_lists'
]);
$contactsList = new ContactsList;
@ -71,8 +71,10 @@ class ContactsListController extends Controller
public function update(Request $request, int $id)
{
$request->validate([
'title' => 'required',
'description' => 'required'
'title' => [
'required',
Rule::unique('contacts_lists')->ignore($id),
],
]);
$contactsList = ContactsList::findOrFail($id);

View file

@ -2,52 +2,218 @@
namespace App\Http\Controllers\Admin;
use App\Account;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Libraries\StatisticsCruncher;
use App\StatisticsMessage;
use Carbon\Carbon;
use Carbon\CarbonInterval;
use Carbon\CarbonPeriod;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class StatisticsController extends Controller
{
public function showDay(Request $request)
public function index(Request $request)
{
$day = StatisticsCruncher::day();
$maxDay = 0;
foreach ($day as $hour) {
if ($maxDay < $hour['all']) $maxDay = $hour['all'];
}
return view('admin.statistics.show_day', [
'day' => $day,
'max_day' => $maxDay,
return redirect()->route('admin.statistics.show', [
'type' => 'messages'
]);
}
public function showWeek(Request $request)
public function edit(Request $request)
{
$week = StatisticsCruncher::week();
$maxWeek = 0;
foreach ($week as $day) {
if ($maxWeek < $day['all']) $maxWeek = $day['all'];
}
return view('admin.statistics.show_week', [
'week' => $week,
'max_week' => $maxWeek,
return redirect()->route('admin.statistics.show', [
'from' => $request->get('from'),
'type' => $request->get('type'),
'to' => $request->get('to'),
'by' => $request->get('by'),
]);
}
public function showMonth(Request $request)
public function show(Request $request, string $type = 'messages')
{
$month = StatisticsCruncher::month();
$maxMonth = 0;
foreach ($month as $day) {
if ($maxMonth < $day['all']) $maxMonth = $day['all'];
$request->validate([
'from' => 'date_format:Y-m-d|before:to',
'to' => 'date_format:Y-m-d|after:from',
'by' => 'in:day,week,month,year',
]);
$dateColumn = 'created_at';
$label = 'Label';
switch ($type) {
case 'messages':
$dateColumn = 'sent_at';
$label = 'Messages';
$data = StatisticsMessage::orderBy($dateColumn, 'asc');
break;
case 'accounts':
$label = 'Accounts';
$data = Account::orderBy($dateColumn, 'asc');
break;
}
return view('admin.statistics.show_month', [
'month' => $month,
'max_month' => $maxMonth,
$data = $data->groupBy('moment')
->orderBy('moment', 'desc')
->setEagerLoads([]);
if ($request->get('to')) {
$data = $data->where($dateColumn, '<=', $request->get('to'));
}
$by = $request->get('by', 'day');
switch ($by) {
case 'day':
$data = $data->where($dateColumn, '>=', $request->get('from', Carbon::now()->subDay()->format('Y-m-d H:i:s')))
->get([
DB::raw("date_format(" . $dateColumn . ",'%Y-%m-%d %H') as moment"),
DB::raw('COUNT(*) as "count"')
]);
break;
case 'week':
$data = $data->where($dateColumn, '>=', $request->get('from', Carbon::now()->subWeek()->format('Y-m-d H:i:s')))
->get([
DB::raw("date_format(" . $dateColumn . ",'%Y-%m-%d') as moment"),
DB::raw('COUNT(*) as "count"')
]);
break;
case 'month':
$data = $data->where($dateColumn, '>=', $request->get('from', Carbon::now()->subMonth()->format('Y-m-d H:i:s')))
->get([
DB::raw("date_format(" . $dateColumn . ",'%Y-%m-%d') as moment"),
DB::raw('COUNT(*) as "count"')
]);
break;
case 'year':
$data = $data->where($dateColumn, '>=', $request->get('from', Carbon::now()->subYear()->format('Y-m-d H:i:s')))
->get([
DB::raw("date_format(" . $dateColumn . ",'%Y-%m') as moment"),
DB::raw('COUNT(*) as "count"')
]);
break;
}
$data = $data->each->setAppends([])->pluck('count', 'moment');
$data = $this->compileStatistics(
$by,
$request->get('from'),
$request->get('to'),
$data
);
if ($request->get('export', false)) {
$file = fopen('php://output', 'w');
$callback = function () use ($data, $file) {
foreach ($data as $key => $value) {
fputcsv($file, [$key, $value]);
}
fclose($file);
};
return response()->stream($callback, 200, [
"Content-type" => "text/csv",
"Content-Disposition" => "attachment; filename=export.csv",
"Pragma" => "no-cache",
"Cache-Control" => "must-revalidate, post-check=0, pre-check=0",
"Expires" => "0"
]);
}
$config = [
'type' => 'bar',
'data' => [
'labels' => $data->keys()->toArray(),
'datasets' => [[
'label' => $label,
'borderColor' => 'rgba(108, 122, 135, 1)',
'backgroundColor' => 'rgba(108, 122, 135, 1)',
'data' => $data->values()->toArray(),
'order' => 1
]]
],
'options' => [
'maintainAspectRatio' => false,
'spanGaps' => true,
'legend' => [
'position' => 'right'
],
'scales' => [
'y' => [
'stacked' => true,
'title' => [
'display' => true,
'text' => $label
]
],
'x' => [
'stacked' => true,
]
],
'interaction' => [
'mode' => 'nearest',
'axis' => 'x',
'intersect' => false
],
]
];
return view('admin.statistics.show', [
'jsonConfig' => json_encode($config),
'by' => $by,
'type' => $type,
'request' => $request
]);
}
private static function compileStatistics(string $by, $from, $to, $data): Collection
{
$stats = [];
switch ($by) {
case 'day':
$period = collect(CarbonInterval::hour()->toPeriod(
$from ?? Carbon::now()->subDay()->format('Y-m-d H:i:s'),
$to ?? Carbon::now()->format('Y-m-d H:i:s')
))->map->format('Y-m-d H');
break;
case 'week':
$period = collect(CarbonPeriod::create(
$from ?? Carbon::now()->subWeek(),
$to ?? Carbon::now()
))->map->format('Y-m-d');
break;
case 'month':
$period = collect(
CarbonPeriod::create(
$from ?? Carbon::now()->subMonth(),
$to ?? Carbon::now()
)
)->map->format('Y-m-d');
break;
case 'year':
$period = collect(
CarbonPeriod::create(
$from ?? Carbon::now()->subYear(),
$to ?? Carbon::now()
)
)->map->format('Y-m');
break;
}
foreach ($period as $moment) {
$stats[$moment] = $data[$moment] ?? 0;
}
return collect($stats);
}
}

View file

@ -1,196 +0,0 @@
<?php
/*
Flexisip Account Manager is a set of tools to manage SIP accounts.
Copyright (C) 2020 Belledonne Communications SARL, All rights reserved.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace App\Libraries;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
use Carbon\CarbonPeriod;
use Carbon\CarbonInterval;
use App\Account;
class StatisticsCruncher
{
public static function month()
{
$data = self::getAccountFrom(Carbon::now()->subMonth())
->get(array(
DB::raw("date_format(created_at,'%Y-%m-%d') as moment"),
DB::raw('COUNT(*) as "count"')
))->each->setAppends([])->pluck('count', 'moment');
$dataAliases = self::getAccountFrom(Carbon::now()->subMonth())
->whereIn('id', function ($query) {
$query->select('account_id')
->from('aliases');
})
->get(array(
DB::raw("date_format(created_at,'%Y-%m-%d') as moment"),
DB::raw('COUNT(*) as "count"')
))->each->setAppends([])->pluck('count', 'moment');
$dataActivated = self::getAccountFrom(Carbon::now()->subMonth())
->where('activated', true)
->get(array(
DB::raw("date_format(created_at,'%Y-%m-%d') as moment"),
DB::raw('COUNT(*) as "count"')
))->each->setAppends([])->pluck('count', 'moment');
$dataAliasesActivated = self::getAccountFrom(Carbon::now()->subMonth())
->where('activated', true)
->whereIn('id', function ($query) {
$query->select('account_id')
->from('aliases');
})
->get(array(
DB::raw("date_format(created_at,'%Y-%m-%d') as moment"),
DB::raw('COUNT(*) as "count"')
))->each->setAppends([])->pluck('count', 'moment');
return self::compileStatistics(
collect(CarbonPeriod::create(Carbon::now()->subMonth(), Carbon::now()))->map->format('Y-m-d'),
$data,
$dataAliases,
$dataActivated,
$dataAliasesActivated
);
}
public static function week()
{
$data = self::getAccountFrom(Carbon::now()->subWeek())
->get(array(
DB::raw("date_format(created_at,'%Y-%m-%d') as moment"),
DB::raw('COUNT(*) as "count"')
))->each->setAppends([])->pluck('count', 'moment');
$dataAliases = self::getAccountFrom(Carbon::now()->subWeek())
->whereIn('id', function ($query) {
$query->select('account_id')
->from('aliases');
})
->get(array(
DB::raw("date_format(created_at,'%Y-%m-%d') as moment"),
DB::raw('COUNT(*) as "count"')
))->each->setAppends([])->pluck('count', 'moment');
$dataActivated = self::getAccountFrom(Carbon::now()->subWeek())
->where('activated', true)
->get(array(
DB::raw("date_format(created_at,'%Y-%m-%d') as moment"),
DB::raw('COUNT(*) as "count"')
))->each->setAppends([])->pluck('count', 'moment');
$dataAliasesActivated = self::getAccountFrom(Carbon::now()->subWeek())
->where('activated', true)
->whereIn('id', function ($query) {
$query->select('account_id')
->from('aliases');
})
->get(array(
DB::raw("date_format(created_at,'%Y-%m-%d') as moment"),
DB::raw('COUNT(*) as "count"')
))->each->setAppends([])->pluck('count', 'moment');
return self::compileStatistics(
collect(CarbonPeriod::create(Carbon::now()->subWeek(), Carbon::now()))->map->format('Y-m-d'),
$data,
$dataAliases,
$dataActivated,
$dataAliasesActivated
);
}
public static function day()
{
$data = self::getAccountFrom(Carbon::now()->subDay())
->get(array(
DB::raw("date_format(created_at,'%Y-%m-%d %H') as moment"),
DB::raw('COUNT(*) as "count"')
))->each->setAppends([])->pluck('count', 'moment');
$dataAliases = self::getAccountFrom(Carbon::now()->subDay())
->whereIn('id', function ($query) {
$query->select('account_id')
->from('aliases');
})
->get(array(
DB::raw("date_format(created_at,'%Y-%m-%d %H') as moment"),
DB::raw('COUNT(*) as "count"')
))->each->setAppends([])->pluck('count', 'moment');
$dataActivated = self::getAccountFrom(Carbon::now()->subDay())
->where('activated', true)
->get(array(
DB::raw("date_format(created_at,'%Y-%m-%d %H') as moment"),
DB::raw('COUNT(*) as "count"')
))->each->setAppends([])->pluck('count', 'moment');
$dataAliasesActivated = self::getAccountFrom(Carbon::now()->subDay())
->where('activated', true)
->whereIn('id', function ($query) {
$query->select('account_id')
->from('aliases');
})
->get(array(
DB::raw("date_format(created_at,'%Y-%m-%d %H') as moment"),
DB::raw('COUNT(*) as "count"')
))->each->setAppends([])->pluck('count', 'moment');
return self::compileStatistics(
collect(CarbonInterval::hour()->toPeriod(Carbon::now()->subDay(), Carbon::now()))->map->format('Y-m-d H'),
$data,
$dataAliases,
$dataActivated,
$dataAliasesActivated
);
}
private static function getAccountFrom($date)
{
return Account::where('created_at', '>=', $date)
->groupBy('moment')
->orderBy('moment', 'DESC')
->setEagerLoads([]);
}
private static function compileStatistics($period, $data, $dataAliases, $dataActivated, $dataAliasesActivated)
{
$stats = [];
foreach ($period as $moment) {
$all = $data[$moment] ?? 0;
$aliases = $dataAliases[$moment] ?? 0;
$activated = $dataActivated[$moment] ?? 0;
$activatedAliases = $dataAliasesActivated[$moment] ?? 0;
$stats[$moment] = [
'all' => $all,
'phone' => $aliases,
'email' => $all - $aliases,
'activated_phone' => $activatedAliases,
'activated_email' => $activated - $activatedAliases
];
}
return $stats;
}
}

68
flexiapi/composer.lock generated
View file

@ -399,16 +399,16 @@
},
{
"name": "doctrine/dbal",
"version": "3.6.4",
"version": "3.6.5",
"source": {
"type": "git",
"url": "https://github.com/doctrine/dbal.git",
"reference": "19f0dec95edd6a3c3c5ff1d188ea94c6b7fc903f"
"reference": "96d5a70fd91efdcec81fc46316efc5bf3da17ddf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/dbal/zipball/19f0dec95edd6a3c3c5ff1d188ea94c6b7fc903f",
"reference": "19f0dec95edd6a3c3c5ff1d188ea94c6b7fc903f",
"url": "https://api.github.com/repos/doctrine/dbal/zipball/96d5a70fd91efdcec81fc46316efc5bf3da17ddf",
"reference": "96d5a70fd91efdcec81fc46316efc5bf3da17ddf",
"shasum": ""
},
"require": {
@ -423,10 +423,10 @@
"require-dev": {
"doctrine/coding-standard": "12.0.0",
"fig/log-test": "^1",
"jetbrains/phpstorm-stubs": "2022.3",
"phpstan/phpstan": "1.10.14",
"jetbrains/phpstorm-stubs": "2023.1",
"phpstan/phpstan": "1.10.21",
"phpstan/phpstan-strict-rules": "^1.5",
"phpunit/phpunit": "9.6.7",
"phpunit/phpunit": "9.6.9",
"psalm/plugin-phpunit": "0.18.4",
"squizlabs/php_codesniffer": "3.7.2",
"symfony/cache": "^5.4|^6.0",
@ -491,7 +491,7 @@
],
"support": {
"issues": "https://github.com/doctrine/dbal/issues",
"source": "https://github.com/doctrine/dbal/tree/3.6.4"
"source": "https://github.com/doctrine/dbal/tree/3.6.5"
},
"funding": [
{
@ -507,7 +507,7 @@
"type": "tidelift"
}
],
"time": "2023-06-15T07:40:12+00:00"
"time": "2023-07-17T09:15:50+00:00"
},
{
"name": "doctrine/deprecations",
@ -1799,16 +1799,16 @@
},
{
"name": "laravel/framework",
"version": "v9.52.10",
"version": "v9.52.12",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
"reference": "858add225ce88a76c43aec0e7866288321ee0ee9"
"reference": "8bfd22be79f437fa335e70692e4e91ff40ce561d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/858add225ce88a76c43aec0e7866288321ee0ee9",
"reference": "858add225ce88a76c43aec0e7866288321ee0ee9",
"url": "https://api.github.com/repos/laravel/framework/zipball/8bfd22be79f437fa335e70692e4e91ff40ce561d",
"reference": "8bfd22be79f437fa335e70692e4e91ff40ce561d",
"shasum": ""
},
"require": {
@ -1993,20 +1993,20 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
"time": "2023-06-27T13:25:54+00:00"
"time": "2023-07-26T13:20:55+00:00"
},
{
"name": "laravel/serializable-closure",
"version": "v1.3.0",
"version": "v1.3.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/serializable-closure.git",
"reference": "f23fe9d4e95255dacee1bf3525e0810d1a1b0f37"
"reference": "e5a3057a5591e1cfe8183034b0203921abe2c902"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/serializable-closure/zipball/f23fe9d4e95255dacee1bf3525e0810d1a1b0f37",
"reference": "f23fe9d4e95255dacee1bf3525e0810d1a1b0f37",
"url": "https://api.github.com/repos/laravel/serializable-closure/zipball/e5a3057a5591e1cfe8183034b0203921abe2c902",
"reference": "e5a3057a5591e1cfe8183034b0203921abe2c902",
"shasum": ""
},
"require": {
@ -2053,7 +2053,7 @@
"issues": "https://github.com/laravel/serializable-closure/issues",
"source": "https://github.com/laravel/serializable-closure"
},
"time": "2023-01-30T18:31:20+00:00"
"time": "2023-07-14T13:56:28+00:00"
},
{
"name": "laravelcollective/html",
@ -3434,16 +3434,16 @@
},
{
"name": "phpunit/php-code-coverage",
"version": "9.2.26",
"version": "9.2.27",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
"reference": "443bc6912c9bd5b409254a40f4b0f4ced7c80ea1"
"reference": "b0a88255cb70d52653d80c890bd7f38740ea50d1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/443bc6912c9bd5b409254a40f4b0f4ced7c80ea1",
"reference": "443bc6912c9bd5b409254a40f4b0f4ced7c80ea1",
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b0a88255cb70d52653d80c890bd7f38740ea50d1",
"reference": "b0a88255cb70d52653d80c890bd7f38740ea50d1",
"shasum": ""
},
"require": {
@ -3499,7 +3499,8 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
"source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.26"
"security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
"source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.27"
},
"funding": [
{
@ -3507,7 +3508,7 @@
"type": "github"
}
],
"time": "2023-03-06T12:58:08+00:00"
"time": "2023-07-26T13:44:30+00:00"
},
{
"name": "phpunit/php-file-iterator",
@ -9009,16 +9010,16 @@
},
{
"name": "mockery/mockery",
"version": "1.6.3",
"version": "1.6.4",
"source": {
"type": "git",
"url": "https://github.com/mockery/mockery.git",
"reference": "b1be135c1ba7632f0248e07ee5e6e412576a309d"
"reference": "d1413755e26fe56a63455f7753221c86cbb88f66"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/mockery/mockery/zipball/b1be135c1ba7632f0248e07ee5e6e412576a309d",
"reference": "b1be135c1ba7632f0248e07ee5e6e412576a309d",
"url": "https://api.github.com/repos/mockery/mockery/zipball/d1413755e26fe56a63455f7753221c86cbb88f66",
"reference": "d1413755e26fe56a63455f7753221c86cbb88f66",
"shasum": ""
},
"require": {
@ -9032,16 +9033,17 @@
"require-dev": {
"phpunit/phpunit": "^8.5 || ^9.3",
"psalm/plugin-phpunit": "^0.18.4",
"symplify/easy-coding-standard": "^11.5.0",
"vimeo/psalm": "^5.13.1"
},
"type": "library",
"autoload": {
"files": [
"src/helpers.php",
"src/Mockery.php"
"library/helpers.php",
"library/Mockery.php"
],
"psr-4": {
"Mockery\\": "src/Mockery"
"Mockery\\": "library/Mockery"
}
},
"notification-url": "https://packagist.org/downloads/",
@ -9089,7 +9091,7 @@
"security": "https://github.com/mockery/mockery/security/advisories",
"source": "https://github.com/mockery/mockery"
},
"time": "2023-07-18T17:47:29+00:00"
"time": "2023-07-19T15:51:02+00:00"
},
{
"name": "nunomaduro/collision",

View file

@ -0,0 +1,18 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
class StatisticsMessageFactory extends Factory
{
public function definition(): array
{
return [
'id' => $this->faker->uuid(),
'from' => $this->faker->email(),
'sent_at' => $this->faker->dateTimeBetween('-1 year'),
'encrypted' => false
];
}
}

View file

@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::disableForeignKeyConstraints();
DB::query('delete from contacts_lists');
Schema::enableForeignKeyConstraints();
Schema::table('contacts_lists', function (Blueprint $table) {
$table->unique('title');
$table->text('description')->nullable(true)->change();
});
Schema::table('statistics_messages', function (Blueprint $table) {
$table->index('sent_at');
});
Schema::table('accounts', function (Blueprint $table) {
$table->index('created_at');
});
}
public function down()
{
Schema::table('contacts_lists', function (Blueprint $table) {
$table->dropUnique('contacts_lists_title_unique');
$table->text('description')->nullable(false)->change();
});
Schema::table('statistics_messages', function (Blueprint $table) {
$table->dropIndex('statistics_messages_sent_at_index');
});
Schema::table('accounts', function (Blueprint $table) {
$table->dropIndex('accounts_created_at_index');
});
}
};

View file

@ -1,7 +1,7 @@
<?php
/*
Flexisip Account Manager is a set of tools to manage SIP accounts.
Copyright (C) 2021 Belledonne Communications SARL, All rights reserved.
Copyright (C) 2020 Belledonne Communications SARL, All rights reserved.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
@ -17,27 +17,25 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace App\Http\Controllers\Api;
namespace Database\Seeders;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Database\Seeder;
use App\Libraries\StatisticsCruncher;
use App\StatisticsMessage;
use App\StatisticsMessageDevice;
use Illuminate\Support\Facades\Schema;
class StatisticController extends Controller
class StatisticsMessagesSeeder extends Seeder
{
public function month(Request $request)
public function run()
{
return StatisticsCruncher::month();
}
Schema::disableForeignKeyConstraints();
StatisticsMessageDevice::truncate();
StatisticsMessage::truncate();
Schema::enableForeignKeyConstraints();
public function week(Request $request)
{
return StatisticsCruncher::week();
}
public function day(Request $request)
{
return StatisticsCruncher::day();
StatisticsMessage::factory()
->count(10000)
->create();
}
}

View file

@ -637,6 +637,10 @@ table tr.empty td:before {
margin: 0;
}
.chip.selected {
font-weight: bold;
}
/** Pagination **/
ul.pagination {
@ -704,3 +708,7 @@ select.list_toggle {
opacity: 0.5;
pointer-events: none;
}
#chart {
min-height: 80vh;
}

View file

@ -114,4 +114,4 @@ An adminisator can create, edit and delete account types. Those can be used to c
## Statistics
The statistics panel show registrations statistics based on their type (mobile and email based registration) and their activations states.
The statistics panel show different statistics recorder by the Account Manager, they can be explored, filtered and exported.

View file

@ -1,74 +1,71 @@
@extends('layouts.main', ['welcome' => true])
@section('content')
<section>
<p class="oppose">
You already have an account?
<a class="btn btn-secondary" href="{{ route('account.login') }}">Login</a>
</p>
<section>
<h1><i class="material-icons">account_circle</i> Register</h1>
<p class="oppose">
You already have an account?
<a class="btn btn-secondary" href="{{ route('account.login') }}">Login</a>
</p>
@include('parts.tabs.register')
<h1><i class="material-icons">account_circle</i> Register</h1>
{!! Form::open(['route' => 'account.store']) !!}
@include('parts.tabs.register')
<div>
{!! Form::text('username', old('username'), ['placeholder' => 'username', 'required']) !!}
{!! Form::label('username', 'Username') !!}
@include('parts.errors', ['name' => 'username'])
</div>
{!! Form::open(['route' => 'account.store']) !!}
<div>
<input type="text" name="username" value="{{ $domain }}" disabled>
</div>
<div>
{!! Form::text('username', old('username'), ['placeholder' => 'username', 'required']) !!}
{!! Form::label('username', 'Username') !!}
@include('parts.errors', ['name' => 'username'])
</div>
<div>
{!! Form::email('email', old('email'), ['placeholder' => 'bob@example.net', 'required']) !!}
{!! Form::label('email', 'Email') !!}
@include('parts.errors', ['name' => 'email'])
</div>
<div>
{!! Form::email('email_confirmation', old('email_confirm'), ['placeholder' => 'bob@example.net', 'required']) !!}
{!! Form::label('email_confirmation', 'Confirm email') !!}
@include('parts.errors', ['name' => 'email_confirmation'])
</div>
<div>
<input type="text" name="username" value="{{ $domain }}" disabled>
</div>
<div>
<input required="" name="password" type="password" value="" placeholder="Password">
<label for="password">Password</label>
@include('parts.errors', ['name' => 'password'])
</div>
<div>
<input required="" name="password_confirmation" type="password" value="" placeholder="Password confirmation">
<label for="password_confirmation">Confirm password</label>
@include('parts.errors', ['name' => 'password_confirmation'])
</div>
<div>
{!! Form::email('email', old('email'), ['placeholder' => 'bob@example.net', 'required']) !!}
{!! Form::label('email', 'Email') !!}
@include('parts.errors', ['name' => 'email'])
</div>
<div>
{!! Form::email('email_confirmation', old('email_confirm'), ['placeholder' => 'bob@example.net', 'required']) !!}
{!! Form::label('email_confirmation', 'Confirm email') !!}
@include('parts.errors', ['name' => 'email_confirmation'])
</div>
@if (!empty(config('app.newsletter_registration_address')))
<div class="large checkbox">
{!! Form::checkbox('newsletter', 'true', false, ['class' => 'form-check-input', 'id' => 'newsletter']) !!}
<label class="form-check-label" for="newsletter">I would like to subscribe to the newsletter</a></label>
</div>
@endif
<div>
{!! Form::password('password', ['required']) !!}
{!! Form::label('password', 'Password') !!}
@include('parts.errors', ['name' => 'password'])
</div>
<div>
{!! Form::password('password_confirmation', ['required']) !!}
{!! Form::label('password_confirmation', 'Confirm password') !!}
@include('parts.errors', ['name' => 'password_confirmation'])
</div>
@include('parts.terms')
@if (!empty(config('app.newsletter_registration_address')))
<div class="large checkbox">
{!! Form::checkbox('newsletter', 'true', false, ['class' => 'form-check-input', 'id' => 'newsletter']) !!}
<label class="form-check-label" for="newsletter">I would like to subscribe to the newsletter</a></label>
</div>
@endif
<div class="large">
{!! Form::submit('Register', ['class' => 'btn oppose']) !!}
</div>
@include('parts.terms')
<div class="large">
{!! Form::submit('Register', ['class' => 'btn oppose']) !!}
</div>
{!! Form::close() !!}
</section>
<section class="on_desktop">
<img src="/img/login.svg">
</section>
{!! Form::close() !!}
</section>
<section class="on_desktop">
<img src="/img/login.svg">
</section>
@endsection
@section('footer')
Hop
Hop
@endsection

View file

@ -31,13 +31,14 @@
<div></div>
<div>
{!! Form::password('password', ['required']) !!}
{!! Form::label('password', 'Password') !!}
<input required="" name="password" type="password" value="" placeholder="Password">
<label for="password">Password</label>
@include('parts.errors', ['name' => 'password'])
</div>
<div>
{!! Form::password('password_confirmation', ['required']) !!}
{!! Form::label('password_confirmation', 'Confirm password') !!}
<input required="" name="password_confirmation" type="password" value="" placeholder="Password confirmation">
<label for="password_confirmation">Confirm password</label>
@include('parts.errors', ['name' => 'password_confirmation'])
</div>
@include('parts.terms')

View file

@ -1,18 +1,24 @@
@extends('layouts.main')
@section('content')
<div>
@if ($account->id)
<a class="btn oppose btn-secondary" href="{{ route('admin.account.delete', $account->id) }}">
@if ($account->id)
<header>
<h1><i class="material-icons">people</i> Edit an account</h1>
<a href="{{ route('admin.account.index') }}" class="btn btn-secondary oppose">Cancel</a>
<a class="btn btn-secondary" href="{{ route('admin.account.delete', $account->id) }}">
<i class="material-icons">delete</i>
Delete
</a>
<h1><i class="material-icons">people</i> Edit an account</h1>
<p title="{{ $account->updated_at }}">Updated on {{ $account->updated_at->format('d/m/Y') }}
@else
<input form="create_edit" class="btn" type="submit" value="Update">
</header>
<p title="{{ $account->updated_at }}">Updated on {{ $account->updated_at->format('d/m/Y') }}
@else
<header>
<h1><i class="material-icons">people</i> Create an account</h1>
@endif
</div>
<a href="{{ route('admin.account.index') }}" class="btn btn-secondary oppose">Cancel</a>
<input form="create_edit" class="btn" type="submit" value="Create">
</header>
@endif
<form method="POST"
action="{{ $account->id ? route('admin.account.update', $account->id) : route('admin.account.store') }}"
@ -95,12 +101,6 @@
</form>
<form>
<div class="large">
<input class="btn oppose" type="submit" value="{{ $account->id ? 'Update' : 'Create' }}" form="create_edit">
</div>
</form>
<hr class="large">
@if ($account->id)

View file

@ -4,13 +4,16 @@
<header>
@if ($contacts_list->id)
<h1><i class="material-icons">account_box</i> Edit a Contacts List</h1>
<a class="btn oppose btn-secondary" href="{{ route('admin.contacts_lists.delete', $contacts_list->id) }}">
<a href="{{ route('admin.contacts_lists.index') }}" class="btn btn-secondary oppose">Cancel</a>
<a class="btn btn-secondary" href="{{ route('admin.contacts_lists.delete', $contacts_list->id) }}">
<i class="material-icons">delete</i>
Delete
</a>
<input form="create_edit_contacts_list" class="btn" type="submit" value="{{ $contacts_list->id ? 'Update' : 'Create' }}">
<input form="create_edit_contacts_list" class="btn" type="submit" value="Update">
@else
<h1><i class="material-icons">account_box</i> Create a Contacts List</h1>
<a href="{{ route('admin.contacts_lists.index') }}" class="btn btn-secondary oppose">Cancel</a>
<input form="create_edit_contacts_list" class="btn" type="submit" value="Create">
@endif
</header>
@ -18,20 +21,19 @@
<p title="{{ $contacts_list->updated_at }}">Updated on {{ $contacts_list->updated_at->format('d/m/Y') }}
@endif
<form method="POST"
id="create_edit_contacts_list"
<form method="POST" id="create_edit_contacts_list"
action="{{ $contacts_list->id ? route('admin.contacts_lists.update', $contacts_list->id) : route('admin.contacts_lists.store') }}"
accept-charset="UTF-8">
@csrf
@method($contacts_list->id ? 'put' : 'post')
<div>
<input placeholder="Name" required="required" name="title" type="text" value="{{ $contacts_list->title }}">
<input placeholder="Name" required="required" name="title" type="text" value="{{ $contacts_list->title ?? old('title') }}">
<label for="username">Name</label>
@include('parts.errors', ['name' => 'title'])
</div>
<div>
<textarea placeholder="Description" required="required" name="description">{{ $contacts_list->description }}</textarea>
<textarea placeholder="Description" name="description">{{ $contacts_list->description ?? old('description') }}</textarea>
<label for="description">Description</label>
@include('parts.errors', ['name' => 'description'])
</div>
@ -45,15 +47,15 @@
<span class="list_toggle" data-list-id="d{{ $contacts_list->id }}"></span> selected
</p>
<form method="POST"
action="{{ route('admin.contacts_lists.contacts.destroy', $contacts_list->id) }}"
accept-charset="UTF-8">
<form method="POST" action="{{ route('admin.contacts_lists.contacts.destroy', $contacts_list->id) }}"
accept-charset="UTF-8">
@csrf
@method('delete')
<select name="contacts_ids[]" class="list_toggle" data-list-id="d{{ $contacts_list->id }}"></select>
<input type="hidden" name="contacts_list_id" value="{{ $contacts_list->id }}">
<input class="btn btn-tertiary" type="submit" value="Remove contacts" onclick="Utils.clearStorageList('d{{ $contacts_list->id }}')">
<input class="btn btn-tertiary" type="submit" value="Remove contacts"
onclick="Utils.clearStorageList('d{{ $contacts_list->id }}')">
</form>
<a class="btn btn-secondary" href="{{ route('admin.contacts_lists.contacts.add', $contacts_list->id) }}">
@ -79,7 +81,8 @@
@foreach ($contacts_list->contacts as $contact)
<tr>
<td>
<input class="list_toggle" type="checkbox" data-list-id="d{{ $contacts_list->id }}" data-id="{{ $contact->id }}">
<input class="list_toggle" type="checkbox" data-list-id="d{{ $contacts_list->id }}"
data-id="{{ $contact->id }}">
</td>
<td>{{ $contact->identifier }}</td>
</tr>

View file

@ -0,0 +1,47 @@
@extends('layouts.main')
@section('content')
@include('parts.tabs', [
'items' => [
route('admin.statistics.show', ['type' => 'messages']) => 'Messages',
route('admin.statistics.show', ['type' => 'accounts']) => 'Accounts',
],
])
<header>
<form class="inline" method="POST" action="{{ route('admin.statistics.edit') }}" accept-charset="UTF-8">
@csrf
@method('post')
<input type="hidden" name="by" value="{{ $by }}">
<input type="hidden" name="type" value="{{ $type }}">
<div>
<input type="date" name="from" value="{{ $request->get('from') }}" onchange="this.form.submit()">
<label for="from">From</label>
</div>
<div>
<input type="date" name="to" value="{{ $request->get('to') }}" onchange="this.form.submit()">
<label for="to">To</label>
</div>
<div>
<a href="{{ route('admin.statistics.show', ['by' => 'day', 'type' => $type] + $request->only(['from', 'to'])) }}"
class="chip @if ($by == 'day') selected @endif">Day</a>
<a href="{{ route('admin.statistics.show', ['by' => 'week', 'type' => $type] + $request->only(['from', 'to'])) }}"
class="chip @if ($by == 'week') selected @endif">Week</a>
<a href="{{ route('admin.statistics.show', ['by' => 'month', 'type' => $type] + $request->only(['from', 'to'])) }}"
class="chip @if ($by == 'month') selected @endif">Month</a>
<a href="{{ route('admin.statistics.show', ['by' => 'year', 'type' => $type] + $request->only(['from', 'to'])) }}"
class="chip @if ($by == 'year') selected @endif">Year</a>
</div>
<a class="btn btn-secondary" href="{{ route('admin.statistics.show') }}">Reset</a>
<a class="btn btn-tertiary" href="{{ route('admin.statistics.show', ['by' => $by, 'type' => $type, 'export' => true] + $request->only(['from', 'to'])) }}">
<i class="material-icons">download</i> Export
</a>
</form>
</header>
@include('parts.graph')
@endsection

View file

@ -1,37 +0,0 @@
@extends('layouts.main')
@section('breadcrumb')
<li class="breadcrumb-item active" aria-current="page">
Statistics
</li>
@endsection
@section('content')
<ul class="nav justify-content-center">
<li class="nav-item">
<a class="nav-link disabled" href="#">Day</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ route('admin.statistics.show.week') }}">Week</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ route('admin.statistics.show.month') }}">Month</a>
</li>
</ul>
<h2>Statistics</h2>
@include('admin.statistics.parts.legend')
<h3>Day</h3>
<div class="columns">
@foreach ($day as $key => $hour)
<div class="column" data-value="{{ substr($key, -2, 2) }}:00">
@include('admin.statistics.parts.columns', ['slice' => $hour, 'max' => $max_day])
</div>
@endforeach
</div>
@endsection

View file

@ -1,37 +0,0 @@
@extends('layouts.main')
@section('breadcrumb')
<li class="breadcrumb-item active" aria-current="page">
Statistics
</li>
@endsection
@section('content')
<ul class="nav justify-content-center">
<li class="nav-item">
<a class="nav-link" href="{{ route('admin.statistics.show.day') }}">Day</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ route('admin.statistics.show.week') }}">Week</a>
</li>
<li class="nav-item">
<a class="nav-link disabled" href="#">Month</a>
</li>
</ul>
<h2>Statistics</h2>
@include('admin.statistics.parts.legend')
<h3>Month</h3>
<div class="columns">
@foreach ($month as $key => $day)
<div class="column" data-value="{{ $key }}">
@include('admin.statistics.parts.columns', ['slice' => $day, 'max' => $max_month])
</div>
@endforeach
</div>
@endsection

View file

@ -1,37 +0,0 @@
@extends('layouts.main')
@section('breadcrumb')
<li class="breadcrumb-item active" aria-current="page">
Statistics
</li>
@endsection
@section('content')
<ul class="nav justify-content-center">
<li class="nav-item">
<a class="nav-link" href="{{ route('admin.statistics.show.day') }}">Day</a>
</li>
<li class="nav-item">
<a class="nav-link disabled" href="#">Week</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ route('admin.statistics.show.month') }}">Month</a>
</li>
</ul>
<h2>Statistics</h2>
@include('admin.statistics.parts.legend')
<h3>Week</h3>
<div class="columns">
@foreach ($week as $key => $day)
<div class="column" data-value="{{ $key }}">
@include('admin.statistics.parts.columns', ['slice' => $day, 'max' => $max_week])
</div>
@endforeach
</div>
@endsection

View file

@ -525,20 +525,6 @@ JSON parameters:
* `to` required, SIP address of the receiver
* `body` required, content of the message
## Statistics
### `GET /statistics/day`
<span class="badge badge-warning">Admin</span>
Retrieve registrations statistics for 24 hours.
### `GET /statistics/week`
<span class="badge badge-warning">Admin</span>
Retrieve registrations statistics for a week.
### `GET /statistics/month`
<span class="badge badge-warning">Admin</span>
Retrieve registrations statistics for a month.
# Non-API Endpoints
The following URLs are **not API endpoints** they are not returning `JSON` content and they are not located under `/api` but directly under the root path.

View file

@ -12,6 +12,9 @@
<link rel="stylesheet" type="text/css" href="{{ asset('css/far.css') }}">
<link rel="stylesheet" type="text/css" href="{{ asset('css/form.css') }}">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.0.0"></script>
@if (config('instance.custom_theme') & file_exists(public_path('css/' . config('app.env') . '.style.css')))
<link rel="stylesheet" type="text/css" href="{{ asset('css/' . config('app.env') . '.style.css') }}">
<!--<link rel="stylesheet" type="text/css" href="{{ asset('css/charts.css') }}" >-->

View file

@ -0,0 +1,27 @@
<div id="chart" @if (isset($style))style="{{ $style }}" @endif></div>
<script>
const config = {!! $jsonConfig !!};
chart = document.getElementById('chart');
chart.innerHTML = '';
canvas = document.createElement('canvas');
canvas.id = 'myChart';
chart.appendChild(canvas);
@if (isset($withDataLabel) && $withDataLabel)
config.plugins = [ChartDataLabels];
@endif
@if (isset($showLabel) && $showLabel)
config.options.plugins.datalabels.formatter = function(value, context) {
return value + ' ' + context.dataset.label;
}
@endif
new Chart(
document.getElementById('myChart'),
config
);
</script>

View file

@ -7,7 +7,7 @@
if (auth()->user() && auth()->user()->admin) {
$items['admin.account.index'] = ['title' => 'Accounts', 'icon' => 'people'];
$items['admin.contacts_lists.index'] = ['title' => 'Contacts Lists', 'icon' => 'account_box'];
$items['admin.statistics.show.day'] = ['title' => 'Statistics', 'icon' => 'analytics'];
$items['admin.statistics.show'] = ['title' => 'Statistics', 'icon' => 'analytics'];
}
@endphp

View file

@ -1,5 +1,5 @@
<ul class="tabs">
@foreach ($items as $route => $title)
<li @if (url()->current() == route($route))class="current"@endif><a href="{{ route($route) }}">{{ $title }}</a></li>
<li @if (url()->current() == $route)class="current"@endif><a href="{{ $route }}">{{ $title }}</a></li>
@endforeach
</ul>

View file

@ -1,6 +1,6 @@
@if(config('app.phone_authentication'))
@include('parts.tabs', ['items' => [
'account.register.email' => 'Email registration',
'account.register.phone' => 'Phone registration',
route('account.register.phone') => 'Phone registration',
route('account.register.email') => 'Email registration',
]])
@endif

View file

@ -54,10 +54,6 @@ Route::post('accounts/auth_token', 'Api\Account\AuthTokenController@store');
Route::get('accounts/me/api_key/{auth_token}', 'Api\Account\ApiKeyController@generateFromToken')->middleware('cookie', 'cookie.encrypt');
Route::group(['middleware' => ['auth.digest_or_key']], function () {
Route::get('statistic/month', 'Api\StatisticController@month');
Route::get('statistic/week', 'Api\StatisticController@week');
Route::get('statistic/day', 'Api\StatisticController@day');
Route::get('accounts/auth_token/{auth_token}/attach', 'Api\Account\AuthTokenController@attach');
Route::get('accounts/me/api_key', 'Api\Account\ApiKeyController@generate')->middleware('cookie', 'cookie.encrypt');

View file

@ -32,7 +32,7 @@ use App\Http\Controllers\Admin\AccountTypeController;
use App\Http\Controllers\Admin\AccountController as AdminAccountController;
use App\Http\Controllers\Admin\ContactsListController;
use App\Http\Controllers\Admin\ContactsListContactController;
use App\Http\Controllers\Admin\StatisticsController;
use Illuminate\Support\Facades\Route;
Route::redirect('/', '/login')->name('account.home');
@ -131,11 +131,11 @@ if (config('app.web_panel')) {
Route::get('auth_tokens/auth/{token}', 'Account\AuthTokenController@auth')->name('auth_tokens.auth');
Route::name('admin.')->prefix('admin')->middleware(['auth.admin'])->group(function () {
// Statistics
Route::get('statistics/day', 'Admin\StatisticsController@showDay')->name('statistics.show.day');
Route::get('statistics/week', 'Admin\StatisticsController@showWeek')->name('statistics.show.week');
Route::get('statistics/month', 'Admin\StatisticsController@showMonth')->name('statistics.show.month');
Route::name('statistics.')->controller(StatisticsController::class)->prefix('statistics')->group(function () {
Route::get('/', 'index')->name('index');
Route::get('/{type?}', 'show')->name('show');
Route::post('/', 'edit')->name('edit');
});
Route::name('account.')->prefix('accounts')->group(function () {
Route::controller(AdminAccountController::class)->group(function () {