Implement people management features by adding API endpoints for retrieving school rosters and individual person cards. Enhance the UI to support a people browser with filtering and pagination capabilities. Update localization strings for improved user experience and ensure robust handling of person data. Revise documentation to reflect new API functionalities and update tests to validate the new features.
ci / server (push) Failing after 11s
ci / client (push) Successful in 17s

This commit is contained in:
Leonid Pershin
2026-08-18 19:44:42 +03:00
parent 52c5082418
commit 533bd80f5e
23 changed files with 2134 additions and 47 deletions
+5 -1
View File
@@ -49,7 +49,11 @@ public sealed record PersonName(
string Patronymic,
CaseTable GivenCases,
CaseTable SurnameCases,
CaseTable PatronymicCases);
CaseTable PatronymicCases)
{
/// <summary>Nominative ФИО as the list and cards show it: surname, given, patronymic.</summary>
public string Full => string.Join(' ', new[] { Surname, Given, Patronymic }.Where(part => part.Length > 0));
}
public sealed record Family(
string Id,
+202
View File
@@ -0,0 +1,202 @@
namespace HSchool.People;
public enum PersonSort
{
Surname,
Age,
Year,
Position,
}
/// <summary>Filter, sort and page a published roster. Does not touch a World.</summary>
public readonly record struct RosterQuery(
string? Role,
int? Year,
string? Letter,
string? Position,
bool? Female,
int? AgeMin,
int? AgeMax,
PersonSort Sort,
bool Descending,
int Page,
int PageSize);
public sealed record RosterPage(int Total, IReadOnlyList<Person> People);
public static class RosterBrowser
{
public const int DefaultPageSize = 50;
public const int MaxPageSize = 100;
public static RosterPage Apply(Roster roster, DateTime asOf, RosterQuery query)
{
var classes = roster.Classes.ToDictionary(schoolClass => schoolClass.Id, StringComparer.Ordinal);
var filtered = new List<Person>();
foreach (var person in roster.People)
{
if (!Matches(person, classes, asOf, query))
{
continue;
}
filtered.Add(person);
}
var ordered = query.Sort switch
{
PersonSort.Age => query.Descending
? filtered.OrderByDescending(person => person.AgeOn(asOf)).ThenByDescending(SurnameKey, StringComparer.Ordinal)
: filtered.OrderBy(person => person.AgeOn(asOf)).ThenBy(SurnameKey, StringComparer.Ordinal),
PersonSort.Year => SortByYear(filtered, classes, query.Descending),
PersonSort.Position => query.Descending
? filtered
.OrderBy(person => person.Position is null)
.ThenByDescending(person => person.Position ?? string.Empty, StringComparer.Ordinal)
.ThenByDescending(SurnameKey, StringComparer.Ordinal)
: filtered
.OrderBy(person => person.Position is null)
.ThenBy(person => person.Position ?? string.Empty, StringComparer.Ordinal)
.ThenBy(SurnameKey, StringComparer.Ordinal),
_ => query.Descending
? filtered.OrderByDescending(SurnameKey, StringComparer.Ordinal).ThenByDescending(GivenKey, StringComparer.Ordinal)
: filtered.OrderBy(SurnameKey, StringComparer.Ordinal).ThenBy(GivenKey, StringComparer.Ordinal),
};
var sorted = ordered.ThenBy(person => person.Id, StringComparer.Ordinal).ToArray();
var page = Math.Max(query.Page, 1);
var pageSize = Math.Clamp(query.PageSize, 1, MaxPageSize);
var skip = (page - 1) * pageSize;
var slice = skip >= sorted.Length ? [] : sorted.Skip(skip).Take(pageSize).ToArray();
return new RosterPage(sorted.Length, slice);
}
public static bool TryParseSort(string? value, out PersonSort sort)
{
if (string.IsNullOrWhiteSpace(value) || value.Equals("surname", StringComparison.OrdinalIgnoreCase))
{
sort = PersonSort.Surname;
return true;
}
if (value.Equals("age", StringComparison.OrdinalIgnoreCase))
{
sort = PersonSort.Age;
return true;
}
if (value.Equals("year", StringComparison.OrdinalIgnoreCase))
{
sort = PersonSort.Year;
return true;
}
if (value.Equals("position", StringComparison.OrdinalIgnoreCase))
{
sort = PersonSort.Position;
return true;
}
sort = PersonSort.Surname;
return false;
}
private static IOrderedEnumerable<Person> SortByYear(
List<Person> filtered,
IReadOnlyDictionary<string, SchoolClass> classes,
bool descending)
{
var studentsFirst = filtered.OrderBy(person => YearOf(person, classes) is null);
return descending
? studentsFirst
.ThenByDescending(person => YearOf(person, classes) ?? 0)
.ThenByDescending(person => LetterOf(person, classes) ?? string.Empty, StringComparer.Ordinal)
.ThenByDescending(SurnameKey, StringComparer.Ordinal)
: studentsFirst
.ThenBy(person => YearOf(person, classes) ?? 0)
.ThenBy(person => LetterOf(person, classes) ?? string.Empty, StringComparer.Ordinal)
.ThenBy(SurnameKey, StringComparer.Ordinal);
}
private static bool Matches(
Person person,
IReadOnlyDictionary<string, SchoolClass> classes,
DateTime asOf,
RosterQuery query)
{
if (query.Role is not null && !HasRole(person, query.Role))
{
return false;
}
if (query.Year is int year && YearOf(person, classes) != year)
{
return false;
}
if (query.Letter is not null
&& !string.Equals(LetterOf(person, classes), query.Letter, StringComparison.Ordinal))
{
return false;
}
if (query.Position is not null
&& !string.Equals(person.Position, query.Position, StringComparison.Ordinal))
{
return false;
}
if (query.Female is bool female && person.Female != female)
{
return false;
}
var age = person.AgeOn(asOf);
if (query.AgeMin is int ageMin && age < ageMin)
{
return false;
}
if (query.AgeMax is int ageMax && age > ageMax)
{
return false;
}
return true;
}
private static bool HasRole(Person person, string role)
{
if (role.Equals(PersonRoles.Student, StringComparison.OrdinalIgnoreCase))
{
return person.IsStudent;
}
if (role.Equals(PersonRoles.Staff, StringComparison.OrdinalIgnoreCase))
{
return person.IsStaff;
}
if (role.Equals(PersonRoles.Parent, StringComparison.OrdinalIgnoreCase))
{
return person.IsParent;
}
return false;
}
private static int? YearOf(Person person, IReadOnlyDictionary<string, SchoolClass> classes) =>
person.ClassId is { } classId && classes.TryGetValue(classId, out var schoolClass)
? schoolClass.Year
: null;
private static string? LetterOf(Person person, IReadOnlyDictionary<string, SchoolClass> classes) =>
person.ClassId is { } classId && classes.TryGetValue(classId, out var schoolClass)
? schoolClass.Letter
: null;
private static string SurnameKey(Person person) => person.Name.Surname;
private static string GivenKey(Person person) => person.Name.Given;
}