|
13 | 13 | @inject IStringLocalizer<Index> Localizer |
14 | 14 |
|
15 | 15 | <div class="container"> |
16 | | - <!-- Filter Section --> |
17 | | - <div class="row mb-3"> |
18 | | - <div class="col-md-4"> |
19 | | - <label for="statusFilter">@Localizer["Filter.Status"]</label> |
20 | | - <select id="statusFilter" class="form-select" @bind="_statusFilter" @bind:after="NavigateWithFilters"> |
21 | | - <option value="">@Localizer["Filter.AllStatuses"]</option> |
22 | | - <option value="@GrowerStatus.Active">@Localizer["Status.Active"]</option> |
23 | | - <option value="@GrowerStatus.Inactive">@Localizer["Status.Inactive"]</option> |
24 | | - <option value="@GrowerStatus.Exited">@Localizer["Status.Exited"]</option> |
25 | | - </select> |
26 | | - </div> |
27 | | - <div class="col-md-3"> |
28 | | - <label for="villageFilter">@Localizer["Filter.Village"]</label> |
29 | | - <select id="villageFilter" class="form-select" @bind="_villageFilter" @bind:after="NavigateWithFilters"> |
30 | | - <option value="">@Localizer["Filter.AllVillages"]</option> |
31 | | - @if (_villages != null) |
32 | | - { |
33 | | - @foreach (var village in _villages) |
| 16 | + <!-- Filter / Sort bar --> |
| 17 | + <div class="d-flex flex-wrap align-items-end justify-content-between gap-2 mb-3"> |
| 18 | + <!-- Filters (left) --> |
| 19 | + <div class="d-flex flex-wrap gap-2"> |
| 20 | + <div> |
| 21 | + <label for="statusFilter" class="form-label">@Localizer["Filter.Status"]</label> |
| 22 | + <select id="statusFilter" class="form-select" @bind="_statusFilter" @bind:after="NavigateWithFilters"> |
| 23 | + <option value="">@Localizer["Filter.AllStatuses"]</option> |
| 24 | + <option value="@GrowerStatus.Active">@Localizer["Status.Active"]</option> |
| 25 | + <option value="@GrowerStatus.Inactive">@Localizer["Status.Inactive"]</option> |
| 26 | + <option value="@GrowerStatus.Exited">@Localizer["Status.Exited"]</option> |
| 27 | + </select> |
| 28 | + </div> |
| 29 | + <div> |
| 30 | + <label for="villageFilter" class="form-label">@Localizer["Filter.Village"]</label> |
| 31 | + <select id="villageFilter" class="form-select" @bind="_villageFilter" @bind:after="NavigateWithFilters"> |
| 32 | + <option value="">@Localizer["Filter.AllVillages"]</option> |
| 33 | + @if (_villages != null) |
34 | 34 | { |
35 | | - <option value="@village.VillageId">@village.VillageName</option> |
| 35 | + @foreach (var village in _villages) |
| 36 | + { |
| 37 | + <option value="@village.VillageId">@village.VillageName</option> |
| 38 | + } |
36 | 39 | } |
37 | | - } |
38 | | - </select> |
39 | | - </div> |
40 | | - <div class="col-md-3"> |
41 | | - <label for="cohortFilter">Cohort</label> |
42 | | - <select id="cohortFilter" class="form-select" @bind="_cohortFilter" @bind:after="LoadGrowersAsync"> |
43 | | - <option value="">All Cohorts</option> |
44 | | - @foreach (var cohort in _cohorts) |
45 | | - { |
46 | | - <option value="@cohort.CohortId">@cohort.Name</option> |
47 | | - } |
48 | | - </select> |
| 40 | + </select> |
| 41 | + </div> |
| 42 | + <div> |
| 43 | + <label for="cohortFilter" class="form-label">Cohort</label> |
| 44 | + <select id="cohortFilter" class="form-select" @bind="_cohortFilter" @bind:after="LoadGrowersAsync"> |
| 45 | + <option value="">All Cohorts</option> |
| 46 | + @foreach (var cohort in _cohorts) |
| 47 | + { |
| 48 | + <option value="@cohort.CohortId">@cohort.Name</option> |
| 49 | + } |
| 50 | + </select> |
| 51 | + </div> |
| 52 | + <div class="d-flex align-items-end gap-2"> |
| 53 | + <button class="btn btn-primary" @onclick="ApplyFilters">Filter</button> |
| 54 | + <button class="btn btn-secondary" @onclick="ClearFilters">@Localizer["Action.ClearFilters"]</button> |
| 55 | + </div> |
49 | 56 | </div> |
50 | | - <div class="col-md-2 d-flex align-items-end gap-2"> |
51 | | - <button class="btn btn-primary w-100" @onclick="ApplyFilters">Filter</button> |
52 | | - <button class="btn btn-secondary w-100" @onclick="ClearFilters">@Localizer["Action.ClearFilters"]</button> |
| 57 | + <!-- Sort (right) --> |
| 58 | + <div class="d-flex align-items-end gap-2"> |
| 59 | + <div> |
| 60 | + <label for="sortBy" class="form-label">Sort by</label> |
| 61 | + <select id="sortBy" class="form-select" @bind="_sortBy" @bind:after="NavigateWithFilters"> |
| 62 | + <option value="lastname">Last Name</option> |
| 63 | + <option value="firstname">First Name</option> |
| 64 | + <option value="village">Village</option> |
| 65 | + <option value="status">Status</option> |
| 66 | + <option value="exitdate">Exit Date</option> |
| 67 | + </select> |
| 68 | + </div> |
| 69 | + <button class="btn btn-outline-secondary" @onclick="ToggleSortDirAndNavigate" title="@(_sortAsc ? "Ascending" : "Descending")" aria-label="Toggle sort direction"> |
| 70 | + <span class="oi @(_sortAsc ? "oi-sort-ascending" : "oi-sort-descending")" aria-hidden="true"></span> |
| 71 | + </button> |
53 | 72 | </div> |
54 | 73 | </div> |
55 | 74 |
|
|
114 | 133 | </tr> |
115 | 134 | </thead> |
116 | 135 | <tbody> |
117 | | - @foreach (var grower in _growers) |
| 136 | + @foreach (var grower in SortedGrowers()) |
118 | 137 | { |
119 | 138 | var rowClass = grower.Status == GrowerStatus.Inactive ? "table-warning" : |
120 | 139 | grower.Status == GrowerStatus.Exited ? "table-secondary" : ""; |
|
131 | 150 | <td> |
132 | 151 | <div class="d-flex gap-1"> |
133 | 152 | <ActionLink Action="Edit" |
134 | | - Parameters="@($"id={grower.GrowerId}&returnurl={Uri.EscapeDataString(GetReturnUrl())}")" |
| 153 | + Parameters="@($"id={grower.GrowerId}&{BuildFilterQueryString()}")" |
135 | 154 | Text="Edit" |
136 | 155 | ResourceKey="EditGrower" |
137 | 156 | Class="btn btn-sm btn-primary" /> |
|
162 | 181 | private string _villageFilter = ""; |
163 | 182 | private string _cohortFilter = ""; |
164 | 183 | private bool _isLoading = false; |
| 184 | + private string _sortBy = "lastname"; |
| 185 | + private bool _sortAsc = true; |
| 186 | + |
| 187 | + private void ToggleSortDir() => _sortAsc = !_sortAsc; |
| 188 | + |
| 189 | + private void ToggleSortDirAndNavigate() |
| 190 | + { |
| 191 | + _sortAsc = !_sortAsc; |
| 192 | + NavigateWithFilters(); |
| 193 | + } |
165 | 194 |
|
166 | 195 | protected override async Task OnParametersSetAsync() |
167 | 196 | { |
|
177 | 206 | { |
178 | 207 | _statusFilter = PageState.QueryString.GetValueOrDefault("status", ""); |
179 | 208 | _villageFilter = PageState.QueryString.GetValueOrDefault("villageId", ""); |
| 209 | + _sortBy = PageState.QueryString.GetValueOrDefault("sortBy", "lastname"); |
| 210 | + _sortAsc = !PageState.QueryString.ContainsKey("sortDesc"); |
180 | 211 | await LoadVillagesAsync(); |
181 | 212 | await LoadCohortsForMentorAsync(); |
182 | 213 | await LoadSummaryAsync(); |
|
279 | 310 | StateHasChanged(); |
280 | 311 | } |
281 | 312 |
|
| 313 | + private static (string Last, string First) SplitName(string fullName) |
| 314 | + { |
| 315 | + var trimmed = fullName?.Trim() ?? string.Empty; |
| 316 | + var idx = trimmed.LastIndexOf(' '); |
| 317 | + return idx < 0 ? (trimmed, string.Empty) : (trimmed[(idx + 1)..], trimmed[..idx]); |
| 318 | + } |
| 319 | + |
| 320 | + private IEnumerable<Models.Grower> SortedGrowers() |
| 321 | + { |
| 322 | + IOrderedEnumerable<Models.Grower> sorted = _sortBy switch |
| 323 | + { |
| 324 | + "firstname" => _growers.OrderBy(g => SplitName(g.GrowerName).First), |
| 325 | + "village" => _growers.OrderBy(g => GetVillageName(g.VillageId)), |
| 326 | + "status" => _growers.OrderBy(g => g.Status), |
| 327 | + "exitdate" => _growers.OrderBy(g => g.ExitDate), |
| 328 | + _ => _growers.OrderBy(g => SplitName(g.GrowerName).Last) |
| 329 | + .ThenBy(g => SplitName(g.GrowerName).First), |
| 330 | + }; |
| 331 | + return _sortAsc ? sorted : sorted.Reverse(); |
| 332 | + } |
| 333 | + |
282 | 334 | private void ClearFilters() |
283 | 335 | { |
284 | 336 | NavigationManager.NavigateTo(NavigateUrl()); |
|
300 | 352 | var parts = new List<string>(); |
301 | 353 | if (!string.IsNullOrEmpty(_statusFilter)) parts.Add($"status={Uri.EscapeDataString(_statusFilter)}"); |
302 | 354 | if (!string.IsNullOrEmpty(_villageFilter)) parts.Add($"villageId={Uri.EscapeDataString(_villageFilter)}"); |
| 355 | + if (_sortBy != "lastname") parts.Add($"sortBy={Uri.EscapeDataString(_sortBy)}"); |
| 356 | + if (!_sortAsc) parts.Add("sortDesc=1"); |
303 | 357 | return string.Join("&", parts); |
304 | 358 | } |
305 | 359 |
|
306 | 360 | private void NavigateWithFilters() |
307 | 361 | { |
308 | 362 | var queryString = BuildFilterQueryString(); |
309 | | - NavigationManager.NavigateTo(NavigateUrl(PageState.Page.Path, queryString)); |
310 | | - } |
311 | | - |
312 | | - private string GetReturnUrl() |
313 | | - { |
314 | | - var queryString = BuildFilterQueryString(); |
315 | | - return NavigateUrl(PageState.Page.Path, queryString); |
| 363 | + NavigationManager.NavigateTo(EditUrl("", queryString)); |
316 | 364 | } |
317 | 365 |
|
318 | 366 | private string GetVillageName(int villageId) |
|
0 commit comments