Merge branch 'phase/75-gradebook-ui'
# Conflicts: # docs/phases/13-grades/README.md
This commit is contained in:
@@ -211,6 +211,7 @@ const ru = {
|
||||
peopleTabCarry: 'Ноша',
|
||||
peopleTabNow: 'Сейчас',
|
||||
peopleTabConnections: 'Связи',
|
||||
peopleTabGradebook: 'Журнал',
|
||||
peopleTabPortrait: 'Портрет',
|
||||
peopleConnectionsFriends: 'Друзья',
|
||||
peopleConnectionsEnemies: 'Враги',
|
||||
@@ -235,6 +236,18 @@ const ru = {
|
||||
peopleOffenses: 'Проступки',
|
||||
peopleOffensesEmpty: 'Недавних проступков нет.',
|
||||
peopleOffenseWith: '{kind} · {other}',
|
||||
peopleGradebookMarks: 'Оценки',
|
||||
peopleGradebookMarksEmpty: 'Недавних оценок нет.',
|
||||
peopleGradebookAttendance: 'Явка',
|
||||
peopleGradebookAttendanceEmpty: 'Отметок явки нет.',
|
||||
peopleGradebookWhen: 'Когда',
|
||||
peopleGradebookSubject: 'Предмет',
|
||||
peopleGradebookMark: 'Оценка',
|
||||
peopleGradebookStatus: 'Статус',
|
||||
classGradebookAverage: 'средний {mark}',
|
||||
classGradebookNoMarks: 'оценок пока нет',
|
||||
classGradebookAbsences: 'прогулов {n}',
|
||||
classGradebookFrequent: 'часто отсутствует: {n}',
|
||||
|
||||
peoplePortraitAvatar: 'Аватар',
|
||||
peoplePortraitFull: 'В полный рост',
|
||||
@@ -623,6 +636,7 @@ const en: Messages = {
|
||||
peopleTabCarry: 'Carried',
|
||||
peopleTabNow: 'Now',
|
||||
peopleTabConnections: 'Connections',
|
||||
peopleTabGradebook: 'Gradebook',
|
||||
peopleTabPortrait: 'Portrait',
|
||||
peopleConnectionsFriends: 'Friends',
|
||||
peopleConnectionsEnemies: 'Enemies',
|
||||
@@ -647,6 +661,18 @@ const en: Messages = {
|
||||
peopleOffenses: 'Misconduct',
|
||||
peopleOffensesEmpty: 'No recent misconduct.',
|
||||
peopleOffenseWith: '{kind} · {other}',
|
||||
peopleGradebookMarks: 'Marks',
|
||||
peopleGradebookMarksEmpty: 'No recent marks.',
|
||||
peopleGradebookAttendance: 'Attendance',
|
||||
peopleGradebookAttendanceEmpty: 'No attendance rows.',
|
||||
peopleGradebookWhen: 'When',
|
||||
peopleGradebookSubject: 'Subject',
|
||||
peopleGradebookMark: 'Mark',
|
||||
peopleGradebookStatus: 'Status',
|
||||
classGradebookAverage: 'avg {mark}',
|
||||
classGradebookNoMarks: 'no marks yet',
|
||||
classGradebookAbsences: 'absences {n}',
|
||||
classGradebookFrequent: 'often absent: {n}',
|
||||
|
||||
peoplePortraitAvatar: 'Avatar',
|
||||
peoplePortraitFull: 'Full body',
|
||||
|
||||
@@ -359,6 +359,10 @@ export interface PersonCard {
|
||||
readonly orientation?: DefLabel | null;
|
||||
/** Recent misconduct from the person save — same order as the world list. */
|
||||
readonly offenses?: readonly OffenseEntry[] | null;
|
||||
/** Recent lesson marks (2–5) — same order as the world list. */
|
||||
readonly lessonMarks?: readonly LessonMarkEntry[] | null;
|
||||
/** Recent attendance rows — same order as the world list. */
|
||||
readonly attendance?: readonly AttendanceEntry[] | null;
|
||||
}
|
||||
|
||||
export interface OffenseEntry {
|
||||
@@ -369,6 +373,24 @@ export interface OffenseEntry {
|
||||
readonly otherFullName: string | null;
|
||||
}
|
||||
|
||||
export interface LessonMarkEntry {
|
||||
readonly subject: string;
|
||||
readonly subjectLabel: string;
|
||||
readonly value: number;
|
||||
readonly time: string;
|
||||
readonly period: number;
|
||||
}
|
||||
|
||||
export interface AttendanceEntry {
|
||||
readonly subject: string;
|
||||
readonly subjectLabel: string;
|
||||
readonly status: string;
|
||||
readonly statusLabel: string;
|
||||
readonly time: string;
|
||||
readonly period: number;
|
||||
readonly absenceReason?: string | null;
|
||||
}
|
||||
|
||||
export interface WornItem {
|
||||
readonly defName: string;
|
||||
readonly label: string;
|
||||
@@ -731,6 +753,12 @@ export interface ClassTeacherSlot {
|
||||
readonly letter: string;
|
||||
readonly classTeacherId: string | null;
|
||||
readonly classTeacherName: string | null;
|
||||
/** Mean of recent marks in the class; null when none. */
|
||||
readonly averageMark?: number | null;
|
||||
/** Absent rows in recent attendance memory. */
|
||||
readonly absentLessons?: number;
|
||||
/** Pupils with at least three recent absences. */
|
||||
readonly frequentAbsentees?: number;
|
||||
}
|
||||
|
||||
export interface Staffing {
|
||||
|
||||
@@ -836,6 +836,39 @@ body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.people__gradebook-block {
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
|
||||
.people__gradebook {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.people__gradebook th,
|
||||
.people__gradebook td {
|
||||
padding: 4px 6px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.people__gradebook-mark {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.staffing__class-teacher-row {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.staffing__class-gradebook {
|
||||
margin: 0 0 0 2px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.people__tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -558,4 +558,31 @@ describe('ManagementPanel money and subjects', () => {
|
||||
select.dispatchEvent(new Event('change'));
|
||||
await vi.waitFor(() => expect(clearClassTeacher).toHaveBeenCalledWith(2, 'c1', 'en'));
|
||||
});
|
||||
|
||||
it('shows a short class gradebook summary next to each class teacher slot', async () => {
|
||||
vi.mocked(fetchStaffing).mockResolvedValue({
|
||||
...hiredTeacher(),
|
||||
classes: [
|
||||
{
|
||||
id: 'c1',
|
||||
year: 5,
|
||||
letter: 'A',
|
||||
classTeacherId: null,
|
||||
classTeacherName: null,
|
||||
averageMark: 3.5,
|
||||
absentLessons: 4,
|
||||
frequentAbsentees: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const panel = new ManagementPanel();
|
||||
document.body.append(panel.listElement, panel.cardElement);
|
||||
panel.show(2);
|
||||
await vi.waitFor(() => {
|
||||
expect(panel.listElement.textContent).toContain(t('classGradebookAverage', { mark: '3.5' }));
|
||||
});
|
||||
expect(panel.listElement.textContent).toContain(t('classGradebookAbsences', { n: 4 }));
|
||||
expect(panel.listElement.textContent).toContain(t('classGradebookFrequent', { n: 1 }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
fetchStaffing,
|
||||
fetchTimetable,
|
||||
unassignSubject,
|
||||
type ClassTeacherSlot,
|
||||
type PersonCard,
|
||||
type Staffing,
|
||||
type StaffMember,
|
||||
@@ -446,10 +447,18 @@ export class ManagementPanel {
|
||||
|
||||
this.classTeachersList.append(
|
||||
el(
|
||||
'label',
|
||||
{ class: 'people__field staffing__class-teacher' },
|
||||
el('span', { class: 'people__label', text: `${schoolClass.year}${schoolClass.letter}` }),
|
||||
select,
|
||||
'div',
|
||||
{ class: 'staffing__class-teacher-row' },
|
||||
el(
|
||||
'label',
|
||||
{ class: 'people__field staffing__class-teacher' },
|
||||
el('span', { class: 'people__label', text: `${schoolClass.year}${schoolClass.letter}` }),
|
||||
select,
|
||||
),
|
||||
el('p', {
|
||||
class: 'staffing__class-gradebook',
|
||||
text: classGradebookSummary(schoolClass),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -775,3 +784,17 @@ export class ManagementPanel {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function classGradebookSummary(schoolClass: ClassTeacherSlot): string {
|
||||
const markBit =
|
||||
schoolClass.averageMark !== null && schoolClass.averageMark !== undefined
|
||||
? t('classGradebookAverage', { mark: String(schoolClass.averageMark) })
|
||||
: t('classGradebookNoMarks');
|
||||
const absences = schoolClass.absentLessons ?? 0;
|
||||
const frequent = schoolClass.frequentAbsentees ?? 0;
|
||||
return [
|
||||
markBit,
|
||||
t('classGradebookAbsences', { n: absences }),
|
||||
t('classGradebookFrequent', { n: frequent }),
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
@@ -464,4 +464,70 @@ describe('renderPersonCard', () => {
|
||||
expect(items[0]).toContain('ссора');
|
||||
expect(items[1]).toContain('драка');
|
||||
});
|
||||
|
||||
it('shows empty gradebook placeholders when marks and attendance are missing', () => {
|
||||
setLocale('ru');
|
||||
const root = document.createElement('div');
|
||||
renderPersonCard(root, card({ lessonMarks: [], attendance: [] }), () => {}, options({ tab: 'gradebook' }));
|
||||
|
||||
expect(tabButton(root, 'gradebook').textContent).toBe(t('peopleTabGradebook'));
|
||||
expect(root.textContent).toContain(t('peopleGradebookMarksEmpty'));
|
||||
expect(root.textContent).toContain(t('peopleGradebookAttendanceEmpty'));
|
||||
expect(root.querySelectorAll('.people__gradebook tr').length).toBe(0);
|
||||
});
|
||||
|
||||
it('lists recent marks and attendance on the gradebook tab', () => {
|
||||
setLocale('ru');
|
||||
const root = document.createElement('div');
|
||||
renderPersonCard(
|
||||
root,
|
||||
card({
|
||||
lessonMarks: [
|
||||
{
|
||||
subject: 'Mathematics',
|
||||
subjectLabel: 'Математика',
|
||||
value: 4,
|
||||
time: '2012-04-03T10:00:00Z',
|
||||
period: 1,
|
||||
},
|
||||
{
|
||||
subject: 'Literature',
|
||||
subjectLabel: 'Литература',
|
||||
value: 5,
|
||||
time: '2012-04-03T11:00:00Z',
|
||||
period: 2,
|
||||
},
|
||||
],
|
||||
attendance: [
|
||||
{
|
||||
subject: 'Mathematics',
|
||||
subjectLabel: 'Математика',
|
||||
status: 'present',
|
||||
statusLabel: 'был',
|
||||
time: '2012-04-03T10:00:00Z',
|
||||
period: 1,
|
||||
},
|
||||
{
|
||||
subject: 'Literature',
|
||||
subjectLabel: 'Литература',
|
||||
status: 'late',
|
||||
statusLabel: 'опоздал',
|
||||
time: '2012-04-03T11:00:00Z',
|
||||
period: 2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
() => {},
|
||||
options({ tab: 'gradebook' }),
|
||||
);
|
||||
|
||||
const panel = root.querySelector('[data-card-tab="gradebook"]');
|
||||
expect(panel).not.toBeNull();
|
||||
expect((panel as HTMLElement).hidden).toBe(false);
|
||||
expect(panel?.textContent).toContain('5');
|
||||
expect(panel?.textContent).toContain('Математика');
|
||||
expect(panel?.textContent).toContain('опоздал');
|
||||
expect(panel?.textContent).toContain(t('peopleGradebookMarks'));
|
||||
expect(panel?.textContent).toContain(t('peopleGradebookAttendance'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { el } from './dom.ts';
|
||||
import { fillApparel } from './personCardApparel.ts';
|
||||
import { fillCarry } from './personCardCarry.ts';
|
||||
import { fillConnections } from './personCardConnections.ts';
|
||||
import { fillGradebook } from './personCardGradebook.ts';
|
||||
import { fillNow } from './personCardNow.ts';
|
||||
import { fillOverview } from './personCardOverview.ts';
|
||||
import { fillPortrait } from './personCardPortrait.ts';
|
||||
@@ -17,7 +18,14 @@ const ROLE_KEYS: Record<PersonRole, MessageKey> = {
|
||||
parent: 'peopleRoleParent',
|
||||
};
|
||||
|
||||
export type PersonCardTab = 'overview' | 'apparel' | 'carry' | 'now' | 'connections' | 'portrait';
|
||||
export type PersonCardTab =
|
||||
| 'overview'
|
||||
| 'apparel'
|
||||
| 'carry'
|
||||
| 'now'
|
||||
| 'connections'
|
||||
| 'gradebook'
|
||||
| 'portrait';
|
||||
|
||||
export interface RenderPersonCardOptions {
|
||||
readonly place?: string;
|
||||
@@ -115,6 +123,7 @@ export function renderPersonCard(
|
||||
carry: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'carry' } }),
|
||||
now: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'now' } }),
|
||||
connections: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'connections' } }),
|
||||
gradebook: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'gradebook' } }),
|
||||
portrait: el('div', { class: 'people__tab-panel people__portrait-panel', dataset: { cardTab: 'portrait' } }),
|
||||
};
|
||||
|
||||
@@ -124,6 +133,7 @@ export function renderPersonCard(
|
||||
carry: 'peopleTabCarry',
|
||||
now: 'peopleTabNow',
|
||||
connections: 'peopleTabConnections',
|
||||
gradebook: 'peopleTabGradebook',
|
||||
portrait: 'peopleTabPortrait',
|
||||
};
|
||||
|
||||
@@ -149,6 +159,7 @@ export function renderPersonCard(
|
||||
fillCarry(panels.carry, card);
|
||||
fillNow(panels.now, card, options.away === true, tab === 'now' ? options.log : null, options);
|
||||
fillConnections(panels.connections, card, onRelative, options);
|
||||
fillGradebook(panels.gradebook, card);
|
||||
fillPortrait(panels.portrait, card, options);
|
||||
|
||||
const showTab = (next: PersonCardTab): void => {
|
||||
@@ -169,7 +180,16 @@ export function renderPersonCard(
|
||||
|
||||
showTab(tab);
|
||||
|
||||
parent.append(tabs, panels.overview, panels.apparel, panels.carry, panels.now, panels.connections, panels.portrait);
|
||||
parent.append(
|
||||
tabs,
|
||||
panels.overview,
|
||||
panels.apparel,
|
||||
panels.carry,
|
||||
panels.now,
|
||||
panels.connections,
|
||||
panels.gradebook,
|
||||
panels.portrait,
|
||||
);
|
||||
}
|
||||
|
||||
function cardMeta(card: PersonCard): string {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { AttendanceEntry, LessonMarkEntry, PersonCard } from '../net/api.ts';
|
||||
import { formatGameDate, formatGameTimeOfDay } from '../format/gameTime.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { el } from './dom.ts';
|
||||
|
||||
export function fillGradebook(parent: HTMLElement, card: PersonCard): void {
|
||||
parent.append(marksBlock(card.lessonMarks ?? []));
|
||||
parent.append(attendanceBlock(card.attendance ?? []));
|
||||
}
|
||||
|
||||
function marksBlock(marks: readonly LessonMarkEntry[]): HTMLElement {
|
||||
const wrap = el('div', { class: 'people__gradebook-block' });
|
||||
wrap.append(el('h4', { class: 'people__section-title', text: t('peopleGradebookMarks') }));
|
||||
if (marks.length === 0) {
|
||||
wrap.append(el('p', { class: 'panel__empty', text: t('peopleGradebookMarksEmpty') }));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const table = el('table', { class: 'people__gradebook' });
|
||||
const body = el('tbody');
|
||||
table.append(
|
||||
el(
|
||||
'thead',
|
||||
{},
|
||||
el(
|
||||
'tr',
|
||||
{},
|
||||
el('th', { text: t('peopleGradebookWhen') }),
|
||||
el('th', { text: t('peopleGradebookSubject') }),
|
||||
el('th', { text: t('peopleGradebookMark') }),
|
||||
),
|
||||
),
|
||||
body,
|
||||
);
|
||||
|
||||
for (const row of [...marks].reverse()) {
|
||||
body.append(
|
||||
el(
|
||||
'tr',
|
||||
{},
|
||||
el('td', { text: formatWhen(row.time) }),
|
||||
el('td', { text: row.subjectLabel }),
|
||||
el('td', { class: 'people__gradebook-mark', text: String(row.value) }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
wrap.append(table);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function attendanceBlock(rows: readonly AttendanceEntry[]): HTMLElement {
|
||||
const wrap = el('div', { class: 'people__gradebook-block' });
|
||||
wrap.append(el('h4', { class: 'people__section-title', text: t('peopleGradebookAttendance') }));
|
||||
if (rows.length === 0) {
|
||||
wrap.append(el('p', { class: 'panel__empty', text: t('peopleGradebookAttendanceEmpty') }));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const table = el('table', { class: 'people__gradebook' });
|
||||
const body = el('tbody');
|
||||
table.append(
|
||||
el(
|
||||
'thead',
|
||||
{},
|
||||
el(
|
||||
'tr',
|
||||
{},
|
||||
el('th', { text: t('peopleGradebookWhen') }),
|
||||
el('th', { text: t('peopleGradebookSubject') }),
|
||||
el('th', { text: t('peopleGradebookStatus') }),
|
||||
),
|
||||
),
|
||||
body,
|
||||
);
|
||||
|
||||
for (const row of [...rows].reverse()) {
|
||||
body.append(
|
||||
el(
|
||||
'tr',
|
||||
{},
|
||||
el('td', { text: formatWhen(row.time) }),
|
||||
el('td', { text: row.subjectLabel }),
|
||||
el('td', { text: row.statusLabel }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
wrap.append(table);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function formatWhen(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
return `${formatGameDate(date)} ${formatGameTimeOfDay(date)}`;
|
||||
}
|
||||
@@ -93,6 +93,7 @@ const CARD_TABS: readonly PersonCardTab[] = [
|
||||
'carry',
|
||||
'now',
|
||||
'connections',
|
||||
'gradebook',
|
||||
'portrait',
|
||||
];
|
||||
const LOG_DIRS: readonly PersonLogDir[] = ['asc', 'desc'];
|
||||
|
||||
@@ -11,6 +11,14 @@ public static class AttendanceStatuses
|
||||
public const string Late = "late";
|
||||
|
||||
public const string Absent = "absent";
|
||||
|
||||
public static string LocaleKey(string status) => status switch
|
||||
{
|
||||
Present => "AttendancePresent",
|
||||
Late => "AttendanceLate",
|
||||
Absent => "AttendanceAbsent",
|
||||
_ => "Attendance" + char.ToUpperInvariant(status[0]) + status[1..],
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -88,7 +88,9 @@ internal sealed record PersonCardResponse(
|
||||
string? TalkTopicId = null,
|
||||
string? ClassTeacherId = null,
|
||||
string? ClassTeacherName = null,
|
||||
IReadOnlyList<OffenseEntryResponse>? Offenses = null);
|
||||
IReadOnlyList<OffenseEntryResponse>? Offenses = null,
|
||||
IReadOnlyList<LessonMarkEntryResponse>? LessonMarks = null,
|
||||
IReadOnlyList<AttendanceEntryResponse>? Attendance = null);
|
||||
|
||||
internal sealed record OffenseEntryResponse(
|
||||
string Kind,
|
||||
@@ -97,6 +99,24 @@ internal sealed record OffenseEntryResponse(
|
||||
string? OtherPersonId,
|
||||
string? OtherFullName);
|
||||
|
||||
/// <summary>One recent lesson mark on the person card. Same order as the world list.</summary>
|
||||
internal sealed record LessonMarkEntryResponse(
|
||||
string Subject,
|
||||
string SubjectLabel,
|
||||
int Value,
|
||||
DateTime Time,
|
||||
int Period);
|
||||
|
||||
/// <summary>One recent attendance row on the person card. Same order as the world list.</summary>
|
||||
internal sealed record AttendanceEntryResponse(
|
||||
string Subject,
|
||||
string SubjectLabel,
|
||||
string Status,
|
||||
string StatusLabel,
|
||||
DateTime Time,
|
||||
int Period,
|
||||
string? AbsenceReason = null);
|
||||
|
||||
|
||||
internal sealed record WornItemResponse(
|
||||
string DefName,
|
||||
@@ -274,7 +294,13 @@ internal sealed record ClassTeacherSlotResponse(
|
||||
int Year,
|
||||
string Letter,
|
||||
string? ClassTeacherId,
|
||||
string? ClassTeacherName);
|
||||
string? ClassTeacherName,
|
||||
/// <summary>Mean of recent lesson marks across pupils in the class; null when none.</summary>
|
||||
float? AverageMark = null,
|
||||
/// <summary>Count of absent rows in recent attendance memory for the class.</summary>
|
||||
int AbsentLessons = 0,
|
||||
/// <summary>Pupils with at least three recent absences.</summary>
|
||||
int FrequentAbsentees = 0);
|
||||
|
||||
internal sealed record UncoveredSubjectResponse(
|
||||
string DefName,
|
||||
@@ -390,12 +416,16 @@ internal static class StaffingMapper
|
||||
teacherName = teacher.Name.Full;
|
||||
}
|
||||
|
||||
var (averageMark, absentLessons, frequentAbsentees) = ClassGradebook(roster, schoolClass.Id);
|
||||
return new ClassTeacherSlotResponse(
|
||||
schoolClass.Id,
|
||||
schoolClass.Year,
|
||||
schoolClass.Letter,
|
||||
schoolClass.ClassTeacherId,
|
||||
teacherName);
|
||||
teacherName,
|
||||
averageMark,
|
||||
absentLessons,
|
||||
frequentAbsentees);
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
@@ -411,6 +441,65 @@ internal static class StaffingMapper
|
||||
classes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Short class track for Management — mean mark and absence counts from sparse person memory,
|
||||
/// not a year-long sheet.
|
||||
/// </summary>
|
||||
private static (float? AverageMark, int AbsentLessons, int FrequentAbsentees) ClassGradebook(
|
||||
Roster roster,
|
||||
string classId)
|
||||
{
|
||||
var markSum = 0;
|
||||
var markCount = 0;
|
||||
var absentLessons = 0;
|
||||
var frequentAbsentees = 0;
|
||||
const int frequentThreshold = 3;
|
||||
|
||||
foreach (var person in roster.People)
|
||||
{
|
||||
if (!person.IsStudent
|
||||
|| person.ClassId is null
|
||||
|| !person.ClassId.Equals(classId, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (person.LessonMarks is { Count: > 0 } marks)
|
||||
{
|
||||
foreach (var row in marks)
|
||||
{
|
||||
markSum += row.Value;
|
||||
markCount++;
|
||||
}
|
||||
}
|
||||
|
||||
var absents = 0;
|
||||
if (person.Attendance is { Count: > 0 } attendance)
|
||||
{
|
||||
foreach (var row in attendance)
|
||||
{
|
||||
if (row.Status != AttendanceStatuses.Absent)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
absents++;
|
||||
absentLessons++;
|
||||
}
|
||||
}
|
||||
|
||||
if (absents >= frequentThreshold)
|
||||
{
|
||||
frequentAbsentees++;
|
||||
}
|
||||
}
|
||||
|
||||
float? average = markCount == 0
|
||||
? null
|
||||
: MathF.Round(markSum / (float)markCount, 1);
|
||||
return (average, absentLessons, frequentAbsentees);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<LabeledStatResponse> SkillsOf(Person person, DefCatalog? catalog, string locale)
|
||||
{
|
||||
return person.Skills
|
||||
|
||||
@@ -111,7 +111,84 @@ internal static partial class PersonCardReader
|
||||
TalkTopicId: circle?.TopicId,
|
||||
ClassTeacherId: classTeacherId,
|
||||
ClassTeacherName: classTeacherName,
|
||||
Offenses: Offenses(roster, person, catalog, locale));
|
||||
Offenses: Offenses(roster, person, catalog, locale),
|
||||
LessonMarks: LessonMarks(person, catalog, locale),
|
||||
Attendance: Attendance(person, catalog, locale));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<LessonMarkEntryResponse> LessonMarks(
|
||||
Person person,
|
||||
DefCatalog? catalog,
|
||||
string locale)
|
||||
{
|
||||
var rows = person.LessonMarks;
|
||||
if (rows is null || rows.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var result = new LessonMarkEntryResponse[rows.Count];
|
||||
for (var i = 0; i < rows.Count; i++)
|
||||
{
|
||||
var row = rows[i];
|
||||
result[i] = new LessonMarkEntryResponse(
|
||||
row.Subject,
|
||||
SubjectLabel(catalog, locale, row.Subject),
|
||||
row.Value,
|
||||
row.Time,
|
||||
row.Period);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AttendanceEntryResponse> Attendance(
|
||||
Person person,
|
||||
DefCatalog? catalog,
|
||||
string locale)
|
||||
{
|
||||
var rows = person.Attendance;
|
||||
if (rows is null || rows.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var result = new AttendanceEntryResponse[rows.Count];
|
||||
for (var i = 0; i < rows.Count; i++)
|
||||
{
|
||||
var row = rows[i];
|
||||
result[i] = new AttendanceEntryResponse(
|
||||
row.Subject,
|
||||
SubjectLabel(catalog, locale, row.Subject),
|
||||
row.Status,
|
||||
StatusLabel(catalog, locale, row.Status),
|
||||
row.Time,
|
||||
row.Period,
|
||||
row.AbsenceReason);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string SubjectLabel(DefCatalog? catalog, string locale, string subject)
|
||||
{
|
||||
if (catalog is not null && catalog.Subjects.TryGetValue(subject, out var def))
|
||||
{
|
||||
return catalog.Label(locale, def);
|
||||
}
|
||||
|
||||
return subject;
|
||||
}
|
||||
|
||||
private static string StatusLabel(DefCatalog? catalog, string locale, string status)
|
||||
{
|
||||
var key = AttendanceStatuses.LocaleKey(status);
|
||||
if (catalog is not null && catalog.HasText(locale, key))
|
||||
{
|
||||
return catalog.Text(locale, key);
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<OffenseEntryResponse> Offenses(
|
||||
|
||||
@@ -211,6 +211,9 @@
|
||||
"OffenseQuarrel": "quarrel",
|
||||
"OffenseFight": "fight",
|
||||
"OffenseReprimand": "reprimand",
|
||||
"AttendancePresent": "present",
|
||||
"AttendanceLate": "late",
|
||||
"AttendanceAbsent": "absent",
|
||||
"PrincipalHearing": "Principal's hearing",
|
||||
"TopicStudy": "schoolwork",
|
||||
"TopicGames": "games",
|
||||
|
||||
@@ -211,6 +211,9 @@
|
||||
"OffenseQuarrel": "ссора",
|
||||
"OffenseFight": "драка",
|
||||
"OffenseReprimand": "выговор",
|
||||
"AttendancePresent": "был",
|
||||
"AttendanceLate": "опоздал",
|
||||
"AttendanceAbsent": "отсутствовал",
|
||||
"PrincipalHearing": "Приём у директора",
|
||||
"TopicStudy": "учёбе",
|
||||
"TopicGames": "играх",
|
||||
|
||||
Reference in New Issue
Block a user