From eb3ab99407059ad5a1e1d8559e915ab5a1dfc765 Mon Sep 17 00:00:00 2001 From: edwh Date: Wed, 30 Sep 2026 18:22:10 +0100 Subject: [PATCH 01/13] Data engagement: reporting links, group stats, Fixometer fixes (#919) Reporting links (#920, #921) - Networks have a reporting dashboard address (new networks.reporting_url column), set by administrators on the network edit page and linked from the network actions menu. - Groups link to the group reporting dashboard, filtered to the group, from the group actions menu and the stats section. The address comes from GROUP_REPORTING_URL, with {group} replaced by the group id; members of the group and those who look after it see the link. Group page stats (#922) - Items worked on and most repaired items sit alongside group achievements and environmental impact; the device breakdown section is gone, along with the queries that fed it. - "Most repaired devices" is now "Most repaired items". Fixometer headline stats (#923) - Powered/unpowered figures say they are items fixed. - "years volunteered" is now "years of time volunteered". - Adds the number of events held. Fixometer repair records (#924) - One list of powered and unpowered items, with a powered/unpowered filter. - Sorting uses the column clicked; the API maps table columns to database columns and ignores anything else. - The status filter works (the API accepts the status names the client sends). - The "to" date includes events on that day; deleted events are left out. - Expanding a record shows its details instead of empty fields. - The date column is the event date, and records keep their own dates rather than those of the joined tables. Repair data download (#925) - Everyone can download the repair records from the Fixometer page. - The export checks visibility in the query and loads events and groups with the devices, in chunks, rather than querying each event and group in turn. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/Group.php | 10 + app/Http/Controllers/ApiController.php | 66 ++++-- app/Http/Controllers/ExportController.php | 113 +++++++---- app/Http/Controllers/GroupController.php | 62 ------ app/Http/Controllers/NetworkController.php | 12 ++ app/Http/Resources/Device.php | 9 + config/restarters.php | 6 + ...00_add_reporting_url_to_networks_table.php | 23 +++ lang/de/groups.php | 3 - lang/en/devices.php | 11 +- lang/en/groups.php | 8 +- lang/en/networks.php | 3 + lang/es/groups.php | 3 - lang/fr-BE/devices.php | 11 +- lang/fr-BE/groups.php | 8 +- lang/fr-BE/networks.php | 3 + lang/fr/devices.php | 11 +- lang/fr/groups.php | 8 +- lang/fr/networks.php | 3 + lang/it/groups.php | 3 - lang/ne/groups.php | 3 - lang/nl-BE/groups.php | 3 - lang/nl/groups.php | 3 - lang/no/groups.php | 3 - .../js/components/DeviceCategorySelect.vue | 24 ++- resources/js/components/FixometerFilters.vue | 59 +++++- .../components/FixometerGlobalImpact.test.js | 62 ++++++ .../js/components/FixometerGlobalImpact.vue | 32 +-- resources/js/components/FixometerPage.vue | 189 +++++------------- .../components/FixometerRecordsTable.test.js | 133 ++++++++++++ .../js/components/FixometerRecordsTable.vue | 145 +++++++------- resources/js/components/GroupActions.test.js | 42 ++++ resources/js/components/GroupActions.vue | 11 + .../js/components/GroupDevicesBreakdown.vue | 114 ----------- .../GroupDevicesBreakdownCluster.vue | 147 -------------- resources/js/components/GroupHeading.vue | 9 +- resources/js/components/GroupPage.vue | 34 ++-- resources/js/components/GroupStats.test.js | 55 +++++ resources/js/components/GroupStats.vue | 40 +++- resources/js/components/NetworkPage.test.js | 17 ++ resources/js/components/NetworkPage.vue | 1 + resources/views/group/view.blade.php | 65 +----- resources/views/networks/edit.blade.php | 13 ++ .../Events/ExportDevicesQueryCountTest.php | 100 +++++++++ tests/Feature/Fixometer/HeadlineStatsTest.php | 44 ++++ .../Fixometer/RepairRecordsApiTest.php | 128 ++++++++++++ .../Feature/Groups/GroupReportingLinkTest.php | 73 +++++++ .../Networks/NetworkReportingLinkTest.php | 98 +++++++++ 48 files changed, 1277 insertions(+), 746 deletions(-) create mode 100644 database/migrations/2026_09_30_000000_add_reporting_url_to_networks_table.php create mode 100644 resources/js/components/FixometerGlobalImpact.test.js create mode 100644 resources/js/components/FixometerRecordsTable.test.js create mode 100644 resources/js/components/GroupActions.test.js delete mode 100644 resources/js/components/GroupDevicesBreakdown.vue delete mode 100644 resources/js/components/GroupDevicesBreakdownCluster.vue create mode 100644 resources/js/components/GroupStats.test.js create mode 100644 tests/Feature/Events/ExportDevicesQueryCountTest.php create mode 100644 tests/Feature/Fixometer/HeadlineStatsTest.php create mode 100644 tests/Feature/Fixometer/RepairRecordsApiTest.php create mode 100644 tests/Feature/Groups/GroupReportingLinkTest.php create mode 100644 tests/Feature/Networks/NetworkReportingLinkTest.php diff --git a/app/Group.php b/app/Group.php index 1a2dbebb32..3778ef8f98 100644 --- a/app/Group.php +++ b/app/Group.php @@ -471,6 +471,16 @@ public function isMemberOf($network) return $this->networks->contains($network); } + /** + * The group reporting dashboard, filtered to this group, or null if reporting isn't set up. + */ + public function reportingUrl(): ?string + { + $template = config('restarters.reporting.group_url'); + + return $template ? str_replace('{group}', $this->idgroups, $template) : null; + } + // If just one of the networks that the group is a member of should push to Wordpress, then we should push. // If an group is not approved, then we should not push the events to Wordpress. public function eventsShouldPushToWordpress() diff --git a/app/Http/Controllers/ApiController.php b/app/Http/Controllers/ApiController.php index 7e6f2e05da..bfd9bf9f53 100644 --- a/app/Http/Controllers/ApiController.php +++ b/app/Http/Controllers/ApiController.php @@ -7,6 +7,7 @@ use App\Group; use App\Party; use App\User; +use Carbon\Carbon; use Auth; use DB; use Illuminate\Http\Request; @@ -51,10 +52,10 @@ public static function homepage_data(): JsonResponse { $result = []; - $lock = \Cache::lock('homepage_data_lock', 60); + $lock = \Cache::lock('homepage_data_v2_lock', 60); - if (\Cache::has('homepage_data')) { - $result = \Cache::get('homepage_data'); + if (\Cache::has('homepage_data_v2')) { + $result = \Cache::get('homepage_data_v2'); } elseif ($lock->get()) { try { $Device = new Device; @@ -65,6 +66,7 @@ public static function homepage_data(): JsonResponse ->whereNull('deleted_at') ->where('event_end_utc', '<', now()) ->selectRaw(" + COUNT(*) as events, SUM(pax) as participants, SUM(CASE WHEN cancelled = 1 THEN 3 @@ -76,6 +78,7 @@ public static function homepage_data(): JsonResponse $result['participants'] = (int) ($eventStats->participants ?? 0); $result['hours_volunteered'] = (int) ($eventStats->hours_volunteered ?? 0); + $result['events'] = (int) ($eventStats->events ?? 0); $fixed = $Device->statusCount(); $result['items_fixed'] = count($fixed) ? $fixed[0]->counter : 0; @@ -100,13 +103,13 @@ public static function homepage_data(): JsonResponse $result['unpowered_waste'] = round($result['waste_unpowered']); $result['emissions'] = round($result['co2_total']); - \Cache::put('homepage_data', $result, 43200); + \Cache::put('homepage_data_v2', $result, 43200); } finally { $lock->release(); } } else { // Another worker is rebuilding — return stale or empty rather than pile on - $result = \Cache::get('homepage_data', []); + $result = \Cache::get('homepage_data_v2', []); } return response() @@ -204,9 +207,12 @@ public static function getUserList() */ public static function getDevices(Request $request, $page, $size): JsonResponse { + $request->validate([ + 'from_date' => 'nullable|date', + 'to_date' => 'nullable|date', + ]); + $powered = $request->input('powered'); - $sortBy = $request->input('sortBy'); - $sortDesc = $request->input('sortDesc'); $category = $request->input('category'); $brand = $request->input('brand'); $model = $request->input('model'); @@ -218,10 +224,30 @@ public static function getDevices(Request $request, $page, $size): JsonResponse $from_date = $request->input('from_date'); $to_date = $request->input('to_date'); - $wheres = [ - ['categories.powered', '=', $powered == 'true' ? 1 : 0], + // The client asks to sort by one of its table columns; map those onto database columns. Anything else + // gets the default of most recent event first. + $sortColumns = [ + 'item_type' => 'devices.item_type', + 'category' => 'categories.name', + 'device_category.name' => 'categories.name', + 'brand' => 'devices.brand', + 'groupname' => 'groups.name', + 'repair_status' => 'devices.repair_status', + 'event_date' => 'events.event_start_utc', + 'created_at' => 'devices.created_at', + 'iddevices' => 'devices.iddevices', ]; + $sortBy = $sortColumns[$request->input('sortBy')] ?? 'events.event_start_utc'; + $sortDesc = strtolower($request->input('sortDesc', 'desc')) === 'asc' ? 'asc' : 'desc'; + + $wheres = []; + + // No powered filter means both powered and unpowered items. + if ($powered === 'true' || $powered === 'false') { + $wheres[] = ['categories.powered', '=', $powered === 'true' ? 1 : 0]; + } + if ($category) { $wheres[] = ['idcategories', '=', $category]; } @@ -247,7 +273,14 @@ public static function getDevices(Request $request, $page, $size): JsonResponse } if ($status) { - $wheres[] = ['repair_status', '=', $status]; + // The client uses the status strings from the rest of the API; accept the underlying numbers too. + $statuses = [ + Device::REPAIR_STATUS_FIXED_STR => Device::REPAIR_STATUS_FIXED, + Device::REPAIR_STATUS_REPAIRABLE_STR => Device::REPAIR_STATUS_REPAIRABLE, + Device::REPAIR_STATUS_ENDOFLIFE_STR => Device::REPAIR_STATUS_ENDOFLIFE, + ]; + + $wheres[] = ['repair_status', '=', $statuses[$status] ?? intval($status)]; } if ($group) { @@ -255,20 +288,25 @@ public static function getDevices(Request $request, $page, $size): JsonResponse } if ($from_date) { - $wheres[] = ['events.event_start_utc', '>=', $from_date]; + $wheres[] = ['events.event_start_utc', '>=', Carbon::parse($from_date)->startOfDay()]; } if ($to_date) { - $wheres[] = ['events.event_end_utc', '<=', $to_date]; + // The date is inclusive - events on that day count. + $wheres[] = ['events.event_start_utc', '<', Carbon::parse($to_date)->startOfDay()->addDay()]; } - // Get the items we want for this page. + // Get the items we want for this page. Select only device columns - the joined tables share column names + // such as created_at, which would otherwise overwrite the device's own. $query = Device::with(['deviceEvent.theGroup', 'deviceCategory', 'barriers']) + ->select('devices.*') ->join('events', 'events.idevents', '=', 'devices.event') ->join('groups', 'events.group', '=', 'groups.idgroups') ->join('categories', 'devices.category', '=', 'categories.idcategories') + ->whereNull('events.deleted_at') ->where($wheres) - ->orderBy($sortBy, $sortDesc); + ->orderBy($sortBy, $sortDesc) + ->orderBy('devices.iddevices', $sortDesc); // Get total info across all pages. $count = $query->count(); diff --git a/app/Http/Controllers/ExportController.php b/app/Http/Controllers/ExportController.php index 2a48b2dbfd..3ccf237407 100644 --- a/app/Http/Controllers/ExportController.php +++ b/app/Http/Controllers/ExportController.php @@ -37,19 +37,34 @@ public function devices(Request $request, $idevents = NULL, $idgroups = NULL) // To not display column if the referring URL is therestartproject.org $host = parse_url(\Request::server('HTTP_REFERER'), PHP_URL_HOST); + $me = auth()->user(); + + // Only export devices from events this user can see - the same rules as User::userCanSeeEvent(), but + // applied in the query so that we don't have to look up each event and group in turn. $all_devices = Device::with([ 'deviceCategory', - 'deviceEvent', + 'deviceEvent.theGroup', ]) ->join('events', 'events.idevents', '=', 'devices.event') ->join('groups', 'groups.idgroups', '=', 'events.group') + ->whereNull('events.deleted_at') ->when($idevents != NULL, function($query) use ($idevents) { return $query->where('events.idevents', $idevents); }) ->when($idgroups != NULL, function($query) use ($idgroups) { return $query->where('events.group', $idgroups); }) - ->select('devices.*', 'groups.name AS group_name')->get(); + ->when(!$me || !$me->hasRole('Administrator'), function($query) use ($me) { + $extraGroups = $this->groupsWithUnapprovedEventsVisibleTo($me); + + return $query->where(function($query) use ($extraGroups) { + $query->where(function($query) { + $query->where('events.approved', true) + ->where('groups.approved', true); + })->orWhereIn('events.group', $extraGroups); + }); + }) + ->select('devices.*', 'groups.name AS group_name'); $displacementFactor = \App\Device::getDisplacementFactor(); $eEmissionRatio = \App\Helpers\LcaStats::getEmissionRatioPowered(); @@ -75,8 +90,6 @@ public function devices(Request $request, $idevents = NULL, $idgroups = NULL) $fullpath = $this->exportPath($filename); $file = fopen($fullpath, 'w+'); - $me = auth()->user(); - // We can't put accented characters into a CSV file, so flatten them. // Use //TRANSLIT//IGNORE to handle characters that can't be transliterated on // servers with older glibc (e.g. 2.27) and POSIX locale, which lack transliteration @@ -99,45 +112,42 @@ public function devices(Request $request, $idevents = NULL, $idgroups = NULL) ]; fputcsv($file, $columns); - $party = null; - foreach ($all_devices as $device) { + // Work through the devices in chunks so that the whole dataset isn't held in memory at once. + foreach ($all_devices->lazyById(1000, 'devices.iddevices', 'iddevices') as $device) { set_time_limit(60); - $party = !$party || $party->idevents != $device->event ? Party::findOrFail($device->event) : $party; - if (User::userCanSeeEvent($me, $party)) { - $wasteImpact = 0; - $co2Diverted = 0; + $wasteImpact = 0; + $co2Diverted = 0; - if ($device->isFixed()) + if ($device->isFixed()) + { + if ($device->deviceCategory->powered) + { + $wasteImpact = $device->eWasteDiverted(); + $co2Diverted = $device->eCo2Diverted($eEmissionRatio, $displacementFactor); + } else { - if ($device->deviceCategory->powered) - { - $wasteImpact = $device->eWasteDiverted(); - $co2Diverted = $device->eCo2Diverted($eEmissionRatio, $displacementFactor); - } else - { - $wasteImpact = $device->uWasteDiverted(); - $co2Diverted = $device->uCo2Diverted($uEmissionratio, $displacementFactor); - } + $wasteImpact = $device->uWasteDiverted(); + $co2Diverted = $device->uCo2Diverted($uEmissionratio, $displacementFactor); } - - fputcsv($file, $this->csvSafeRow([ - $device->item_type, - $device->deviceCategory->name, - $device->brand, - $device->model, - $device->problem, - $device->getRepairStatus(), - $device->getSpareParts(), - $device->deviceEvent->getEventName(), - $device->deviceEvent->theGroup->name, - $device->deviceEvent->getFormattedLocalStart('Y-m-d'), - $wasteImpact, - $co2Diverted, - $device->deviceCategory->powered ? 'Powered' : 'Unpowered' - ])); } + + fputcsv($file, $this->csvSafeRow([ + $device->item_type, + $device->deviceCategory->name, + $device->brand, + $device->model, + $device->problem, + $device->getRepairStatus(), + $device->getSpareParts(), + $device->deviceEvent->getEventName(), + $device->deviceEvent->theGroup->name, + $device->deviceEvent->getFormattedLocalStart('Y-m-d'), + $wasteImpact, + $co2Diverted, + $device->deviceCategory->powered ? 'Powered' : 'Unpowered' + ])); } fclose($file); @@ -149,6 +159,37 @@ public function devices(Request $request, $idevents = NULL, $idgroups = NULL) return Response::download($fullpath, $filename, $headers)->deleteFileAfterSend(true); } + /** + * Groups whose events a user can see even when the event or group isn't approved: those they host, and those in + * networks they coordinate. + */ + private function groupsWithUnapprovedEventsVisibleTo($user): array + { + if (!$user) { + return []; + } + + $groups = []; + + if ($user->hasRole('Host')) { + $groups = UserGroups::where('user', $user->id) + ->where('role', \App\Role::HOST) + ->pluck('group') + ->all(); + } + + $networks = $user->networks->pluck('id'); + + if ($networks->count()) { + $groups = array_merge($groups, DB::table('group_network') + ->whereIn('network_id', $networks) + ->pluck('group_id') + ->all()); + } + + return array_values(array_unique($groups)); + } + /** * @return \Illuminate\Http\Response */ diff --git a/app/Http/Controllers/GroupController.php b/app/Http/Controllers/GroupController.php index a28eaabcba..e95347c92b 100644 --- a/app/Http/Controllers/GroupController.php +++ b/app/Http/Controllers/GroupController.php @@ -192,66 +192,6 @@ public function view($groupid) $Device->ofThisGroup($group->idgroups); - $counts = $Device->countByClustersYearStatus($group->idgroups); - $template = [ - 0 => [ - 'counter' => 0, - 'repair_status' => 1, - ], - 1 => [ - 'counter' => 0, - 'repair_status' => 2, - ], - 2 => [ - 'counter' => 0, - 'repair_status' => 3, - ], - 'total' => 0 - ]; - - $clusters = [ - 'all' => [ - 1 => $template, - 2 => $template, - 3 => $template, - 4 => $template, - ] - ]; - - foreach ($counts as $count) { - $year = $count->year; - $cluster = $count->cluster; - $repair_status = $count->repair_status; - $counter = $count->counter; - - if ($repair_status && $cluster) { - if (array_key_exists($cluster, $clusters['all'])) { - $clusters['all'][$cluster][$repair_status - 1]['counter'] += $counter; - $clusters['all'][$cluster]['total'] += $counter; - - if (!array_key_exists($year, $clusters)) { - $clusters[$year] = [ - 1 => $template, - 2 => $template, - 3 => $template, - 4 => $template, - ]; - } - - $clusters[$year][$cluster][$repair_status - 1]['counter'] += $counter; - $clusters[$year][$cluster]['total'] += $counter; - } - } - } - - // most/least stats for clusters - $mostleast = []; - for ($i = 1; $i <= 4; $i++) { - $mostleast[$i]['most_seen'] = $Device->findMostSeen(null, $i, $group->idgroups); - $mostleast[$i]['most_repaired'] = $Device->findMostSeen(1, $i, $group->idgroups); - $mostleast[$i]['least_repaired'] = $Device->findMostSeen(3, $i, $group->idgroups); - } - if (! isset($response)) { $response = null; } @@ -319,8 +259,6 @@ public function view($groupid) 'group_device_count_status' => $Device->statusCount($group->idgroups), 'group_stats' => $groupStats, 'expanded_events' => $expanded_events, - 'clusters' => $clusters, - 'mostleast' => $mostleast, 'top' => $Device->findMostSeen(1, null, $group->idgroups), 'user' => $user, 'upcoming_events' => $upcoming_events, diff --git a/app/Http/Controllers/NetworkController.php b/app/Http/Controllers/NetworkController.php index ef269b8022..018fc4f87d 100644 --- a/app/Http/Controllers/NetworkController.php +++ b/app/Http/Controllers/NetworkController.php @@ -87,6 +87,7 @@ public function show(Network $network): View 'name' => $network->name, 'description' => $network->description, 'website' => $network->website, + 'reporting_url' => $network->reporting_url, 'logo' => $network->sizedLogo('_x100'), 'coordinators' => $network->coordinators->map(function ($c) { $profile = $c->getProfile($c->id); @@ -120,6 +121,7 @@ public function edit(Network $network): View return view('networks.edit', [ 'network' => $network, + 'canEditReportingUrl' => Fixometer::hasRole(Auth::user(), 'Administrator'), ]); } @@ -130,6 +132,16 @@ public function update(Request $request, Network $network): RedirectResponse { $this->authorize('update', $network); + // The reporting dashboard address is set up by administrators alongside the dashboard itself. + if (Fixometer::hasRole(Auth::user(), 'Administrator') && $request->has('reporting_url')) { + $request->validate([ + 'reporting_url' => 'nullable|url:https,http|max:2048', + ]); + + $network->reporting_url = $request->input('reporting_url') ?: null; + $network->save(); + } + if ($request->hasFile('network_logo')) { if (! config('restarters.features.image_upload')) { return redirect()->route('networks.edit', [$network]) diff --git a/app/Http/Resources/Device.php b/app/Http/Resources/Device.php index f2ed2806e3..b1644bd233 100644 --- a/app/Http/Resources/Device.php +++ b/app/Http/Resources/Device.php @@ -4,6 +4,7 @@ use Illuminate\Http\Request; use App\Party; +use Carbon\Carbon; use Illuminate\Http\Resources\Json\JsonResource; /** @@ -34,6 +35,13 @@ * example="Europe/London" * ), * @OA\Property( + * property="event_date", + * title="event_date", + * description="When the event started. Provided for convenience to avoid extra API calls.", + * format="date-time", + * example="2024-03-10T13:00:00+00:00" + * ), + * @OA\Property( * property="groupid", * title="groupid", * description="The group to which this device belongs.", @@ -195,6 +203,7 @@ public function toArray(Request $request): array 'id' => intval($this->iddevices), 'eventid' => intval($this->event), 'eventtitle' => $event ? $event->title : NULL, + 'event_date' => $event ? Carbon::parse($event->event_start_utc)->toIso8601String() : NULL, 'groupid' => $event ? intval($event->group) : NULL, 'groupname' => $group ? $group->name : NULL, 'category' => intval($this->category), diff --git a/config/restarters.php b/config/restarters.php index f04f2779d0..054cb8a930 100644 --- a/config/restarters.php +++ b/config/restarters.php @@ -26,6 +26,12 @@ 'api_key' => env('CARTO_API_KEY'), ], + 'reporting' => [ + // The group reporting dashboard, filtered to one group. {group} is replaced by the group id, e.g. + // https://metabase.example.org/public/dashboard/abc?group_id={group}#hide_parameters=group_id + 'group_url' => env('GROUP_REPORTING_URL'), + ], + 'xref_types' => [ 'networks' => 7, ], diff --git a/database/migrations/2026_09_30_000000_add_reporting_url_to_networks_table.php b/database/migrations/2026_09_30_000000_add_reporting_url_to_networks_table.php new file mode 100644 index 0000000000..d9de460b32 --- /dev/null +++ b/database/migrations/2026_09_30_000000_add_reporting_url_to_networks_table.php @@ -0,0 +1,23 @@ +string('reporting_url', 2048)->nullable(); + }); + } + + public function down(): void + { + Schema::table('networks', function (Blueprint $table) { + $table->dropColumn('reporting_url'); + }); + } +}; diff --git a/lang/de/groups.php b/lang/de/groups.php index 4fb009110e..618539a0f4 100644 --- a/lang/de/groups.php +++ b/lang/de/groups.php @@ -74,13 +74,11 @@ 'invite_to_group' => 'Zur Gruppe einladen', 'participants' => 'Teilnehmende', 'hours_volunteered' => 'geleistete Freiwilligenstunden', - 'years_volunteered' => 'geleistete Freiwilligenjahre', 'waste_prevented' => 'Vermiedener Abfall', 'upcoming_active' => 'Bevorstehend & Aktiv', 'past' => 'Vergangen', 'no_upcoming_events' => 'Derzeit gibt es keine bevorstehenden Veranstaltungen.', 'no_past_events' => 'Für diese Gruppe gibt es derzeit keine vergangenen Veranstaltungen', - 'device_breakdown' => 'Geräteaufschlüsselung', 'total_devices' => 'Insgesamt bearbeitete Artikel', 'most_repaired_devices' => 'Am häufigsten reparierte Geräte', 'website' => 'Website', @@ -103,7 +101,6 @@ 'fixed_items' => 'Reparierte Artikel', 'repairable_items' => 'Reparierbare Artikel', 'end_of_life_items' => 'Artikel am Ende ihres Lebens', - 'no_unpowered_stats' => 'Momentan werden diese Statistiken nur für elektrische Geräte angezeigt. Wir hoffen, bald auch nicht-elektrische Gegenstände einzubeziehen.', 'all_groups_mobile' => 'Alle', 'create_groups_mobile2' => 'Neu hinzufügen', 'groups_title1_mobile' => 'Meine', diff --git a/lang/en/devices.php b/lang/en/devices.php index 647f9da1c6..56a0bd917a 100644 --- a/lang/en/devices.php +++ b/lang/en/devices.php @@ -62,7 +62,7 @@ 'status' => 'Status', 'spare_parts' => 'Spare parts', 'latest_data' => 'Latest Data', - 'table_intro' => 'Press the ‘i’ icons for details. Click a column head to sort by that column - click again to reverse sort order.', + 'table_intro' => 'Press the icon at the end of a row to see the details of that repair. Click a column heading to sort by that column - click again to reverse the order.', 'assessment' => 'Assessment', 'item_and_repair_info' => 'Item & Repair Info', 'search_assessment_comments' => 'Assessment', @@ -78,4 +78,13 @@ 'image_delete_success' => 'Thank you, the image has been deleted', 'image_delete_error' => 'Sorry, but the image can\'t be deleted', 'image_upload_error' => 'fail - image could not be uploaded', + 'powered_items_fixed' => 'powered items fixed', + 'unpowered_items_fixed' => 'unpowered items fixed', + 'events_held' => 'events held', + 'powered_or_unpowered' => 'Powered or unpowered', + 'all_items' => 'All items', + 'powered' => 'Powered', + 'unpowered' => 'Unpowered', + 'view_record' => 'See the details of this repair', + 'edit_record' => 'See and edit the details of this repair', ]; diff --git a/lang/en/groups.php b/lang/en/groups.php index 7a11f8d64f..7b6b17c185 100644 --- a/lang/en/groups.php +++ b/lang/en/groups.php @@ -73,15 +73,13 @@ 'participants' => 'participants', 'parties_thrown' => 'parties thrown', 'hours_volunteered' => 'hours volunteered', - 'years_volunteered' => 'years volunteered', 'waste_prevented' => 'Waste prevented', 'upcoming_active' => 'Upcoming & Active', 'past' => 'Past', 'no_upcoming_events' => 'There are currently no upcoming events.', 'no_past_events' => 'There are currently no past events for this group', - 'device_breakdown' => 'Device breakdown', 'total_devices' => 'Total items worked on', - 'most_repaired_devices' => 'Most repaired devices', + 'most_repaired_devices' => 'Most repaired items', 'website' => 'Website', 'about_none' => 'There is no description for this group yet.', 'read_less' => ' READ LESS', @@ -102,7 +100,6 @@ 'fixed_items' => 'Fixed items', 'repairable_items' => 'Repairable items', 'end_of_life_items' => 'End-of-life items', - 'no_unpowered_stats' => 'At the moment, these stats are only displayed for powered items. We hope to include unpowered items soon.', 'create_groups_mobile2' => 'Add new', 'groups_title1_mobile' => 'Yours', 'groups_title2_mobile' => 'Find', @@ -181,4 +178,7 @@ 'search_nothing_found' => 'Nothing found.', 'goto_group' => 'Go to group', 'next_event' => 'Next event', + 'years_of_time_volunteered' => 'years of time volunteered', + 'see_group_reports' => 'See more in your group\'s reports', + 'view_reports' => 'View reports', ]; diff --git a/lang/en/networks.php b/lang/en/networks.php index 61505b2603..05070f4661 100644 --- a/lang/en/networks.php +++ b/lang/en/networks.php @@ -4,6 +4,7 @@ 'networks' => 'Networks', 'network' => 'Network', 'general' => [ + 'view_reports' => 'View reports', 'networks' => 'Networks', 'network' => 'Network', 'particular_network' => ':networkName network', @@ -55,6 +56,8 @@ 'none' => 'None', ], 'edit' => [ + 'label_reporting_url' => 'Reporting dashboard address', + 'help_reporting_url' => 'Where this network\'s reports are published. Leave blank if the network has no reports.', 'label_logo' => 'Network logo', 'button_save' => 'Save changes', 'add_new_field' => 'Add new field', diff --git a/lang/es/groups.php b/lang/es/groups.php index fff0be774e..fa3e30721d 100644 --- a/lang/es/groups.php +++ b/lang/es/groups.php @@ -74,13 +74,11 @@ 'invite_to_group' => 'Invitar al grupo', 'participants' => 'participantes', 'hours_volunteered' => 'horas de voluntariado', - 'years_volunteered' => 'años de voluntariado', 'waste_prevented' => 'Residuos evitados', 'upcoming_active' => 'Próximos y activos', 'past' => 'Pasados', 'no_upcoming_events' => 'Actualmente no hay próximos eventos.', 'no_past_events' => 'Actualmente no hay eventos pasados para este grupo', - 'device_breakdown' => 'Desglose de dispositivos', 'total_devices' => 'Total de artículos trabajados', 'most_repaired_devices' => 'Dispositivos más reparados', 'website' => 'Sitio web', @@ -103,7 +101,6 @@ 'fixed_items' => 'Artículos reparados', 'repairable_items' => 'Artículos reparables', 'end_of_life_items' => 'Artículos al final de su vida útil', - 'no_unpowered_stats' => 'En este momento, estas estadísticas solo se muestran para artículos con alimentación eléctrica. Esperamos incluir artículos sin alimentación próximamente.', 'all_groups_mobile' => 'Todos', 'create_groups_mobile2' => 'Añadir nuevo', 'groups_title1_mobile' => 'Los tuyos', diff --git a/lang/fr-BE/devices.php b/lang/fr-BE/devices.php index e64556c516..4889e35be5 100644 --- a/lang/fr-BE/devices.php +++ b/lang/fr-BE/devices.php @@ -65,7 +65,7 @@ 'assessment' => 'Evaluation', 'item_and_repair_info' => 'Appareils et informations de réparation', 'search_assessment_comments' => 'Evaluation', - 'table_intro' => 'Cliquez sur la ligne pour plus de détails. Cliquez sur l\'en-tête d\'une colonne pour trier par cette colonne - cliquez à nouveau pour inverser l\'ordre de tri.', + 'table_intro' => 'Cliquez sur l\'icône en fin de ligne pour voir les détails de cette réparation. Cliquez sur l\'en-tête d\'une colonne pour trier par cette colonne - cliquez à nouveau pour inverser l\'ordre.', 'tooltip_type' => 'Ajoutez autant d\'informations que possible sur le type d\'objet (ex: "Jeans Denim", ou "divan")', 'add_data_event' => 'Veuillez sélectionner un événement', 'add_data_group' => 'Veuillez sélectionner un Repair Café', @@ -78,4 +78,13 @@ 'image_delete_success' => 'Merci, l\'image a été supprimée', 'image_delete_error' => 'Désolé, mais l\'image ne peut pas être supprimée.', 'image_upload_error' => 'fail - l\'image n\'a pas pu être téléchargée', + 'powered_items_fixed' => 'appareils électriques réparés', + 'unpowered_items_fixed' => 'objets non électriques réparés', + 'events_held' => 'événements organisés', + 'powered_or_unpowered' => 'Électrique ou non électrique', + 'all_items' => 'Tous les objets', + 'powered' => 'Électrique', + 'unpowered' => 'Non électrique', + 'view_record' => 'Voir les détails de cette réparation', + 'edit_record' => 'Voir et modifier les détails de cette réparation', ]; diff --git a/lang/fr-BE/groups.php b/lang/fr-BE/groups.php index cfdeae755a..0a31421a7d 100644 --- a/lang/fr-BE/groups.php +++ b/lang/fr-BE/groups.php @@ -65,12 +65,11 @@ 'create_groups_mobile' => 'créer', 'about' => 'A propos', 'add_event' => 'Ajouter événement', - 'device_breakdown' => 'Panne de l\'appareil', 'group_actions' => 'Gestion du Repair Café', 'host' => 'Organisateur', 'invite_to_group' => 'Inviter au Repair Café', 'invite_volunteers' => 'Inviter des bénévoles', - 'most_repaired_devices' => 'Les appareils les plus réparés', + 'most_repaired_devices' => 'Les objets les plus réparés', 'no_past_events' => 'Il n\'y a actuellement aucun événement passé pour ce Repair Café', 'no_upcoming_events' => 'Il n\'y a actuellement aucun événément à venir pour ce Repair Café', 'past' => 'Passé', @@ -80,7 +79,6 @@ 'participants' => 'participants', 'parties_thrown' => 'événements organisés', 'hours_volunteered' => 'heures de bénévolat', - 'years_volunteered' => 'années de bénévolat', 'waste_prevented' => 'Déchets évités', 'upcoming_active' => 'A venir et actif', 'volunteers' => 'Bénévoles', @@ -118,7 +116,6 @@ 'no_groups_mine' => 'Si vous ne pouvez encore en voir aucun ici, pourquoi ne pas trouver un Repair Café près de chez vous?', 'no_groups_nearest_no_location' => '

Vous n\'avez pas défini de village/ville. Vous pouvez en ajouter un.e dans votre profil.

Vous pouvez aussi voir tous les Repair Cafés.

', 'no_groups_nearest_with_location' => '

Il n\'y a apparemment pas encore de Repair Cafés listé proche de chez vous.

Voulez-vous créer ou ajouter un Repair Café? Regardez comment faire dans nos ressources.

', - 'no_unpowered_stats' => 'Pour l\'instant, ces statistiques sont seulement affichées pour les appareils électriques. Nous espérons pouvoir inclure les appareils non-électriques sous peu.', 'search_country_placeholder' => 'Filtrer par pays', 'search_location_placeholder' => 'Rechercher par localisation', 'search_name_placeholder' => 'Rechercher par nom', @@ -184,4 +181,7 @@ 'search_nothing_found' => 'Aucun résultat.', 'goto_group' => 'Aller au Repair Café', 'next_event' => 'Prochain événement', + 'years_of_time_volunteered' => 'années de temps de bénévolat', + 'see_group_reports' => 'En savoir plus dans les rapports de votre groupe', + 'view_reports' => 'Voir les rapports', ]; diff --git a/lang/fr-BE/networks.php b/lang/fr-BE/networks.php index 84bf262c9b..f116f3178d 100644 --- a/lang/fr-BE/networks.php +++ b/lang/fr-BE/networks.php @@ -2,6 +2,8 @@ return [ 'edit' => [ + 'label_reporting_url' => 'Adresse du tableau de bord des rapports', + 'help_reporting_url' => 'L\'adresse où sont publiés les rapports de ce réseau. Laissez vide si le réseau n\'a pas de rapports.', 'button_save' => 'Sauver les changements', 'label_logo' => 'Logo du réseau', 'add_new_field' => 'Ajouter un nouveau champ', @@ -9,6 +11,7 @@ 'add_field' => 'Ajouter le champ', ], 'general' => [ + 'view_reports' => 'Voir les rapports', 'network' => 'Réseau', 'networks' => 'Réseaux', 'particular_network' => 'Réseau :networkName', diff --git a/lang/fr/devices.php b/lang/fr/devices.php index e64556c516..4889e35be5 100644 --- a/lang/fr/devices.php +++ b/lang/fr/devices.php @@ -65,7 +65,7 @@ 'assessment' => 'Evaluation', 'item_and_repair_info' => 'Appareils et informations de réparation', 'search_assessment_comments' => 'Evaluation', - 'table_intro' => 'Cliquez sur la ligne pour plus de détails. Cliquez sur l\'en-tête d\'une colonne pour trier par cette colonne - cliquez à nouveau pour inverser l\'ordre de tri.', + 'table_intro' => 'Cliquez sur l\'icône en fin de ligne pour voir les détails de cette réparation. Cliquez sur l\'en-tête d\'une colonne pour trier par cette colonne - cliquez à nouveau pour inverser l\'ordre.', 'tooltip_type' => 'Ajoutez autant d\'informations que possible sur le type d\'objet (ex: "Jeans Denim", ou "divan")', 'add_data_event' => 'Veuillez sélectionner un événement', 'add_data_group' => 'Veuillez sélectionner un Repair Café', @@ -78,4 +78,13 @@ 'image_delete_success' => 'Merci, l\'image a été supprimée', 'image_delete_error' => 'Désolé, mais l\'image ne peut pas être supprimée.', 'image_upload_error' => 'fail - l\'image n\'a pas pu être téléchargée', + 'powered_items_fixed' => 'appareils électriques réparés', + 'unpowered_items_fixed' => 'objets non électriques réparés', + 'events_held' => 'événements organisés', + 'powered_or_unpowered' => 'Électrique ou non électrique', + 'all_items' => 'Tous les objets', + 'powered' => 'Électrique', + 'unpowered' => 'Non électrique', + 'view_record' => 'Voir les détails de cette réparation', + 'edit_record' => 'Voir et modifier les détails de cette réparation', ]; diff --git a/lang/fr/groups.php b/lang/fr/groups.php index 24beb108dc..9d064a3bc9 100644 --- a/lang/fr/groups.php +++ b/lang/fr/groups.php @@ -65,12 +65,11 @@ 'create_groups_mobile' => 'créer', 'about' => 'A propos', 'add_event' => 'Ajouter événement', - 'device_breakdown' => 'Panne de l\'appareil', 'group_actions' => 'Gestion du Repair Café', 'host' => 'Organisateur', 'invite_to_group' => 'Inviter au Repair Café', 'invite_volunteers' => 'Inviter des bénévoles', - 'most_repaired_devices' => 'Les appareils les plus réparés', + 'most_repaired_devices' => 'Les objets les plus réparés', 'no_past_events' => 'Il n\'y a actuellement aucun événement passé pour ce Repair Café', 'no_upcoming_events' => 'Il n\'y a actuellement aucun événément à venir pour ce Repair Café', 'past' => 'Passé', @@ -80,7 +79,6 @@ 'participants' => 'participants', 'parties_thrown' => 'événements organisés', 'hours_volunteered' => 'heures de bénévolat', - 'years_volunteered' => 'années de bénévolat', 'waste_prevented' => 'Déchets évités', 'upcoming_active' => 'A venir et actif', 'volunteers' => 'Bénévoles', @@ -118,7 +116,6 @@ 'no_groups_mine' => 'Si vous ne pouvez encore en voir aucun ici, pourquoi ne pas trouver un Repair Café près de chez vous?', 'no_groups_nearest_no_location' => '

Vous n\'avez pas défini de village/ville. Vous pouvez en ajouter un.e dans votre profil.

Vous pouvez aussi voir tous les Repair Cafés.

', 'no_groups_nearest_with_location' => '

Il n\'y a apparemment pas encore de Repair Cafés listé proche de chez vous.

Voulez-vous créer ou ajouter un Repair Café? Regardez comment faire dans nos ressources.

', - 'no_unpowered_stats' => 'Pour l\'instant, ces statistiques sont seulement affichées pour les appareils électriques. Nous espérons pouvoir inclure les appareils non-électriques sous peu.', 'search_country_placeholder' => 'Filtrer par pays', 'search_location_placeholder' => 'Rechercher par localisation', 'search_name_placeholder' => 'Rechercher par nom', @@ -184,4 +181,7 @@ 'search_nothing_found' => 'Aucun résultat.', 'goto_group' => 'Aller au Repair Café', 'next_event' => 'Prochain événement', + 'years_of_time_volunteered' => 'années de temps de bénévolat', + 'see_group_reports' => 'En savoir plus dans les rapports de votre groupe', + 'view_reports' => 'Voir les rapports', ]; diff --git a/lang/fr/networks.php b/lang/fr/networks.php index 84bf262c9b..f116f3178d 100644 --- a/lang/fr/networks.php +++ b/lang/fr/networks.php @@ -2,6 +2,8 @@ return [ 'edit' => [ + 'label_reporting_url' => 'Adresse du tableau de bord des rapports', + 'help_reporting_url' => 'L\'adresse où sont publiés les rapports de ce réseau. Laissez vide si le réseau n\'a pas de rapports.', 'button_save' => 'Sauver les changements', 'label_logo' => 'Logo du réseau', 'add_new_field' => 'Ajouter un nouveau champ', @@ -9,6 +11,7 @@ 'add_field' => 'Ajouter le champ', ], 'general' => [ + 'view_reports' => 'Voir les rapports', 'network' => 'Réseau', 'networks' => 'Réseaux', 'particular_network' => 'Réseau :networkName', diff --git a/lang/it/groups.php b/lang/it/groups.php index 59e0d3411c..6cac8af26a 100644 --- a/lang/it/groups.php +++ b/lang/it/groups.php @@ -74,13 +74,11 @@ 'invite_to_group' => 'Invita nel gruppo', 'participants' => 'partecipanti', 'hours_volunteered' => 'ore di volontariato', - 'years_volunteered' => 'anni di volontariato', 'waste_prevented' => 'Rifiuti prevenuti', 'upcoming_active' => 'Prossimi e attivi', 'past' => 'Passati', 'no_upcoming_events' => 'Al momento non ci sono prossimi eventi.', 'no_past_events' => 'Al momento non ci sono eventi passati per questo gruppo', - 'device_breakdown' => 'Ripartizione dispositivi', 'total_devices' => 'Totale oggetti trattati', 'most_repaired_devices' => 'Dispositivi più riparati', 'website' => 'Website', @@ -103,7 +101,6 @@ 'fixed_items' => 'Oggetti riparati', 'repairable_items' => 'Oggetti riparabili', 'end_of_life_items' => 'Oggetti a fine vita', - 'no_unpowered_stats' => 'Al momento queste statistiche sono visualizzate solo per gli oggetti alimentati. Speriamo di includere presto gli oggetti non alimentati.', 'all_groups_mobile' => 'Tutti', 'create_groups_mobile2' => 'Aggiungi nuovo', 'groups_title1_mobile' => 'Tuoi', diff --git a/lang/ne/groups.php b/lang/ne/groups.php index 93fdae5cc0..683d881841 100644 --- a/lang/ne/groups.php +++ b/lang/ne/groups.php @@ -74,13 +74,11 @@ 'invite_to_group' => 'Uitnodigen voor groep', 'participants' => 'deelnemers', 'hours_volunteered' => 'vrijwilligersuren', - 'years_volunteered' => 'vrijwilligersjaren', 'waste_prevented' => 'Afval voorkomen', 'upcoming_active' => 'Aankomend & Actief', 'past' => 'Afgelopen', 'no_upcoming_events' => 'Er zijn momenteel geen aankomende evenementen.', 'no_past_events' => 'Er zijn momenteel geen afgelopen evenementen voor deze groep', - 'device_breakdown' => 'Apparatenanalyse', 'total_devices' => 'Totaal aantal items bewerkt', 'most_repaired_devices' => 'Meest gerepareerde apparaten', 'website' => 'Website', @@ -103,7 +101,6 @@ 'fixed_items' => 'Gerepareerde items', 'repairable_items' => 'Repareerbare items', 'end_of_life_items' => 'Items aan einde levensduur', - 'no_unpowered_stats' => 'Op dit moment worden deze statistieken alleen weergegeven voor elektrische items. We hopen binnenkort ook niet-elektrische items op te nemen.', 'all_groups_mobile' => 'Alle', 'create_groups_mobile2' => 'Nieuw toevoegen', 'groups_title1_mobile' => 'Van mij', diff --git a/lang/nl-BE/groups.php b/lang/nl-BE/groups.php index 53a0671f4f..475ad6097e 100644 --- a/lang/nl-BE/groups.php +++ b/lang/nl-BE/groups.php @@ -74,13 +74,11 @@ 'invite_to_group' => 'Uitnodigen voor groep', 'participants' => 'deelnemers', 'hours_volunteered' => 'uren vrijwilligerswerk', - 'years_volunteered' => 'jaren vrijwilligerswerk', 'waste_prevented' => 'Afval vermeden', 'upcoming_active' => 'Komend & Actief', 'past' => 'Voorbij', 'no_upcoming_events' => 'Er zijn momenteel geen komende activiteiten.', 'no_past_events' => 'Er zijn momenteel geen voorbije activiteiten voor deze groep', - 'device_breakdown' => 'Overzicht van toestellen', 'total_devices' => 'Totaal aantal behandelde items', 'most_repaired_devices' => 'Meest herstelde toestellen', 'website' => 'Website', @@ -103,7 +101,6 @@ 'fixed_items' => 'Herstelde items', 'repairable_items' => 'Herstelbare items', 'end_of_life_items' => 'Onherstelbare items', - 'no_unpowered_stats' => 'Momenteel worden deze statistieken alleen weergegeven voor elektrische items. We hopen binnenkort ook niet-elektrische items op te nemen.', 'all_groups_mobile' => 'Alle', 'create_groups_mobile2' => 'Nieuw toevoegen', 'groups_title1_mobile' => 'Jouw', diff --git a/lang/nl/groups.php b/lang/nl/groups.php index 93fdae5cc0..683d881841 100644 --- a/lang/nl/groups.php +++ b/lang/nl/groups.php @@ -74,13 +74,11 @@ 'invite_to_group' => 'Uitnodigen voor groep', 'participants' => 'deelnemers', 'hours_volunteered' => 'vrijwilligersuren', - 'years_volunteered' => 'vrijwilligersjaren', 'waste_prevented' => 'Afval voorkomen', 'upcoming_active' => 'Aankomend & Actief', 'past' => 'Afgelopen', 'no_upcoming_events' => 'Er zijn momenteel geen aankomende evenementen.', 'no_past_events' => 'Er zijn momenteel geen afgelopen evenementen voor deze groep', - 'device_breakdown' => 'Apparatenanalyse', 'total_devices' => 'Totaal aantal items bewerkt', 'most_repaired_devices' => 'Meest gerepareerde apparaten', 'website' => 'Website', @@ -103,7 +101,6 @@ 'fixed_items' => 'Gerepareerde items', 'repairable_items' => 'Repareerbare items', 'end_of_life_items' => 'Items aan einde levensduur', - 'no_unpowered_stats' => 'Op dit moment worden deze statistieken alleen weergegeven voor elektrische items. We hopen binnenkort ook niet-elektrische items op te nemen.', 'all_groups_mobile' => 'Alle', 'create_groups_mobile2' => 'Nieuw toevoegen', 'groups_title1_mobile' => 'Van mij', diff --git a/lang/no/groups.php b/lang/no/groups.php index e52f37225e..0d0578c229 100644 --- a/lang/no/groups.php +++ b/lang/no/groups.php @@ -74,13 +74,11 @@ 'invite_to_group' => 'Inviter til gruppe', 'participants' => 'deltakere', 'hours_volunteered' => 'timer frivillig arbeid', - 'years_volunteered' => 'år frivillig arbeid', 'waste_prevented' => 'Forhindret avfall', 'upcoming_active' => 'Kommende og aktive', 'past' => 'Tidligere', 'no_upcoming_events' => 'Det er for øyeblikket ingen kommende arrangementer.', 'no_past_events' => 'Det er for øyeblikket ingen tidligere arrangementer for denne gruppen', - 'device_breakdown' => 'Enhetsfordeling', 'total_devices' => 'Totalt antall gjenstander arbeidet med', 'most_repaired_devices' => 'Mest reparerte enheter', 'website' => 'Nettsted', @@ -103,7 +101,6 @@ 'fixed_items' => 'Reparerte gjenstander', 'repairable_items' => 'Reparerbare gjenstander', 'end_of_life_items' => 'Gjenstander ved slutten av levetid', - 'no_unpowered_stats' => 'For øyeblikket vises disse statistikkene kun for elektriske gjenstander. Vi håper å inkludere ikke-elektriske gjenstander snart.', 'all_groups_mobile' => 'Alle', 'create_groups_mobile2' => 'Legg til ny', 'groups_title1_mobile' => 'Dine', diff --git a/resources/js/components/DeviceCategorySelect.vue b/resources/js/components/DeviceCategorySelect.vue index 44064e5c73..9734cdeaaf 100644 --- a/resources/js/components/DeviceCategorySelect.vue +++ b/resources/js/components/DeviceCategorySelect.vue @@ -45,9 +45,11 @@ export default { required: true }, powered: { - // The server might return a number rather than a boolean. + // The server might return a number rather than a boolean. Null offers both powered and unpowered categories, + // for searching. type: [ Boolean, Number ], - required: true + required: false, + default: null }, iconVariant: { type: String, @@ -88,6 +90,9 @@ export default { miscCat() { return this.powered ? CATEGORY_MISC_POWERED : CATEGORY_MISC_UNPOWERED }, + anyPower() { + return this.powered === null + }, categoryOptions() { let ret = [] @@ -97,7 +102,9 @@ export default { cluster.categories.forEach((c) => { // Don't include the misc category - it gets added at the end, which encourages people to read the // whole list before selecting it. - if (((c.idcategories !== this.miscCat) && + if (this.anyPower ? + (c.idcategories !== CATEGORY_MISC_POWERED && c.idcategories !== CATEGORY_MISC_UNPOWERED) : + ((c.idcategories !== this.miscCat) && ((this.powered && c.powered) || (!this.powered && !c.powered)))) { categories.push({ name: this.__(c.name), @@ -116,7 +123,16 @@ export default { ret.push({ cluster: '---', - categories: [ + categories: this.anyPower ? [ + { + name: this.__('partials.category_none') + ' (' + this.__('devices.powered') + ')', + value: CATEGORY_MISC_POWERED, + }, + { + name: this.__('partials.category_none') + ' (' + this.__('devices.unpowered') + ')', + value: CATEGORY_MISC_UNPOWERED, + } + ] : [ { name: this.__('partials.category_none'), value: this.miscCat, diff --git a/resources/js/components/FixometerFilters.vue b/resources/js/components/FixometerFilters.vue index 7ebf98beed..342582fd70 100644 --- a/resources/js/components/FixometerFilters.vue +++ b/resources/js/components/FixometerFilters.vue @@ -21,18 +21,26 @@ + + + - + + + + - + - + - - -
'', + }, + stubs: { + FixometerLatestData: true, + StatsValue: { + props: ['count', 'title'], + template: '
{{ count }}
', + }, + }, + }) +} + +function stat(wrapper, title) { + return wrapper.find(`.stats-value[data-title="${title}"]`) +} + +test('shows the number of events held', () => { + const wrapper = makeWrapper() + + expect(stat(wrapper, 'devices.events_held').text()).toBe('1234') +}) + +test('makes clear the powered and unpowered figures are items fixed', () => { + const wrapper = makeWrapper() + + expect(stat(wrapper, 'devices.powered_items_fixed').text()).toBe('40') + expect(stat(wrapper, 'devices.unpowered_items_fixed').text()).toBe('50') +}) + +test('labels volunteering as years of time volunteered', () => { + const wrapper = makeWrapper() + + expect(stat(wrapper, 'groups.years_of_time_volunteered').text()).toBe('2') +}) diff --git a/resources/js/components/FixometerGlobalImpact.vue b/resources/js/components/FixometerGlobalImpact.vue index 56c344fc48..7aab64780c 100644 --- a/resources/js/components/FixometerGlobalImpact.vue +++ b/resources/js/components/FixometerGlobalImpact.vue @@ -20,10 +20,11 @@ + - - - + + +
{{ __('partials.impact_estimates') }} @@ -48,9 +49,6 @@ export default { } }, components: {FixometerLatestData, StatsValue}, - mounted() { - console.log('Impact data', this.impactData, Math.round(10 * this.impactData.hours_volunteered / 8766) / 10) - } } \ No newline at end of file diff --git a/resources/js/components/GroupDevicesBreakdownCluster.vue b/resources/js/components/GroupDevicesBreakdownCluster.vue deleted file mode 100644 index 66161b682b..0000000000 --- a/resources/js/components/GroupDevicesBreakdownCluster.vue +++ /dev/null @@ -1,147 +0,0 @@ - - - \ No newline at end of file diff --git a/resources/js/components/GroupHeading.vue b/resources/js/components/GroupHeading.vue index 0a75fdbdd7..c1e93cdf4f 100644 --- a/resources/js/components/GroupHeading.vue +++ b/resources/js/components/GroupHeading.vue @@ -3,7 +3,7 @@

{{ __('groups.groups') }}

@@ -26,7 +26,7 @@ {{ __('groups.website') }}
@@ -63,6 +63,11 @@ export default { required: false, default: false }, + reportingUrl: { + type: String, + required: false, + default: null + }, }, computed: { groupImage() { diff --git a/resources/js/components/GroupPage.vue b/resources/js/components/GroupPage.vue index 51f07f58bc..001ea28646 100644 --- a/resources/js/components/GroupPage.vue +++ b/resources/js/components/GroupPage.vue @@ -9,6 +9,7 @@ :can-perform-delete="canPerformDelete" :can-perform-archive="canPerformArchive" :ingroup="ingroup" + :reporting-url="reportingUrl || null" @left="haveLeft = true" /> @@ -22,7 +23,13 @@
- +

@@ -37,16 +44,6 @@ add-button /> -
-
- -
-
- -
-
- - @@ -34,11 +60,11 @@ export default { .stats { display: grid; grid-template-columns: 1fr; - grid-template-rows: auto 0px auto; + grid-template-rows: auto 0px auto auto 0px auto; @include media-breakpoint-up(md) { grid-template-columns: 1fr 20px 1fr; - grid-template-rows: 1fr; + grid-template-rows: auto auto; } } diff --git a/resources/js/components/NetworkPage.test.js b/resources/js/components/NetworkPage.test.js index 64e7a29d4b..1ed31936df 100644 --- a/resources/js/components/NetworkPage.test.js +++ b/resources/js/components/NetworkPage.test.js @@ -101,3 +101,20 @@ test('does not offer the tag filter to users who cannot see tags', () => { expect(map.props('canManageTags')).toBe(false) expect(map.props('availableTags')).toEqual([]) }) + +test('links to the network reporting dashboard from the actions menu', () => { + const wrapper = makeWrapper({ + network: { id: 5, name: 'Test Network', coordinators: [], reporting_url: 'https://reports.example.org/dashboard/7' }, + }) + + const link = wrapper.find('a.network-reporting-link') + expect(link.exists()).toBe(true) + expect(link.attributes('href')).toBe('https://reports.example.org/dashboard/7') + expect(link.attributes('target')).toBe('_blank') +}) + +test('has no reporting link when the network has no dashboard', () => { + const wrapper = makeWrapper() + + expect(wrapper.find('a.network-reporting-link').exists()).toBe(false) +}) diff --git a/resources/js/components/NetworkPage.vue b/resources/js/components/NetworkPage.vue index 59cb4264eb..5b74967310 100644 --- a/resources/js/components/NetworkPage.vue +++ b/resources/js/components/NetworkPage.vue @@ -13,6 +13,7 @@
{{ __('networks.show.add_groups_menuitem') }} + {{ __('networks.general.view_reports') }} {{ __('groups.export_event_list') }}
diff --git a/resources/views/group/view.blade.php b/resources/views/group/view.blade.php index d382f68c80..c123642589 100644 --- a/resources/views/group/view.blade.php +++ b/resources/views/group/view.blade.php @@ -66,72 +66,15 @@ } } - $category_clusters = [ - 1 => 'Computers and Home Office', - 2 => 'Electronic Gadgets', - 3 => 'Home Entertainment', - 4 => 'Kitchen and Household Items', - ]; - - $cluster_stats = []; - - foreach ($category_clusters as $key => $category_cluster) { - $fixed = isset($clusters['all'][$key][0]) ? (int) $clusters['all'][$key][0]['counter'] : 0; - $repairable = isset($clusters['all'][$key][1]) ? (int) $clusters['all'][$key][1]['counter'] : 0; - $dead = isset($clusters['all'][$key][2]) ? (int) $clusters['all'][$key][2]['counter'] : 0; - $total = $clusters['all'][$key]['total'] ? $clusters['all'][$key]['total'] : 0; - - //Seen and repaired stats - if (isset($mostleast[$key]['most_seen'][0])) { - $most_seen = $mostleast[$key]['most_seen'][0]->name; - $most_seen_type = $mostleast[$key]['most_seen'][0]->counter; - } else { - $most_seen = null; - $most_seen_type = 0; - } - - if (isset($mostleast[$key]['most_repaired'][0])) { - $most_repaired = $mostleast[$key]['most_repaired'][0]->name; - $most_repaired_type = $mostleast[$key]['most_repaired'][0]->counter; - } else { - $most_repaired = null; - $most_repaired_type = 0; - } - - if (isset($mostleast[$key]['least_repaired'][0])) { - $least_repaired = $mostleast[$key]['least_repaired'][0]->name; - $least_repaired_type = $mostleast[$key]['least_repaired'][0]->counter; - } else { - $least_repaired = null; - $least_repaired_type = 0; - } - - $cluster_stats[$key] = [ - 'fixed' => $fixed, - 'repairable' => $repairable, - 'dead' => $dead, - 'total' => $total, - 'most_seen' => [ - 'name' => $most_seen, - 'count' => $most_seen_type, - ], - 'most_repaired' => [ - 'name' => $most_repaired, - 'count' => $most_repaired_type, - ], - 'least_repaired' => [ - 'name' => $least_repaired, - 'count' => $least_repaired_type, - ], - ]; - } - $in_group = \App\UserGroups::where('group', $group->idgroups) ->where('user', Auth::id()) ->where('status', 1) ->whereNull('users_groups.deleted_at') ->exists(); + // Group members and those who look after the group can go to the group's reports. + $reporting_url = ($can_edit_group || $in_group) ? $group->reportingUrl() : null; + $discourseGroup = $group->discourse_group ? (env('DISCOURSE_URL').'/g/'.$group->discourse_group) : null; ?> @@ -146,7 +89,6 @@ :initial-group="{{ $group }}" :group-stats="{{ json_encode($group_stats, JSON_INVALID_UTF8_IGNORE) }}" :device-stats="{{ json_encode($device_stats, JSON_INVALID_UTF8_IGNORE) }}" - :cluster-stats="{{ json_encode($cluster_stats, JSON_INVALID_UTF8_IGNORE) }}" :top-devices="{{ json_encode($top, JSON_INVALID_UTF8_IGNORE) }}" :events="{{ json_encode($expanded_events, JSON_INVALID_UTF8_IGNORE) }}" :canedit="{{ $can_edit_group ? 'true' : 'false' }}" @@ -158,6 +100,7 @@ calendar-edit-url="{{ $showCalendar ? url("/profile/edit/{$user->id}#list-calendar-links") : '' }}" :ingroup="{{ $in_group ? 'true' : 'false' }}" discourse-group="{{ $discourseGroup }}" + reporting-url="{{ $reporting_url }}" /> diff --git a/resources/views/networks/edit.blade.php b/resources/views/networks/edit.blade.php index ba3e95e998..3b9a992baa 100644 --- a/resources/views/networks/edit.blade.php +++ b/resources/views/networks/edit.blade.php @@ -32,6 +32,19 @@ + @if ($canEditReportingUrl) +
+
+ + + @error('reporting_url') +
{{ $message }}
+ @enderror + @lang('networks.edit.help_reporting_url') +
+
+ @endif +
diff --git a/tests/Feature/Events/ExportDevicesQueryCountTest.php b/tests/Feature/Events/ExportDevicesQueryCountTest.php new file mode 100644 index 0000000000..dcd216b09d --- /dev/null +++ b/tests/Feature/Events/ExportDevicesQueryCountTest.php @@ -0,0 +1,100 @@ +create(['approved' => $approved]); + + for ($i = 0; $i < $count; $i++) { + $event = Party::factory()->create([ + 'group' => $group->idgroups, + 'approved' => $approved, + 'event_start_utc' => '2024-01-0' . ($i + 1) . 'T10:00:00+00:00', + 'event_end_utc' => '2024-01-0' . ($i + 1) . 'T12:00:00+00:00', + ]); + + Device::factory()->fixed()->count(2)->create([ + 'category' => 11, + 'category_creation' => 11, + 'event' => $event->idevents, + ]); + } + } + + private function exportQueryCount(): int + { + DB::flushQueryLog(); + DB::enableQueryLog(); + $this->get('/export/devices')->assertSuccessful(); + $count = count(DB::getQueryLog()); + DB::disableQueryLog(); + + return $count; + } + + private function exportedRows(): array + { + $response = $this->get('/export/devices'); + $header = $response->headers->get('content-disposition'); + $filename = storage_path('app/exports') . '/' . substr($header, strpos($header, 'filename=') + 9); + + $rows = array_map('str_getcsv', file($filename)); + array_shift($rows); + + return $rows; + } + + public function testQueryCountDoesNotGrowWithEvents(): void + { + $this->actingAs(User::factory()->restarter()->create()); + + $this->addEvents(2); + $this->exportQueryCount(); + $for2 = $this->exportQueryCount(); + + $this->addEvents(6); + $for8 = $this->exportQueryCount(); + + $this->assertLessThanOrEqual($for2 + 2, $for8, "2 events: $for2 queries, 8 events: $for8 queries"); + } + + public function testOrdinaryUserOnlyGetsApprovedEvents(): void + { + $this->actingAs(User::factory()->restarter()->create()); + + $this->addEvents(2); + $this->addEvents(1, false); + + $this->assertCount(4, $this->exportedRows()); + } + + public function testAdministratorGetsEveryEvent(): void + { + $this->actingAs(User::factory()->administrator()->create()); + + $this->addEvents(2); + $this->addEvents(1, false); + + $this->assertCount(6, $this->exportedRows()); + } + + public function testDeletedEventsAreLeftOut(): void + { + $this->actingAs(User::factory()->administrator()->create()); + + $this->addEvents(2); + Party::first()->delete(); + + $this->assertCount(2, $this->exportedRows()); + } +} diff --git a/tests/Feature/Fixometer/HeadlineStatsTest.php b/tests/Feature/Fixometer/HeadlineStatsTest.php new file mode 100644 index 0000000000..e06df54ba3 --- /dev/null +++ b/tests/Feature/Fixometer/HeadlineStatsTest.php @@ -0,0 +1,44 @@ +loginAsTestUser(Role::ADMINISTRATOR); + + $idgroups = $this->createGroup('Stats Group'); + + // Two past events count; a future one and a deleted one don't. + foreach (['-2 months', '-1 month'] as $when) { + Party::factory()->moderated()->create([ + 'group' => $idgroups, + 'event_start_utc' => Carbon::parse($when)->toIso8601String(), + 'event_end_utc' => Carbon::parse($when)->addHours(2)->toIso8601String(), + ]); + } + + Party::factory()->moderated()->create([ + 'group' => $idgroups, + 'event_start_utc' => Carbon::parse('+1 month')->toIso8601String(), + 'event_end_utc' => Carbon::parse('+1 month')->addHours(2)->toIso8601String(), + ]); + + Party::factory()->moderated()->create([ + 'group' => $idgroups, + 'event_start_utc' => Carbon::parse('-3 months')->toIso8601String(), + 'event_end_utc' => Carbon::parse('-3 months')->addHours(2)->toIso8601String(), + ])->delete(); + + $json = $this->get('/api/homepage_data')->assertSuccessful()->json(); + $this->assertEquals(2, $json['events']); + } +} diff --git a/tests/Feature/Fixometer/RepairRecordsApiTest.php b/tests/Feature/Fixometer/RepairRecordsApiTest.php new file mode 100644 index 0000000000..dfb04d9796 --- /dev/null +++ b/tests/Feature/Fixometer/RepairRecordsApiTest.php @@ -0,0 +1,128 @@ +loginAsTestUser(Role::ADMINISTRATOR); + + $idgroupsA = $this->createGroup('Alpha Group'); + $idgroupsB = $this->createGroup('Beta Group'); + + $this->event1 = Party::factory()->moderated()->create([ + 'group' => $idgroupsA, + 'event_start_utc' => '2024-03-10T13:00:00+00:00', + 'event_end_utc' => '2024-03-10T15:00:00+00:00', + ]); + + $this->event2 = Party::factory()->moderated()->create([ + 'group' => $idgroupsB, + 'event_start_utc' => '2024-05-20T13:00:00+00:00', + 'event_end_utc' => '2024-05-20T15:00:00+00:00', + ]); + + $this->device('Laptop', self::POWERED_CATEGORY, Device::REPAIR_STATUS_FIXED, $this->event1); + $this->device('Kettle', self::POWERED_CATEGORY, Device::REPAIR_STATUS_ENDOFLIFE, $this->event2); + $this->device('Trousers', self::UNPOWERED_CATEGORY, Device::REPAIR_STATUS_REPAIRABLE, $this->event1); + } + + private function device($itemType, $category, $status, $event): Device + { + return Device::factory()->create([ + 'item_type' => $itemType, + 'category' => $category, + 'category_creation' => $category, + 'repair_status' => $status, + 'event' => $event->idevents, + ]); + } + + private function records(array $params): array + { + $response = $this->get('/api/devices/1/20?' . http_build_query($params)); + $response->assertSuccessful(); + + return json_decode($response->getContent(), true); + } + + private function itemTypes(array $params): array + { + return array_column($this->records($params)['items'], 'item_type'); + } + + public function testWithoutPoweredFilterReturnsPoweredAndUnpowered(): void + { + $json = $this->records([]); + $this->assertEquals(3, $json['count']); + } + + public function testPoweredFilter(): void + { + $this->assertEqualsCanonicalizing(['Laptop', 'Kettle'], $this->itemTypes(['powered' => 'true'])); + $this->assertEquals(['Trousers'], $this->itemTypes(['powered' => 'false'])); + } + + public function testSortsByTheRequestedColumn(): void + { + $this->assertEquals(['Kettle', 'Laptop', 'Trousers'], $this->itemTypes(['sortBy' => 'item_type', 'sortDesc' => 'ASC'])); + $this->assertEquals(['Trousers', 'Laptop', 'Kettle'], $this->itemTypes(['sortBy' => 'item_type', 'sortDesc' => 'DESC'])); + } + + public function testSortsByGroupName(): void + { + $this->assertEquals('Kettle', $this->itemTypes(['sortBy' => 'groupname', 'sortDesc' => 'DESC'])[0]); + } + + public function testDefaultSortIsMostRecentEventFirst(): void + { + $this->assertEquals('Kettle', $this->itemTypes([])[0]); + } + + public function testUnknownSortColumnFallsBackToDefault(): void + { + $this->assertEquals('Kettle', $this->itemTypes(['sortBy' => 'nonsense; DROP TABLE devices', 'sortDesc' => 'DESC'])[0]); + } + + public function testFiltersByStatusName(): void + { + // The client sends the status strings used across the API. + $this->assertEquals(['Laptop'], $this->itemTypes(['status' => Device::REPAIR_STATUS_FIXED_STR])); + $this->assertEquals(['Kettle'], $this->itemTypes(['status' => Device::REPAIR_STATUS_ENDOFLIFE_STR])); + $this->assertEquals(['Trousers'], $this->itemTypes(['status' => Device::REPAIR_STATUS_REPAIRABLE_STR])); + } + + public function testToDateIncludesEventsOnThatDay(): void + { + $this->assertEqualsCanonicalizing(['Laptop', 'Trousers'], $this->itemTypes(['to_date' => '2024-03-10'])); + $this->assertEquals(['Kettle'], $this->itemTypes(['from_date' => '2024-05-20'])); + } + + public function testRecordsCarryTheirOwnDatesAndEventDate(): void + { + $device = Device::where('item_type', 'Laptop')->first(); + $device->created_at = Carbon::parse('2024-03-11 10:00:00'); + $device->save(); + + $item = $this->records(['sortBy' => 'item_type', 'sortDesc' => 'ASC'])['items'][1]; + $this->assertEquals('Laptop', $item['item_type']); + $this->assertEquals($device->iddevices, $item['id']); + $this->assertEquals('2024-03-11', substr($item['created_at'], 0, 10)); + $this->assertEquals('2024-03-10', substr($item['event_date'], 0, 10)); + } +} diff --git a/tests/Feature/Groups/GroupReportingLinkTest.php b/tests/Feature/Groups/GroupReportingLinkTest.php new file mode 100644 index 0000000000..2f64728dcb --- /dev/null +++ b/tests/Feature/Groups/GroupReportingLinkTest.php @@ -0,0 +1,73 @@ + 'https://reports.example.org/dashboard/3?group_id={group}#hide_parameters=group_id']); + + $this->group = Group::factory()->create(['approved' => true]); + } + + private function reportingUrl($response): ?string + { + foreach ($this->getVueProperties($response) as $prop) { + if (($prop['VueComponent'] ?? null) === 'grouppage') { + return $prop['reporting-url'] ?? null; + } + } + + $this->fail('No GroupPage on the page'); + } + + public function testHostGetsALinkToTheirGroupsReports(): void + { + $host = User::factory()->host()->create(); + $this->group->addVolunteer($host); + $this->group->makeMemberAHost($host); + $this->actingAs($host); + + $this->assertEquals( + 'https://reports.example.org/dashboard/3?group_id=' . $this->group->idgroups . '#hide_parameters=group_id', + $this->reportingUrl($this->get('/group/view/' . $this->group->idgroups)) + ); + } + + public function testGroupMemberGetsTheLink(): void + { + $volunteer = User::factory()->restarter()->create(); + $this->group->addVolunteer($volunteer); + $this->actingAs($volunteer); + + $this->assertStringContainsString( + 'group_id=' . $this->group->idgroups, + $this->reportingUrl($this->get('/group/view/' . $this->group->idgroups)) + ); + } + + public function testNonMemberDoesNotGetTheLink(): void + { + $this->actingAs(User::factory()->restarter()->create()); + + $this->assertEmpty($this->reportingUrl($this->get('/group/view/' . $this->group->idgroups))); + } + + public function testNoLinkWhenReportingIsNotConfigured(): void + { + config(['restarters.reporting.group_url' => null]); + + $this->actingAs(User::factory()->administrator()->create()); + + $this->assertEmpty($this->reportingUrl($this->get('/group/view/' . $this->group->idgroups))); + } +} diff --git a/tests/Feature/Networks/NetworkReportingLinkTest.php b/tests/Feature/Networks/NetworkReportingLinkTest.php new file mode 100644 index 0000000000..a0b07ab657 --- /dev/null +++ b/tests/Feature/Networks/NetworkReportingLinkTest.php @@ -0,0 +1,98 @@ +getVueProperties($response); + + foreach ($props as $prop) { + if (array_key_exists(':network', $prop)) { + return json_decode($prop[':network'], true); + } + } + + $this->fail('No :network property on the page'); + } + + public function testNetworkPageCarriesTheReportingUrl(): void + { + $network = Network::factory()->create([ + 'reporting_url' => 'https://reports.example.org/dashboard/7', + ]); + + $this->actingAs(User::factory()->administrator()->create()); + + $data = $this->networkData($this->get('/networks/' . $network->id)); + $this->assertEquals('https://reports.example.org/dashboard/7', $data['reporting_url']); + } + + public function testNetworkWithoutReportsHasNoReportingUrl(): void + { + $network = Network::factory()->create(); + + $this->actingAs(User::factory()->administrator()->create()); + + $data = $this->networkData($this->get('/networks/' . $network->id)); + $this->assertNull($data['reporting_url']); + } + + public function testAdministratorCanSetTheReportingUrl(): void + { + $network = Network::factory()->create(); + $this->actingAs(User::factory()->administrator()->create()); + + $this->get('/networks/' . $network->id . '/edit')->assertSee('reporting_url'); + + $this->put('/networks/' . $network->id, [ + 'reporting_url' => 'https://reports.example.org/dashboard/9', + ])->assertRedirect(); + + $this->assertEquals('https://reports.example.org/dashboard/9', $network->fresh()->reporting_url); + + // Blank clears it. + $this->put('/networks/' . $network->id, [ + 'reporting_url' => '', + ])->assertRedirect(); + + $this->assertNull($network->fresh()->reporting_url); + } + + public function testReportingUrlMustBeAWebAddress(): void + { + $network = Network::factory()->create(); + $this->actingAs(User::factory()->administrator()->create()); + $this->withExceptionHandling(); + + $this->put('/networks/' . $network->id, [ + 'reporting_url' => 'javascript:alert(1)', + ])->assertSessionHasErrors('reporting_url'); + + $this->assertNull($network->fresh()->reporting_url); + } + + public function testCoordinatorCannotChangeTheReportingUrl(): void + { + $network = Network::factory()->create([ + 'reporting_url' => 'https://reports.example.org/dashboard/7', + ]); + + $coordinator = User::factory()->networkCoordinator()->create(); + $network->addCoordinator($coordinator); + $this->actingAs($coordinator); + + $this->get('/networks/' . $network->id . '/edit')->assertDontSee('reporting_url'); + + $this->put('/networks/' . $network->id, [ + 'reporting_url' => 'https://elsewhere.example.org/', + ])->assertRedirect(); + + $this->assertEquals('https://reports.example.org/dashboard/7', $network->fresh()->reporting_url); + } +} From 1f7a4c37833e680ae3c797aacf9e8d238d1dcf49 Mon Sep 17 00:00:00 2001 From: edwh Date: Wed, 30 Sep 2026 18:56:47 +0100 Subject: [PATCH 02/13] Address SonarCloud findings on the #919 changes Split the device list filters out of getDevices(), make the record details toggle a button so it works from the keyboard, and tidy the new tests. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/Http/Controllers/ApiController.php | 117 +++++++++--------- app/Http/Resources/Device.php | 2 +- resources/js/components/FixometerPage.vue | 6 +- .../js/components/FixometerRecordsTable.vue | 14 ++- .../Events/ExportDevicesQueryCountTest.php | 2 +- .../Feature/Groups/GroupReportingLinkTest.php | 13 +- .../Networks/NetworkReportingLinkTest.php | 31 +++-- 7 files changed, 99 insertions(+), 86 deletions(-) diff --git a/app/Http/Controllers/ApiController.php b/app/Http/Controllers/ApiController.php index bfd9bf9f53..ac4b709e6f 100644 --- a/app/Http/Controllers/ApiController.php +++ b/app/Http/Controllers/ApiController.php @@ -203,75 +203,44 @@ public static function getUserList() } /** - * List/search devices. + * The where clauses for the device list's search filters. */ - public static function getDevices(Request $request, $page, $size): JsonResponse + private static function deviceFilters(Request $request): array { - $request->validate([ - 'from_date' => 'nullable|date', - 'to_date' => 'nullable|date', - ]); - - $powered = $request->input('powered'); - $category = $request->input('category'); - $brand = $request->input('brand'); - $model = $request->input('model'); - $item_type = $request->input('item_type'); - $status = $request->input('status'); - $comments = $request->input('comments'); - $wiki = filter_var($request->input('wiki', false), FILTER_VALIDATE_BOOLEAN); - $group = $request->input('group'); - $from_date = $request->input('from_date'); - $to_date = $request->input('to_date'); - - // The client asks to sort by one of its table columns; map those onto database columns. Anything else - // gets the default of most recent event first. - $sortColumns = [ - 'item_type' => 'devices.item_type', - 'category' => 'categories.name', - 'device_category.name' => 'categories.name', - 'brand' => 'devices.brand', - 'groupname' => 'groups.name', - 'repair_status' => 'devices.repair_status', - 'event_date' => 'events.event_start_utc', - 'created_at' => 'devices.created_at', - 'iddevices' => 'devices.iddevices', - ]; - - $sortBy = $sortColumns[$request->input('sortBy')] ?? 'events.event_start_utc'; - $sortDesc = strtolower($request->input('sortDesc', 'desc')) === 'asc' ? 'asc' : 'desc'; - $wheres = []; // No powered filter means both powered and unpowered items. + $powered = $request->input('powered'); + if ($powered === 'true' || $powered === 'false') { $wheres[] = ['categories.powered', '=', $powered === 'true' ? 1 : 0]; } - if ($category) { - $wheres[] = ['idcategories', '=', $category]; + if ($request->input('category')) { + $wheres[] = ['idcategories', '=', $request->input('category')]; } - if ($brand) { - $wheres[] = ['devices.brand', 'LIKE', '%'.$brand.'%']; - } - - if ($model) { - $wheres[] = ['devices.model', 'LIKE', '%'.$model.'%']; - } - - if ($item_type) { - $wheres[] = ['devices.item_type', 'LIKE', '%'.$item_type.'%']; - } + // Free text searches. + $likes = [ + 'brand' => 'devices.brand', + 'model' => 'devices.model', + 'item_type' => 'devices.item_type', + 'comments' => 'devices.problem', + 'group' => 'groups.name', + ]; - if ($comments) { - $wheres[] = ['devices.problem', 'LIKE', '%'.$comments.'%']; + foreach ($likes as $param => $column) { + if ($request->input($param)) { + $wheres[] = [$column, 'LIKE', '%'.$request->input($param).'%']; + } } - if ($wiki) { + if (filter_var($request->input('wiki', false), FILTER_VALIDATE_BOOLEAN)) { $wheres[] = ['devices.wiki', '=', 1]; } + $status = $request->input('status'); + if ($status) { // The client uses the status strings from the rest of the API; accept the underlying numbers too. $statuses = [ @@ -283,19 +252,47 @@ public static function getDevices(Request $request, $page, $size): JsonResponse $wheres[] = ['repair_status', '=', $statuses[$status] ?? intval($status)]; } - if ($group) { - $wheres[] = ['groups.name', 'LIKE', '%'.$group.'%']; + if ($request->input('from_date')) { + $wheres[] = ['events.event_start_utc', '>=', Carbon::parse($request->input('from_date'))->startOfDay()]; } - if ($from_date) { - $wheres[] = ['events.event_start_utc', '>=', Carbon::parse($from_date)->startOfDay()]; - } - - if ($to_date) { + if ($request->input('to_date')) { // The date is inclusive - events on that day count. - $wheres[] = ['events.event_start_utc', '<', Carbon::parse($to_date)->startOfDay()->addDay()]; + $wheres[] = ['events.event_start_utc', '<', Carbon::parse($request->input('to_date'))->startOfDay()->addDay()]; } + return $wheres; + } + + /** + * List/search devices. + */ + public static function getDevices(Request $request, $page, $size): JsonResponse + { + $request->validate([ + 'from_date' => 'nullable|date', + 'to_date' => 'nullable|date', + ]); + + // The client asks to sort by one of its table columns; map those onto database columns. Anything else + // gets the default of most recent event first. + $sortColumns = [ + 'item_type' => 'devices.item_type', + 'category' => 'categories.name', + 'device_category.name' => 'categories.name', + 'brand' => 'devices.brand', + 'groupname' => 'groups.name', + 'repair_status' => 'devices.repair_status', + 'event_date' => 'events.event_start_utc', + 'created_at' => 'devices.created_at', + 'iddevices' => 'devices.iddevices', + ]; + + $sortBy = $sortColumns[$request->input('sortBy')] ?? 'events.event_start_utc'; + $sortDesc = strtolower($request->input('sortDesc', 'desc')) === 'asc' ? 'asc' : 'desc'; + + $wheres = self::deviceFilters($request); + // Get the items we want for this page. Select only device columns - the joined tables share column names // such as created_at, which would otherwise overwrite the device's own. $query = Device::with(['deviceEvent.theGroup', 'deviceCategory', 'barriers']) diff --git a/app/Http/Resources/Device.php b/app/Http/Resources/Device.php index b1644bd233..9a313f7e92 100644 --- a/app/Http/Resources/Device.php +++ b/app/Http/Resources/Device.php @@ -203,7 +203,7 @@ public function toArray(Request $request): array 'id' => intval($this->iddevices), 'eventid' => intval($this->event), 'eventtitle' => $event ? $event->title : NULL, - 'event_date' => $event ? Carbon::parse($event->event_start_utc)->toIso8601String() : NULL, + 'event_date' => $event ? Carbon::parse($event->event_start_utc)->toIso8601String() : null, 'groupid' => $event ? intval($event->group) : NULL, 'groupname' => $group ? $group->name : NULL, 'category' => intval($this->category), diff --git a/resources/js/components/FixometerPage.vue b/resources/js/components/FixometerPage.vue index 9979cabcd6..4a0b9ab200 100644 --- a/resources/js/components/FixometerPage.vue +++ b/resources/js/components/FixometerPage.vue @@ -136,20 +136,20 @@ export default { } if (params.has('category')) { - this.category = parseInt(params.get('category')) + this.category = Number.parseInt(params.get('category')) this.startExpandedItems = true } // Older links name the category separately for powered and unpowered items. if (params.has('category_powered')) { this.powered = true - this.category = parseInt(params.get('category_powered')) + this.category = Number.parseInt(params.get('category_powered')) this.startExpandedItems = true } if (params.has('category_unpowered')) { this.powered = false - this.category = parseInt(params.get('category_unpowered')) + this.category = Number.parseInt(params.get('category_unpowered')) this.startExpandedItems = true } diff --git a/resources/js/components/FixometerRecordsTable.vue b/resources/js/components/FixometerRecordsTable.vue index 29cd430af4..26930a5e95 100644 --- a/resources/js/components/FixometerRecordsTable.vue +++ b/resources/js/components/FixometerRecordsTable.vue @@ -45,10 +45,10 @@ @@ -46,6 +50,12 @@ export default { impactData: { type: Object, required: true + }, + reportingUrl: { + // Further reports on the data, if this install has any. + type: String, + required: false, + default: null } }, components: {FixometerLatestData, StatsValue}, @@ -66,7 +76,7 @@ export default { grid-column-gap: 30px; grid-row-gap: 30px; - grid-template-rows: auto auto auto auto auto; + grid-template-rows: auto auto auto auto auto auto; grid-template-columns: 1fr 1fr; .latest-data { @@ -99,13 +109,18 @@ export default { grid-column: 2 / 3; } - .impact-powered { + .impact-items-seen { grid-row: 5 / 6; + grid-column: 1 / 3; + } + + .impact-powered { + grid-row: 6 / 7; grid-column: 1 / 2; } .impact-unpowered { - grid-row: 5 / 6; + grid-row: 6 / 7; grid-column: 2 / 3; } @@ -143,15 +158,20 @@ export default { grid-column: 5 / 6; } - .impact-powered { + .impact-items-seen { grid-row: 2 / 3; grid-column: 3 / 4; } - .impact-unpowered { + .impact-powered { grid-row: 2 / 3; grid-column: 4 / 5; } + + .impact-unpowered { + grid-row: 2 / 3; + grid-column: 5 / 6; + } } } \ No newline at end of file diff --git a/resources/js/components/FixometerPage.vue b/resources/js/components/FixometerPage.vue index 4a0b9ab200..a92b40049d 100644 --- a/resources/js/components/FixometerPage.vue +++ b/resources/js/components/FixometerPage.vue @@ -2,7 +2,7 @@
- +
@@ -79,6 +79,11 @@ export default { type: Object, required: true }, + reportingUrl: { + type: String, + required: false, + default: null + }, clusters: { type: Array, required: false, diff --git a/resources/views/fixometer/index.blade.php b/resources/views/fixometer/index.blade.php index d023ccf75b..204af231b1 100644 --- a/resources/views/fixometer/index.blade.php +++ b/resources/views/fixometer/index.blade.php @@ -18,6 +18,7 @@ csrf="{{ csrf_token() }}" :latest-data="{{ json_encode($most_recent_finished_event, JSON_INVALID_UTF8_IGNORE) }}" :impact-data="{{ json_encode($impact_data, JSON_INVALID_UTF8_IGNORE) }}" + reporting-url="{{ config('restarters.reporting.fixometer_url') }}" :clusters="{{ json_encode($clusters, JSON_INVALID_UTF8_IGNORE) }}" :brands="{{ json_encode($brands, JSON_INVALID_UTF8_IGNORE) }}" :barrier-list="{{ json_encode($barriers, JSON_INVALID_UTF8_IGNORE) }}" diff --git a/tests/Feature/Fixometer/HeadlineStatsTest.php b/tests/Feature/Fixometer/HeadlineStatsTest.php index e06df54ba3..6e54315a59 100644 --- a/tests/Feature/Fixometer/HeadlineStatsTest.php +++ b/tests/Feature/Fixometer/HeadlineStatsTest.php @@ -38,7 +38,31 @@ public function testCountsEventsHeld(): void 'event_end_utc' => Carbon::parse('-3 months')->addHours(2)->toIso8601String(), ])->delete(); + // Cancelled events weren't held. + Party::factory()->moderated()->create([ + 'group' => $idgroups, + 'event_start_utc' => Carbon::parse('-4 months')->toIso8601String(), + 'event_end_utc' => Carbon::parse('-4 months')->addHours(2)->toIso8601String(), + 'cancelled' => true, + ]); + + // Only approved groups count. + $unapproved = $this->createGroup('Unapproved Group', 'https://therestartproject.org', 'London', 'Some text.', true, false); + Party::factory()->moderated()->create([ + 'group' => $unapproved, + 'event_start_utc' => Carbon::parse('-2 months')->toIso8601String(), + 'event_end_utc' => Carbon::parse('-2 months')->addHours(2)->toIso8601String(), + ]); + $json = $this->get('/api/homepage_data')->assertSuccessful()->json(); $this->assertEquals(2, $json['events']); } + + public function testCountsAllItemsSeen(): void + { + Cache::flush(); + $json = $this->get('/api/homepage_data')->assertSuccessful()->json(); + + $this->assertEquals($json['total_powered'] + $json['total_unpowered'], $json['total_items']); + } } From 3862c86495280937068e226f8ccbcefd1b5e6f2a Mon Sep 17 00:00:00 2001 From: edwh Date: Thu, 1 Oct 2026 17:15:54 +0100 Subject: [PATCH 07/13] Fixometer repair records: Neil's review of #924 - Filters in the order Powered or unpowered, Category, Item, Brand, Model, Assessment, Repair status. Brand and model stay for unpowered items, since they can be recorded for them too. - Powered or unpowered is a select like the others, defaulting to Both. - The Item filter asks 'What is it?', as on data capture. - No info icons in the filters or on an expanded record - they explain what to enter, which doesn't apply here. They stay on data capture. - Status before Group; the event date links to that event's items. - White background again; the how-to-use-the-table text is gone. - The To date defaults to today, so records from mis-dated future events don't head the list. Today isn't written into the URL. Co-Authored-By: Claude Opus 5.5 (1M context) --- lang/de/devices.php | 1 - lang/en/devices.php | 4 +- lang/es/devices.php | 1 - lang/fr-BE/devices.php | 4 +- lang/fr/devices.php | 4 +- lang/it/devices.php | 1 - lang/ne/devices.php | 1 - lang/nl-BE/devices.php | 1 - lang/nl/devices.php | 1 - lang/no/devices.php | 1 - .../js/components/DeviceCategorySelect.vue | 2 +- resources/js/components/DeviceModel.vue | 10 ++- resources/js/components/DeviceNotes.vue | 2 +- resources/js/components/DeviceProblem.vue | 2 +- resources/js/components/DeviceType.vue | 2 +- resources/js/components/EventDevice.vue | 23 ++++-- .../js/components/FixometerFilters.test.js | 72 +++++++++++++++++++ resources/js/components/FixometerFilters.vue | 57 +++++++++------ resources/js/components/FixometerPage.vue | 11 ++- .../components/FixometerRecordsTable.test.js | 34 ++++++++- .../js/components/FixometerRecordsTable.vue | 27 ++++--- 21 files changed, 201 insertions(+), 60 deletions(-) create mode 100644 resources/js/components/FixometerFilters.test.js diff --git a/lang/de/devices.php b/lang/de/devices.php index 39040847e0..c3b4aaed8e 100644 --- a/lang/de/devices.php +++ b/lang/de/devices.php @@ -62,7 +62,6 @@ 'status' => 'Status', 'spare_parts' => 'Ersatzteile', 'latest_data' => 'Neueste Daten', - 'table_intro' => 'Klicken Sie auf die „i"-Symbole für Details. Klicken Sie auf einen Spaltenkopf, um nach dieser Spalte zu sortieren – erneut klicken, um die Sortierreihenfolge umzukehren.', 'assessment' => 'Beurteilung', 'item_and_repair_info' => 'Artikel- und Reparaturinfo', 'search_assessment_comments' => 'Beurteilung', diff --git a/lang/en/devices.php b/lang/en/devices.php index 6acea0fcd7..d35e119f4f 100644 --- a/lang/en/devices.php +++ b/lang/en/devices.php @@ -62,7 +62,6 @@ 'status' => 'Status', 'spare_parts' => 'Spare parts', 'latest_data' => 'Latest Data', - 'table_intro' => 'Press the icon at the end of a row to see the details of that repair. Click a column heading to sort by that column - click again to reverse the order.', 'assessment' => 'Assessment', 'item_and_repair_info' => 'Item & Repair Info', 'search_assessment_comments' => 'Assessment', @@ -83,7 +82,8 @@ 'events_held' => 'events held', 'items_seen' => 'items seen', 'powered_or_unpowered' => 'Powered or unpowered', - 'all_items' => 'All items', + 'both' => 'Both', + 'view_event_for_repair' => 'View the event for this repair', 'powered' => 'Powered', 'unpowered' => 'Unpowered', 'view_record' => 'See the details of this repair', diff --git a/lang/es/devices.php b/lang/es/devices.php index f96332604a..86b7b18f99 100644 --- a/lang/es/devices.php +++ b/lang/es/devices.php @@ -62,7 +62,6 @@ 'status' => 'Estado', 'spare_parts' => 'Piezas de repuesto', 'latest_data' => 'Últimos datos', - 'table_intro' => 'Pulsa los iconos «i» para ver los detalles. Haz clic en el encabezado de una columna para ordenar por esa columna; vuelve a hacer clic para invertir el orden.', 'assessment' => 'Evaluación', 'item_and_repair_info' => 'Info del artículo y la reparación', 'search_assessment_comments' => 'Evaluación', diff --git a/lang/fr-BE/devices.php b/lang/fr-BE/devices.php index 99fd7c1889..7fe2b458ff 100644 --- a/lang/fr-BE/devices.php +++ b/lang/fr-BE/devices.php @@ -65,7 +65,6 @@ 'assessment' => 'Evaluation', 'item_and_repair_info' => 'Appareils et informations de réparation', 'search_assessment_comments' => 'Evaluation', - 'table_intro' => 'Cliquez sur l\'icône en fin de ligne pour voir les détails de cette réparation. Cliquez sur l\'en-tête d\'une colonne pour trier par cette colonne - cliquez à nouveau pour inverser l\'ordre.', 'tooltip_type' => 'Ajoutez autant d\'informations que possible sur le type d\'objet (ex: "Jeans Denim", ou "divan")', 'add_data_event' => 'Veuillez sélectionner un événement', 'add_data_group' => 'Veuillez sélectionner un Repair Café', @@ -83,7 +82,8 @@ 'events_held' => 'événements organisés', 'items_seen' => 'objets vus', 'powered_or_unpowered' => 'Électrique ou non électrique', - 'all_items' => 'Tous les objets', + 'both' => 'Les deux', + 'view_event_for_repair' => 'Voir l\'événement de cette réparation', 'powered' => 'Électrique', 'unpowered' => 'Non électrique', 'view_record' => 'Voir les détails de cette réparation', diff --git a/lang/fr/devices.php b/lang/fr/devices.php index 99fd7c1889..7fe2b458ff 100644 --- a/lang/fr/devices.php +++ b/lang/fr/devices.php @@ -65,7 +65,6 @@ 'assessment' => 'Evaluation', 'item_and_repair_info' => 'Appareils et informations de réparation', 'search_assessment_comments' => 'Evaluation', - 'table_intro' => 'Cliquez sur l\'icône en fin de ligne pour voir les détails de cette réparation. Cliquez sur l\'en-tête d\'une colonne pour trier par cette colonne - cliquez à nouveau pour inverser l\'ordre.', 'tooltip_type' => 'Ajoutez autant d\'informations que possible sur le type d\'objet (ex: "Jeans Denim", ou "divan")', 'add_data_event' => 'Veuillez sélectionner un événement', 'add_data_group' => 'Veuillez sélectionner un Repair Café', @@ -83,7 +82,8 @@ 'events_held' => 'événements organisés', 'items_seen' => 'objets vus', 'powered_or_unpowered' => 'Électrique ou non électrique', - 'all_items' => 'Tous les objets', + 'both' => 'Les deux', + 'view_event_for_repair' => 'Voir l\'événement de cette réparation', 'powered' => 'Électrique', 'unpowered' => 'Non électrique', 'view_record' => 'Voir les détails de cette réparation', diff --git a/lang/it/devices.php b/lang/it/devices.php index 62cbf71331..d8c8990ee6 100644 --- a/lang/it/devices.php +++ b/lang/it/devices.php @@ -62,7 +62,6 @@ 'status' => 'Stato', 'spare_parts' => 'Parti di ricambio', 'latest_data' => 'Dati più recenti', - 'table_intro' => 'Premi le icone \'i\' per i dettagli. Clicca l\'intestazione di una colonna per ordinare per quella colonna - clicca di nuovo per invertire l\'ordine.', 'assessment' => 'Valutazione', 'item_and_repair_info' => 'Informazioni oggetto e riparazione', 'search_assessment_comments' => 'Valutazione', diff --git a/lang/ne/devices.php b/lang/ne/devices.php index 50e2aa2e10..b91591253d 100644 --- a/lang/ne/devices.php +++ b/lang/ne/devices.php @@ -62,7 +62,6 @@ 'status' => 'Status', 'spare_parts' => 'Reserveonderdelen', 'latest_data' => 'Meest recente gegevens', - 'table_intro' => 'Klik op de \'i\'-pictogrammen voor details. Klik op een kolomkop om op die kolom te sorteren - klik nogmaals om de sorteervolgorde om te keren.', 'assessment' => 'Beoordeling', 'item_and_repair_info' => 'Item- en reparatie-informatie', 'search_assessment_comments' => 'Beoordeling', diff --git a/lang/nl-BE/devices.php b/lang/nl-BE/devices.php index 0b9038d21d..b4052cd216 100644 --- a/lang/nl-BE/devices.php +++ b/lang/nl-BE/devices.php @@ -62,7 +62,6 @@ 'status' => 'Status', 'spare_parts' => 'Reserveonderdelen', 'latest_data' => 'Meest recente gegevens', - 'table_intro' => 'Druk op de \'i\'-pictogrammen voor details. Klik op een kolomhoofd om op die kolom te sorteren - klik opnieuw om de sorteervolgorde om te keren.', 'assessment' => 'Beoordeling', 'item_and_repair_info' => 'Item- en herstelinfo', 'search_assessment_comments' => 'Beoordeling', diff --git a/lang/nl/devices.php b/lang/nl/devices.php index 50e2aa2e10..b91591253d 100644 --- a/lang/nl/devices.php +++ b/lang/nl/devices.php @@ -62,7 +62,6 @@ 'status' => 'Status', 'spare_parts' => 'Reserveonderdelen', 'latest_data' => 'Meest recente gegevens', - 'table_intro' => 'Klik op de \'i\'-pictogrammen voor details. Klik op een kolomkop om op die kolom te sorteren - klik nogmaals om de sorteervolgorde om te keren.', 'assessment' => 'Beoordeling', 'item_and_repair_info' => 'Item- en reparatie-informatie', 'search_assessment_comments' => 'Beoordeling', diff --git a/lang/no/devices.php b/lang/no/devices.php index be135143ce..d05631d935 100644 --- a/lang/no/devices.php +++ b/lang/no/devices.php @@ -62,7 +62,6 @@ 'status' => 'Status', 'spare_parts' => 'Reservedeler', 'latest_data' => 'Siste data', - 'table_intro' => 'Trykk på «i»-ikonene for detaljer. Klikk på en kolonneoverskrift for å sortere etter den kolonnen – klikk igjen for omvendt sorteringsrekkefølge.', 'assessment' => 'Vurdering', 'item_and_repair_info' => 'Gjenstand og reparasjonsinfo', 'search_assessment_comments' => 'Vurdering', diff --git a/resources/js/components/DeviceCategorySelect.vue b/resources/js/components/DeviceCategorySelect.vue index 9734cdeaaf..99de39ef9a 100644 --- a/resources/js/components/DeviceCategorySelect.vue +++ b/resources/js/components/DeviceCategorySelect.vue @@ -21,7 +21,7 @@ @open="onOpen" > -
+
diff --git a/resources/js/components/DeviceModel.vue b/resources/js/components/DeviceModel.vue index cac5d5e75c..9a1f40c0b8 100644 --- a/resources/js/components/DeviceModel.vue +++ b/resources/js/components/DeviceModel.vue @@ -1,7 +1,7 @@