Skip to content

Commit

Permalink
Merge pull request #33 from OHFLesvos/visitor_distribution_charts
Browse files Browse the repository at this point in the history
Visitor distribution charts
  • Loading branch information
mrcage authored Nov 16, 2023
2 parents 9c71ce1 + e82bb1b commit 9fbe432
Show file tree
Hide file tree
Showing 21 changed files with 470 additions and 239 deletions.
4 changes: 4 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 5.3.0

* Added new visitor gender, nationality, age distribution charts

## 5.2.0

* Added visit purpose chart to new visitor report page
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Controllers/Traits/ValidatesDateRanges.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ protected static function getDatePeriodFromRequest(Request $request, ?int $defau
return [
$request->filled($dateStartField)
? new Carbon($request->input($dateStartField))
: Carbon::today()->subDays($defaultDays),
: ($defaultDays !== null ? Carbon::today()->subDays($defaultDays) : null),
$request->filled($dateEndField)
? new Carbon($request->input($dateEndField))
: Carbon::today(),
Expand Down
124 changes: 67 additions & 57 deletions app/Http/Controllers/Visitors/API/ReportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
use App\Http\Controllers\Traits\ValidatesDateRanges;
use App\Models\Visitors\Visitor;
use App\Models\Visitors\VisitorCheckin;
use App\Support\ChartResponseBuilder;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
Expand Down Expand Up @@ -100,84 +100,94 @@ private function selectByDateGranularity($qry, ?string $granularity = 'days', ?s
}
}

public function listCheckinPurposes()
public function genderDistribution(Request $request): JsonResponse
{
$this->authorize('view-visitors-reports');

$data = VisitorCheckin::getPurposeList();
[$startDate, $endDate] = $this->getDatePeriodFromRequest($request, defaultDays: null, dateStartField: 'date_start', dateEndField: 'date_end');

return response()->json($data);
$data = Visitor::query()
->select('gender')
->selectRaw('COUNT(*) AS `total_count`')
->whereHas('checkins', function (Builder $qry2) use ($startDate, $endDate, $request) {
$qry2->when($startDate !== null, fn (Builder $q) => $q->whereDate('checkin_date', '>=', $startDate))
->when($endDate !== null, fn (Builder $q) => $q->whereDate('checkin_date', '<=', $endDate))
->when($request->filled('purpose'), fn ($qry) => $qry->where('purpose_of_visit', $request->input('purpose')));
})
->groupBy('gender')
->orderBy('total_count', 'desc')
->orderBy('gender')
->get();

return response()->json($data
->map(fn ($e) => [
'label' => __($e->gender),
'value' => $e->total_count,
]));
}

public function ageDistribution(Request $request): JsonResponse
{
$this->authorize('view-visitors-reports');

[$dateFrom, $dateTo] = $this->getDatePeriodFromRequest($request);

$visitors = Visitor::inDateRange($dateFrom, $dateTo)
->fromSub(function ($query) {
$query
->selectRaw('COUNT(*) AS `total_visitors`, created_at')
->selectRaw('YEAR(CURRENT_DATE()) - YEAR(date_of_birth) - (RIGHT(CURRENT_DATE(), 5) < RIGHT(date_of_birth, 5)) AS `age`')
->from('visitors')
->whereNotNull('date_of_birth')
->groupBy('age');
}, 'sub')
[$startDate, $endDate] = $this->getDatePeriodFromRequest($request, defaultDays: null, dateStartField: 'date_start', dateEndField: 'date_end');

$ageDistribution = Visitor::query()
->selectRaw('CASE
WHEN age < 18 THEN "Under 18"
WHEN age >= 18 AND age < 30 THEN "18-29"
WHEN age >= 30 AND age < 65 THEN "30-64"
WHEN age >= 65 THEN "65 and above"
END AS `age_group`')
->selectRaw('COUNT(*) AS `total_visitors`')
WHEN age <= 5 THEN "1-5"
WHEN age >= 6 AND age <= 11 THEN "6-11"
WHEN age >= 12 AND age <= 17 THEN "12-17"
WHEN age >= 18 AND age <= 25 THEN "18-25"
WHEN age >= 26 AND age <= 35 THEN "26-35"
WHEN age >= 36 AND age <= 45 THEN "36-45"
WHEN age >= 46 AND age <= 55 THEN "46-55"
WHEN age >= 56 AND age <= 65 THEN "56-65"
ELSE "66+"
END AS age_group')
->selectRaw('COUNT(*) as total_count')
->from(function ($query) use ($startDate, $endDate, $request) {
$query->select('v.id')->selectRaw('TIMESTAMPDIFF(YEAR, v.date_of_birth, CURDATE()) AS age')
->from('visitors as v')
->join('visitor_checkins as vc', 'v.id', '=', 'vc.visitor_id')
->whereBetween('vc.checkin_date', [$startDate, $endDate])
->when($request->filled('purpose'), fn ($qry) => $qry->where('purpose_of_visit', $request->input('purpose')))
->groupBy('v.id');
}, 's')
->groupBy('age_group')
->orderByRaw("FIELD(age_group, 'Under 18', '18-29', '30-64', '65 and above')")
->get()
->pluck('total_visitors', 'age_group');
->orderBy('age_group')
->get();

return (new ChartResponseBuilder())
->dataset(__('Visitors'), $visitors, null, false)
->build();
return response()->json($ageDistribution
->map(fn ($e) => [
'label' => __($e->age_group),
'value' => $e->total_count,
]));
}

public function nationalityDistribution(Request $request): JsonResponse
{
$this->authorize('view-visitors-reports');

[$dateFrom, $dateTo] = $this->getDatePeriodFromRequest($request);
[$startDate, $endDate] = $this->getDatePeriodFromRequest($request, defaultDays: null, dateStartField: 'date_start', dateEndField: 'date_end');

$visitors = Visitor::inDateRange($dateFrom, $dateTo)
->selectRaw('nationality, COUNT(*) AS `total_visitors`')
->whereNotNull('nationality')
$data = Visitor::query()
->select('nationality')
->selectRaw('COUNT(*) AS `total_count`')
->whereHas('checkins', function (Builder $qry2) use ($startDate, $endDate, $request) {
$qry2->when($startDate !== null, fn (Builder $q) => $q->whereDate('checkin_date', '>=', $startDate))
->when($endDate !== null, fn (Builder $q) => $q->whereDate('checkin_date', '<=', $endDate))
->when($request->filled('purpose'), fn ($qry) => $qry->where('purpose_of_visit', $request->input('purpose')));
})
->groupBy('nationality')
->orderByDesc('total_visitors')
->get()
->pluck('total_visitors', 'nationality');

return (new ChartResponseBuilder())
->dataset(__('Visitors'), $visitors, null, false)
->build();
}

public function checkInsByVisitor(Request $request): JsonResponse
{
$this->authorize('view-visitors-reports');

[$dateFrom, $dateTo] = $this->getDatePeriodFromRequest($request);

$visits = VisitorCheckin::inDateRange($dateFrom, $dateTo, 'checkin_date')
->selectRaw('COUNT(*) AS `total_visits`')
->groupBy('visitor_id')
->get()
->groupBy('total_visits')
->map(function ($visitsGroup) {
return $visitsGroup->count();
});
->orderBy('total_count', 'desc')
->orderBy('nationality')
->get();

return (new ChartResponseBuilder())
->dataset(__('Visits'), $visits)
->build();
return response()->json($data
->map(fn ($e) => [
'label' => __($e->nationality),
'value' => $e->total_count,
]));
}

public function checkInsByPurpose(Request $request): JsonResponse
Expand Down
4 changes: 4 additions & 0 deletions app/Models/Visitors/Visitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Support\Collection;

/**
* @property-read ?int $total_count
* @property-read ?string $age_group
*/
class Visitor extends Model
{
use HasFactory;
Expand Down
3 changes: 3 additions & 0 deletions app/Models/Visitors/VisitorCheckin.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Collection;

/**
* @property-read ?int $total_count
*/
class VisitorCheckin extends Model
{
use HasFactory;
Expand Down
1 change: 1 addition & 0 deletions lang/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,7 @@
"Nationality": "Nationalität",
"Nationalities": "Nationalitäten",
"Gender": "Geschlecht",
"Gender distribution": "Geschlechtsverteilung",
"Date of birth": "Geburtsdatum",
"Age": "Alter",
"Age :age": "Alter: :age",
Expand Down
31 changes: 21 additions & 10 deletions resources/js/api/visitors.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,20 +51,31 @@ export default {
const url = route('api.visitors.report.visitorCheckins', params)
return await api.get(url)
},
async listCheckinPurposes () {
const url = route('api.visitors.report.listCheckinPurposes')
return await api.get(url)
},
async ageDistribution (params) {
const url = route('api.visitors.ageDistribution', params)
async ageDistribution (date_start, date_end, purpose) {
const params = {
date_start: date_start,
date_end: date_end,
purpose: purpose,
}
const url = route('api.visitors.report.ageDistribution', params)
return await api.get(url)
},
async nationalityDistribution (params) {
const url = route('api.visitors.nationalityDistribution', params)
async genderDistribution (date_start, date_end, purpose) {
const params = {
date_start: date_start,
date_end: date_end,
purpose: purpose,
}
const url = route('api.visitors.report.genderDistribution', params)
return await api.get(url)
},
async checkInsByVisitor (params) {
const url = route('api.visitors.checkInsByVisitor', params)
async nationalityDistribution (date_start, date_end, purpose) {
const params = {
date_start: date_start,
date_end: date_end,
purpose: purpose,
}
const url = route('api.visitors.report.nationalityDistribution', params)
return await api.get(url)
},
async checkInsByPurpose(date_start, date_end) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,35 +5,35 @@
<doughnut-chart
:key="JSON.stringify(myData)"
:title="title"
:data="myData"
:data="chartData"
:height="300"
class="mb-2"
>
</doughnut-chart>
</b-card-body>
<b-table-simple
v-if="Object.keys(myData).length > 0"
v-if="myData.length"
responsive
small
class="my-0"
>
<b-tr v-for="(value, label) in myData" :key="label">
<b-tr v-for="(e) in myData" :key="e.label">
<b-td class="fit">
{{ label && label.length ? label : $t('Unspecified') }}
{{ formatLabel(e.label) }}
</b-td>
<b-td class="align-middle d-none d-sm-table-cell">
<b-progress
:value="value"
:value="e.value"
:max="total"
:show-value="false"
variant="secondary"
/>
</b-td>
<b-td class="fit text-right">
{{ percentValue(value, total) }}%
{{ percentValue(e.value, total) }}%
</b-td>
<b-td class="fit text-right d-none d-sm-table-cell">
{{ value | numberFormat }}
{{ e.value | numberFormat }}
</b-td>
</b-tr>
<b-tfoot>
Expand Down Expand Up @@ -68,7 +68,7 @@ export default {
type: String
},
data: {
type: [Function, Object],
type: [Function, Object, Array],
required: true
}
},
Expand All @@ -80,7 +80,12 @@ export default {
},
computed: {
total() {
return Object.values(this.myData).reduce((a, b) => a + b, 0);
return this.myData.reduce((a, b) => a + b.value, 0);
},
chartData() {
let chartData = {}
this.myData.forEach(v => chartData[this.formatLabel(v.label)] = v.value)
return chartData;
}
},
watch: {
Expand All @@ -93,18 +98,25 @@ export default {
},
methods: {
async fetchData() {
let myData
let resData
if (typeof this.data === "function") {
myData = await this.data();
resData = await this.data();
} else {
resData = this.data;
}
if (Array.isArray(resData)) {
this.myData = resData
} else {
myData = this.data;
this.myData = Object.entries(resData).map(e => ({label: e[0], value: e[1]}))
}
this.myData = Array.isArray(myData) && myData.length == 0 ? {} : myData
},
formatLabel(v) {
return v && v.length ? v : this.$t('Unspecified')
},
copyToClipboard() {
const separator = '\t';
const csvText = `${this.title}${separator}${this.$t("Percentage")}${separator}${this.$t("Amount")}\n`
+ Object.entries(this.myData).map(e => `${e[0] && e[0].length ? e[0] : this.$t('Unspecified') }${separator}${this.percentValue(e[1], this.total)}${separator}${e[1]}`).join("\n")
+ this.myData.map(e => `${this.formatLabel(e.label)}${separator}${this.percentValue(e.value, this.total)}${separator}${e.value}`).join("\n")
copy(csvText, {
format: 'text/plain',
Expand Down
Loading

0 comments on commit 9fbe432

Please sign in to comment.