Enhance school simulation management by introducing a new SchoolWeekDays option, allowing configuration of the number of working days in a week. Update the API to include school week days in responses and enhance documentation to reflect these changes. Revise UI components to support the new management features, ensuring a seamless user experience when hiring and assigning staff. Update localization strings for improved clarity and consistency across the application.
This commit is contained in:
@@ -22,6 +22,8 @@ describe('t', () => {
|
||||
expect(t('schoolCount', { current: 2, max: 6 })).toBe('Schools: 2 of 6.');
|
||||
expect(t('pupilSlots', { count: 16 })).toBe('Pupil places: 16');
|
||||
expect(t('peoplePager', { page: 2, pages: 10, total: 512 })).toBe('Page 2 of 10 · 512');
|
||||
expect(t('staffErrorPayroll', { allocated: '10 000', payroll: '8 000', remaining: '2 000', attempted: '12 000' }))
|
||||
.toBe('Not enough money: 8 000 of 10 000 is committed, 2 000 free, 12 000 needed.');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -125,6 +125,46 @@ const ru = {
|
||||
peopleChildren: 'Дети',
|
||||
peopleSiblings: 'Братья и сёстры',
|
||||
peoplePartners: 'Супруг(а)',
|
||||
|
||||
modeOverview: 'Обзор',
|
||||
modeManage: 'Управление',
|
||||
staffAllocated: 'Выделено',
|
||||
staffPayroll: 'Расписано',
|
||||
staffRemaining: 'Свободно',
|
||||
staffUncovered: 'Не покрыто',
|
||||
staffUncoveredEmpty: 'Все предметы существующих параллелей кем-то ведутся.',
|
||||
staffApplicants: 'Соискатели',
|
||||
staffApplicantsEmpty: 'Пул пуст.',
|
||||
staffHired: 'Штат',
|
||||
staffHiredEmpty: 'Никого не наняли.',
|
||||
staffColName: 'ФИО',
|
||||
staffColAsk: 'Ставка',
|
||||
staffColSkills: 'Навыки',
|
||||
staffColPosition: 'Должность',
|
||||
staffColSubjects: 'Предметы',
|
||||
staffColPay: 'В месяц',
|
||||
staffAsk: '{hourly}/ч · {monthly}/мес',
|
||||
staffSubjectRange: '{label} ({min}–{max})',
|
||||
staffParent: 'родитель',
|
||||
staffPickHint: 'Выберите соискателя или работника в списке.',
|
||||
staffHireTitle: 'Наём',
|
||||
staffHire: 'Нанять',
|
||||
staffPosition: 'Должность',
|
||||
staffSubjectsTitle: 'Предметы',
|
||||
staffNoSubjects: 'Предметы ещё не назначены.',
|
||||
staffSubject: 'Предмет',
|
||||
staffAssign: 'Назначить',
|
||||
staffUnassign: 'Снять',
|
||||
staffLoadFailed: 'Не удалось загрузить управление штатом.',
|
||||
staffActionFailed: 'Не удалось выполнить действие.',
|
||||
staffErrorPayroll: 'Не хватает денег: расписано {payroll} из {allocated}, свободно {remaining}, нужно {attempted}.',
|
||||
staffErrorHired: 'Этот человек уже в штате.',
|
||||
staffErrorNoOpening: 'Нет свободного места на эту должность.',
|
||||
staffErrorUnknownApplicant: 'Этого человека нет в пуле соискателей.',
|
||||
staffErrorPosition: 'Такой должности нет.',
|
||||
staffErrorNotTeacher: 'Предмет можно назначить только учителю.',
|
||||
staffErrorAssigned: 'Этот предмет уже назначен.',
|
||||
staffErrorSubject: 'Такого предмета нет.',
|
||||
} as const;
|
||||
|
||||
type Messages = { [K in keyof typeof ru]: string };
|
||||
@@ -254,6 +294,46 @@ const en: Messages = {
|
||||
peopleChildren: 'Children',
|
||||
peopleSiblings: 'Siblings',
|
||||
peoplePartners: 'Spouse',
|
||||
|
||||
modeOverview: 'Overview',
|
||||
modeManage: 'Management',
|
||||
staffAllocated: 'Allocated',
|
||||
staffPayroll: 'Committed',
|
||||
staffRemaining: 'Remaining',
|
||||
staffUncovered: 'Uncovered',
|
||||
staffUncoveredEmpty: 'Every subject in the existing year-groups has a teacher.',
|
||||
staffApplicants: 'Applicants',
|
||||
staffApplicantsEmpty: 'The pool is empty.',
|
||||
staffHired: 'Staff',
|
||||
staffHiredEmpty: 'Nobody hired yet.',
|
||||
staffColName: 'Name',
|
||||
staffColAsk: 'Ask',
|
||||
staffColSkills: 'Skills',
|
||||
staffColPosition: 'Position',
|
||||
staffColSubjects: 'Subjects',
|
||||
staffColPay: 'Monthly',
|
||||
staffAsk: '{hourly}/h · {monthly}/mo',
|
||||
staffSubjectRange: '{label} ({min}–{max})',
|
||||
staffParent: 'parent',
|
||||
staffPickHint: 'Select an applicant or a staff member in the list.',
|
||||
staffHireTitle: 'Hire',
|
||||
staffHire: 'Hire',
|
||||
staffPosition: 'Position',
|
||||
staffSubjectsTitle: 'Subjects',
|
||||
staffNoSubjects: 'No subjects assigned yet.',
|
||||
staffSubject: 'Subject',
|
||||
staffAssign: 'Assign',
|
||||
staffUnassign: 'Remove',
|
||||
staffLoadFailed: 'Could not load staffing.',
|
||||
staffActionFailed: 'That action failed.',
|
||||
staffErrorPayroll: 'Not enough money: {payroll} of {allocated} is committed, {remaining} free, {attempted} needed.',
|
||||
staffErrorHired: 'That person is already on staff.',
|
||||
staffErrorNoOpening: 'There is no free opening for that position.',
|
||||
staffErrorUnknownApplicant: 'That person is not in the applicant pool.',
|
||||
staffErrorPosition: 'That position is not in the catalog.',
|
||||
staffErrorNotTeacher: 'Only a teacher can be assigned a subject.',
|
||||
staffErrorAssigned: 'That subject is already assigned.',
|
||||
staffErrorSubject: 'That subject is not in the catalog.',
|
||||
};
|
||||
|
||||
const catalogs: Record<Locale, Messages> = { ru, en };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* HTTP side of the server: the main menu and the in-school people list/card. The realtime clock
|
||||
* arrives over the WebSocket instead — see `connection.ts`.
|
||||
* HTTP side of the server: the main menu, the in-school people list/card, and staffing. The
|
||||
* realtime clock arrives over the WebSocket instead — see `connection.ts`.
|
||||
*/
|
||||
|
||||
export interface School {
|
||||
@@ -16,6 +16,7 @@ export interface SchoolsResponse {
|
||||
readonly maxSchools: number;
|
||||
readonly defaultStartDate: string;
|
||||
readonly gameMinutesPerRealSecond: number;
|
||||
readonly schoolWeekDays: number;
|
||||
readonly schools: readonly School[];
|
||||
}
|
||||
|
||||
@@ -25,6 +26,10 @@ export class ApiError extends Error {
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly allocated?: number,
|
||||
readonly payroll?: number,
|
||||
readonly remaining?: number,
|
||||
readonly attempted?: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
@@ -104,6 +109,25 @@ export interface SubjectInfo {
|
||||
readonly gradeMax: number;
|
||||
readonly hoursPerWeek: number;
|
||||
readonly skills: readonly SubjectSkillShare[];
|
||||
readonly room: string | null;
|
||||
}
|
||||
|
||||
export interface DayFrameInfo {
|
||||
readonly defName: string;
|
||||
readonly label: string;
|
||||
readonly firstLesson: string;
|
||||
readonly lessonCount: number;
|
||||
readonly lessonMinutes: number;
|
||||
readonly breakMinutes: number;
|
||||
readonly longBreakAfter: number;
|
||||
readonly longBreakMinutes: number;
|
||||
}
|
||||
|
||||
export interface HolidayInfo {
|
||||
readonly defName: string;
|
||||
readonly label: string;
|
||||
readonly start: { readonly month: number; readonly day: number };
|
||||
readonly end: { readonly month: number; readonly day: number };
|
||||
}
|
||||
|
||||
export interface MapLayout {
|
||||
@@ -131,6 +155,8 @@ export interface CatalogResponse {
|
||||
readonly defaultMap: MapLayout;
|
||||
readonly nameSets: readonly DefInfo[];
|
||||
readonly subjects: readonly SubjectInfo[];
|
||||
readonly dayFrame: DayFrameInfo | null;
|
||||
readonly holidays: readonly HolidayInfo[];
|
||||
}
|
||||
|
||||
export async function fetchMods(): Promise<readonly ModInfo[]> {
|
||||
@@ -265,6 +291,98 @@ export async function fetchPerson(schoolId: number, personId: string, lang: stri
|
||||
);
|
||||
}
|
||||
|
||||
export interface StaffingSubject {
|
||||
readonly defName: string;
|
||||
readonly label: string;
|
||||
readonly gradeMin: number;
|
||||
readonly gradeMax: number;
|
||||
readonly hoursPerWeek: number;
|
||||
}
|
||||
|
||||
export interface StaffingApplicant {
|
||||
readonly id: string;
|
||||
readonly fullName: string;
|
||||
readonly female: boolean;
|
||||
readonly age: number;
|
||||
readonly isParent: boolean;
|
||||
readonly hourlyWageAsk: number;
|
||||
readonly monthlyBase: number;
|
||||
readonly skills: readonly LabeledStat[];
|
||||
}
|
||||
|
||||
export interface StaffMember {
|
||||
readonly id: string;
|
||||
readonly fullName: string;
|
||||
readonly female: boolean;
|
||||
readonly age: number;
|
||||
readonly isParent: boolean;
|
||||
readonly position: string;
|
||||
readonly positionLabel: string;
|
||||
readonly hourlyWageAsk: number;
|
||||
readonly monthlyPay: number;
|
||||
readonly subjects: readonly DefLabel[];
|
||||
}
|
||||
|
||||
export interface Staffing {
|
||||
readonly allocated: number;
|
||||
readonly payroll: number;
|
||||
readonly remaining: number;
|
||||
readonly uncovered: readonly StaffingSubject[];
|
||||
readonly applicants: readonly StaffingApplicant[];
|
||||
readonly staff: readonly StaffMember[];
|
||||
readonly positions: readonly DefLabel[];
|
||||
readonly subjects: readonly StaffingSubject[];
|
||||
}
|
||||
|
||||
export async function fetchStaffing(schoolId: number, lang: string): Promise<Staffing> {
|
||||
const params = new URLSearchParams({ lang });
|
||||
return request<Staffing>(`/api/schools/${schoolId}/staffing?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function hireStaff(
|
||||
schoolId: number,
|
||||
personId: string,
|
||||
position: string,
|
||||
lang: string,
|
||||
): Promise<Staffing> {
|
||||
const params = new URLSearchParams({ lang });
|
||||
return request<Staffing>(`/api/schools/${schoolId}/staff/hire?${params.toString()}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ personId, position }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function assignSubject(
|
||||
schoolId: number,
|
||||
personId: string,
|
||||
subject: string,
|
||||
lang: string,
|
||||
): Promise<Staffing> {
|
||||
const params = new URLSearchParams({ lang });
|
||||
return request<Staffing>(
|
||||
`/api/schools/${schoolId}/staff/${encodeURIComponent(personId)}/subjects?${params.toString()}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ subject }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function unassignSubject(
|
||||
schoolId: number,
|
||||
personId: string,
|
||||
subject: string,
|
||||
lang: string,
|
||||
): Promise<Staffing> {
|
||||
const params = new URLSearchParams({ lang });
|
||||
return request<Staffing>(
|
||||
`/api/schools/${schoolId}/staff/${encodeURIComponent(personId)}/subjects/${encodeURIComponent(subject)}?${params.toString()}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
@@ -286,11 +404,23 @@ async function request<T>(
|
||||
async function toApiError(response: Response): Promise<ApiError> {
|
||||
try {
|
||||
// ASP.NET Core problem details; `code` is added by the server for cases the UI reacts to.
|
||||
const problem = (await response.json()) as { code?: string; detail?: string; title?: string };
|
||||
const problem = (await response.json()) as {
|
||||
code?: string;
|
||||
detail?: string;
|
||||
title?: string;
|
||||
allocated?: number;
|
||||
payroll?: number;
|
||||
remaining?: number;
|
||||
attempted?: number;
|
||||
};
|
||||
return new ApiError(
|
||||
response.status,
|
||||
problem.code ?? 'unknown',
|
||||
problem.detail ?? problem.title ?? response.statusText,
|
||||
problem.allocated,
|
||||
problem.payroll,
|
||||
problem.remaining,
|
||||
problem.attempted,
|
||||
);
|
||||
} catch {
|
||||
return new ApiError(response.status, 'unknown', response.statusText);
|
||||
|
||||
@@ -254,12 +254,42 @@ body {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mode-tabs {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
margin-top: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.mode-tab {
|
||||
padding: 10px 18px;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mode-tab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.mode-tab--active {
|
||||
border-bottom-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Manager */
|
||||
|
||||
/*
|
||||
* Two panels, both full height. Left is a list — the map tree or the people table, whichever tab
|
||||
* is up. Right inspects whatever was picked last, a room or a person. Nothing lives below the
|
||||
* fold: the people list used to sit in a bottom row that had to be scrolled to.
|
||||
* Two panels, both full height. Overview: map/people on the left, room or person on the right.
|
||||
* Management: money and lists on the left, the same person card on the right. Mode tabs sit
|
||||
* under the clock so the calendar stays visible while hiring.
|
||||
*/
|
||||
.manager {
|
||||
display: grid;
|
||||
@@ -671,6 +701,69 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.staffing__money {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.staffing__money-item dt {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.staffing__money-item dd {
|
||||
margin: 4px 0 0;
|
||||
font-size: 22px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.staffing__error {
|
||||
margin: 0 0 12px;
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.staffing__badge {
|
||||
margin-left: 8px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.staffing__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.staffing__assigned {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.staffing__assigned-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.staffing__list-section .people__table-wrap {
|
||||
max-height: 220px;
|
||||
}
|
||||
|
||||
/* Below three columns the panels stop competing for height and the page scrolls instead. */
|
||||
@media (max-width: 900px) {
|
||||
#app {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { formatGameDate, formatGameTimeOfDay, formatGameWeekday } from '../forma
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import type { School } from '../net/api.ts';
|
||||
import { clear, el } from './dom.ts';
|
||||
import { ManagementPanel } from './managementPanel.ts';
|
||||
import { PeoplePanel } from './peoplePanel.ts';
|
||||
|
||||
interface GameScreenOptions {
|
||||
@@ -49,6 +50,13 @@ export class GameScreen {
|
||||
private readonly positionsList = el('ul', { class: 'panel__list' });
|
||||
|
||||
private readonly people = new PeoplePanel({ onSelect: () => this.inspect('person') });
|
||||
private readonly management = new ManagementPanel();
|
||||
private readonly overviewTab = el('button', { class: 'mode-tab', type: 'button' });
|
||||
private readonly manageTab = el('button', { class: 'mode-tab', type: 'button' });
|
||||
private readonly overview = el('div', { class: 'manager' });
|
||||
private readonly manage = el('div', { class: 'manager' });
|
||||
private readonly manageListTitle = el('h2', { class: 'panel__title' });
|
||||
private readonly manageCardTitle = el('h2', { class: 'panel__title' });
|
||||
|
||||
private readonly treeButtons = new Map<string, HTMLButtonElement>();
|
||||
private nodes: readonly MapSnapshotNode[] = [];
|
||||
@@ -85,18 +93,24 @@ export class GameScreen {
|
||||
),
|
||||
el('div', { class: 'clock__controls' }, this.playPauseButton, ...this.speedButtons),
|
||||
),
|
||||
el('div', { class: 'mode-tabs' }, this.overviewTab, this.manageTab),
|
||||
this.overview,
|
||||
this.manage,
|
||||
);
|
||||
|
||||
this.overview.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'manager' },
|
||||
el(
|
||||
'section',
|
||||
{ class: 'panel' },
|
||||
el('div', { class: 'panel__tabs' }, this.mapTab, this.peopleTab),
|
||||
this.mapBody,
|
||||
this.peopleBody,
|
||||
),
|
||||
el('section', { class: 'panel' }, this.inspectTitle, this.locationBody, this.people.cardElement),
|
||||
'section',
|
||||
{ class: 'panel' },
|
||||
el('div', { class: 'panel__tabs' }, this.mapTab, this.peopleTab),
|
||||
this.mapBody,
|
||||
this.peopleBody,
|
||||
),
|
||||
el('section', { class: 'panel' }, this.inspectTitle, this.locationBody, this.people.cardElement),
|
||||
);
|
||||
this.manage.append(
|
||||
el('section', { class: 'panel' }, this.manageListTitle, this.management.listElement),
|
||||
el('section', { class: 'panel' }, this.manageCardTitle, this.management.cardElement),
|
||||
);
|
||||
|
||||
this.mapBody.append(this.tree);
|
||||
@@ -111,8 +125,11 @@ export class GameScreen {
|
||||
|
||||
this.mapTab.addEventListener('click', () => this.showTab('map'));
|
||||
this.peopleTab.addEventListener('click', () => this.showTab('people'));
|
||||
this.overviewTab.addEventListener('click', () => this.showMode('overview'));
|
||||
this.manageTab.addEventListener('click', () => this.showMode('manage'));
|
||||
this.showTab('map');
|
||||
this.inspect('location');
|
||||
this.showMode('overview');
|
||||
|
||||
this.localize();
|
||||
}
|
||||
@@ -123,6 +140,10 @@ export class GameScreen {
|
||||
|
||||
localize(): void {
|
||||
this.backButton.textContent = t('backToMenu');
|
||||
this.overviewTab.textContent = t('modeOverview');
|
||||
this.manageTab.textContent = t('modeManage');
|
||||
this.manageListTitle.textContent = t('modeManage');
|
||||
this.manageCardTitle.textContent = t('personTitle');
|
||||
this.mapTab.textContent = t('mapTitle');
|
||||
this.peopleTab.textContent = t('peopleTitle');
|
||||
this.paintInspectTitle();
|
||||
@@ -136,6 +157,7 @@ export class GameScreen {
|
||||
this.positionsEmpty.textContent = t('positionsEmpty');
|
||||
|
||||
this.people.localize();
|
||||
this.management.localize();
|
||||
this.paintSelection();
|
||||
|
||||
if (this.lastGameTime !== null) {
|
||||
@@ -156,6 +178,7 @@ export class GameScreen {
|
||||
this.people.show(school.id);
|
||||
this.showTab('map');
|
||||
this.inspect('location');
|
||||
this.showMode('overview');
|
||||
}
|
||||
|
||||
applyMap(schoolId: number, nodes: readonly MapSnapshotNode[]): void {
|
||||
@@ -211,6 +234,20 @@ export class GameScreen {
|
||||
this.paintSelection();
|
||||
}
|
||||
|
||||
private showMode(mode: 'overview' | 'manage'): void {
|
||||
this.overviewTab.classList.toggle('mode-tab--active', mode === 'overview');
|
||||
this.manageTab.classList.toggle('mode-tab--active', mode === 'manage');
|
||||
this.overview.hidden = mode !== 'overview';
|
||||
this.manage.hidden = mode !== 'manage';
|
||||
if (mode === 'manage' && this.schoolId !== null) {
|
||||
this.management.show(this.schoolId);
|
||||
}
|
||||
|
||||
if (mode === 'overview') {
|
||||
this.people.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
private showTab(tab: 'map' | 'people'): void {
|
||||
this.mapTab.classList.toggle('panel__tab--active', tab === 'map');
|
||||
this.peopleTab.classList.toggle('panel__tab--active', tab === 'people');
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
import {
|
||||
ApiError,
|
||||
assignSubject,
|
||||
fetchPerson,
|
||||
fetchStaffing,
|
||||
hireStaff,
|
||||
unassignSubject,
|
||||
type PersonCard,
|
||||
type Staffing,
|
||||
type StaffingApplicant,
|
||||
type StaffMember,
|
||||
} from '../net/api.ts';
|
||||
import { getLocale, intlTag } from '../i18n/locale.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { clear, el } from './dom.ts';
|
||||
import { renderPersonCard } from './personCard.ts';
|
||||
|
||||
const TEACHER = 'Teacher';
|
||||
|
||||
type Selection =
|
||||
| { readonly kind: 'applicant'; readonly id: string }
|
||||
| { readonly kind: 'staff'; readonly id: string };
|
||||
|
||||
/**
|
||||
* Left: money, coverage, applicants, staff. Right: the same person card as People, plus hire
|
||||
* and subject actions. HTTP only — the clock stays on the bar above.
|
||||
*/
|
||||
export class ManagementPanel {
|
||||
readonly listElement: HTMLElement;
|
||||
readonly cardElement: HTMLElement;
|
||||
|
||||
private readonly error = el('p', { class: 'staffing__error' });
|
||||
private readonly money = el('dl', { class: 'staffing__money' });
|
||||
private readonly uncoveredTitle = el('h3', { class: 'panel__section-title' });
|
||||
private readonly uncovered = el('div', { class: 'people__tags' });
|
||||
private readonly uncoveredEmpty = el('p', { class: 'panel__empty' });
|
||||
private readonly applicantsTitle = el('h3', { class: 'panel__section-title' });
|
||||
private readonly applicantsTable = el('table', { class: 'people__table' });
|
||||
private readonly applicantsEmpty = el('p', { class: 'panel__empty' });
|
||||
private readonly staffTitle = el('h3', { class: 'panel__section-title' });
|
||||
private readonly staffTable = el('table', { class: 'people__table' });
|
||||
private readonly staffEmpty = el('p', { class: 'panel__empty' });
|
||||
private readonly card = el('aside', { class: 'panel__body people__card' });
|
||||
private readonly positionSelect = el('select', { class: 'input people__input' });
|
||||
private readonly subjectSelect = el('select', { class: 'input people__input' });
|
||||
|
||||
private schoolId: number | null = null;
|
||||
private staffing: Staffing | null = null;
|
||||
private selection: Selection | null = null;
|
||||
private loadToken = 0;
|
||||
private cardToken = 0;
|
||||
private busy = false;
|
||||
|
||||
constructor() {
|
||||
this.error.hidden = true;
|
||||
this.listElement = el(
|
||||
'div',
|
||||
{ class: 'panel__body staffing' },
|
||||
this.error,
|
||||
this.money,
|
||||
el('div', { class: 'panel__section' }, this.uncoveredTitle, this.uncoveredEmpty, this.uncovered),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'panel__section staffing__list-section' },
|
||||
this.applicantsTitle,
|
||||
el('div', { class: 'people__table-wrap' }, this.applicantsTable, this.applicantsEmpty),
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'panel__section staffing__list-section' },
|
||||
this.staffTitle,
|
||||
el('div', { class: 'people__table-wrap' }, this.staffTable, this.staffEmpty),
|
||||
),
|
||||
);
|
||||
this.cardElement = this.card;
|
||||
this.localize();
|
||||
}
|
||||
|
||||
localize(): void {
|
||||
this.uncoveredTitle.textContent = t('staffUncovered');
|
||||
this.applicantsTitle.textContent = t('staffApplicants');
|
||||
this.staffTitle.textContent = t('staffHired');
|
||||
this.uncoveredEmpty.textContent = t('staffUncoveredEmpty');
|
||||
this.applicantsEmpty.textContent = t('staffApplicantsEmpty');
|
||||
this.staffEmpty.textContent = t('staffHiredEmpty');
|
||||
this.paint();
|
||||
if (this.selection !== null) {
|
||||
void this.openCard(this.selection);
|
||||
} else {
|
||||
this.paintCard(null);
|
||||
}
|
||||
}
|
||||
|
||||
show(schoolId: number): void {
|
||||
const switched = this.schoolId !== schoolId;
|
||||
this.schoolId = schoolId;
|
||||
if (switched) {
|
||||
this.selection = null;
|
||||
this.staffing = null;
|
||||
this.clearError();
|
||||
}
|
||||
|
||||
void this.reload();
|
||||
}
|
||||
|
||||
private async reload(): Promise<void> {
|
||||
const schoolId = this.schoolId;
|
||||
if (schoolId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = ++this.loadToken;
|
||||
try {
|
||||
const staffing = await fetchStaffing(schoolId, getLocale());
|
||||
if (token !== this.loadToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.staffing = staffing;
|
||||
this.syncSelection();
|
||||
this.paint();
|
||||
if (this.selection !== null) {
|
||||
void this.openCard(this.selection);
|
||||
} else {
|
||||
this.paintCard(null);
|
||||
}
|
||||
} catch {
|
||||
if (token !== this.loadToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showError(t('staffLoadFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
private syncSelection(): void {
|
||||
const staffing = this.staffing;
|
||||
const selected = this.selection;
|
||||
if (staffing === null || selected === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected.kind === 'applicant' && staffing.applicants.some((row) => row.id === selected.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (staffing.staff.some((row) => row.id === selected.id)) {
|
||||
this.selection = { kind: 'staff', id: selected.id };
|
||||
return;
|
||||
}
|
||||
|
||||
this.selection = null;
|
||||
}
|
||||
|
||||
private paint(): void {
|
||||
const staffing = this.staffing;
|
||||
this.paintMoney(staffing);
|
||||
this.paintUncovered(staffing);
|
||||
this.paintApplicants(staffing);
|
||||
this.paintStaff(staffing);
|
||||
}
|
||||
|
||||
private paintMoney(staffing: Staffing | null): void {
|
||||
clear(this.money);
|
||||
const allocated = staffing?.allocated ?? 0;
|
||||
const payroll = staffing?.payroll ?? 0;
|
||||
const remaining = staffing?.remaining ?? 0;
|
||||
for (const [label, amount] of [
|
||||
[t('staffAllocated'), allocated],
|
||||
[t('staffPayroll'), payroll],
|
||||
[t('staffRemaining'), remaining],
|
||||
] as const) {
|
||||
this.money.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'staffing__money-item' },
|
||||
el('dt', { text: label }),
|
||||
el('dd', { text: formatMoney(amount) }),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private paintUncovered(staffing: Staffing | null): void {
|
||||
clear(this.uncovered);
|
||||
const rows = staffing?.uncovered ?? [];
|
||||
this.uncoveredEmpty.hidden = rows.length > 0;
|
||||
this.uncovered.hidden = rows.length === 0;
|
||||
for (const subject of rows) {
|
||||
this.uncovered.append(
|
||||
el('span', {
|
||||
class: 'people__tag',
|
||||
text: t('staffSubjectRange', {
|
||||
label: subject.label,
|
||||
min: subject.gradeMin,
|
||||
max: subject.gradeMax,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private paintApplicants(staffing: Staffing | null): void {
|
||||
const rows = staffing?.applicants ?? [];
|
||||
this.applicantsEmpty.hidden = rows.length > 0;
|
||||
this.applicantsTable.hidden = rows.length === 0;
|
||||
clear(this.applicantsTable);
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const body = el('tbody');
|
||||
this.applicantsTable.append(
|
||||
el(
|
||||
'thead',
|
||||
{},
|
||||
el(
|
||||
'tr',
|
||||
{},
|
||||
el('th', { text: t('staffColName') }),
|
||||
el('th', { text: t('staffColAsk') }),
|
||||
el('th', { text: t('staffColSkills') }),
|
||||
),
|
||||
),
|
||||
body,
|
||||
);
|
||||
|
||||
for (const applicant of rows) {
|
||||
const selected = this.selection?.kind === 'applicant' && this.selection.id === applicant.id;
|
||||
const row = el('tr', {
|
||||
class: selected ? 'people__row people__row--active' : 'people__row',
|
||||
dataset: { personId: applicant.id },
|
||||
onClick: () => void this.select({ kind: 'applicant', id: applicant.id }),
|
||||
});
|
||||
const ask = el('td', {
|
||||
text: t('staffAsk', {
|
||||
hourly: formatMoney(applicant.hourlyWageAsk),
|
||||
monthly: formatMoney(applicant.monthlyBase),
|
||||
}),
|
||||
});
|
||||
const name = el('td', { text: applicant.fullName });
|
||||
if (applicant.isParent) {
|
||||
name.append(el('span', { class: 'staffing__badge', text: t('staffParent') }));
|
||||
}
|
||||
|
||||
row.append(
|
||||
name,
|
||||
ask,
|
||||
el('td', { text: applicant.skills.map((skill) => skill.label).join(', ') || '—' }),
|
||||
);
|
||||
body.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
private paintStaff(staffing: Staffing | null): void {
|
||||
const rows = staffing?.staff ?? [];
|
||||
this.staffEmpty.hidden = rows.length > 0;
|
||||
this.staffTable.hidden = rows.length === 0;
|
||||
clear(this.staffTable);
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const body = el('tbody');
|
||||
this.staffTable.append(
|
||||
el(
|
||||
'thead',
|
||||
{},
|
||||
el(
|
||||
'tr',
|
||||
{},
|
||||
el('th', { text: t('staffColName') }),
|
||||
el('th', { text: t('staffColPosition') }),
|
||||
el('th', { text: t('staffColSubjects') }),
|
||||
el('th', { text: t('staffColPay') }),
|
||||
),
|
||||
),
|
||||
body,
|
||||
);
|
||||
|
||||
for (const member of rows) {
|
||||
const selected = this.selection?.kind === 'staff' && this.selection.id === member.id;
|
||||
const row = el('tr', {
|
||||
class: selected ? 'people__row people__row--active' : 'people__row',
|
||||
dataset: { personId: member.id },
|
||||
onClick: () => void this.select({ kind: 'staff', id: member.id }),
|
||||
});
|
||||
const name = el('td', { text: member.fullName });
|
||||
if (member.isParent) {
|
||||
name.append(el('span', { class: 'staffing__badge', text: t('staffParent') }));
|
||||
}
|
||||
|
||||
row.append(
|
||||
name,
|
||||
el('td', { text: member.positionLabel }),
|
||||
el('td', { text: member.subjects.map((subject) => subject.label).join(', ') || '—' }),
|
||||
el('td', { text: formatMoney(member.monthlyPay) }),
|
||||
);
|
||||
body.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
private async select(selection: Selection): Promise<void> {
|
||||
this.selection = selection;
|
||||
this.clearError();
|
||||
this.paint();
|
||||
await this.openCard(selection);
|
||||
}
|
||||
|
||||
private async openCard(selection: Selection): Promise<void> {
|
||||
const schoolId = this.schoolId;
|
||||
if (schoolId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = ++this.cardToken;
|
||||
try {
|
||||
const card = await fetchPerson(schoolId, selection.id, getLocale());
|
||||
if (token !== this.cardToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.paintCard(card);
|
||||
} catch {
|
||||
if (token !== this.cardToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
clear(this.card);
|
||||
this.card.append(el('p', { class: 'panel__empty', text: t('peopleCardFailed') }));
|
||||
}
|
||||
}
|
||||
|
||||
private paintCard(card: PersonCard | null): void {
|
||||
clear(this.card);
|
||||
if (card === null) {
|
||||
this.card.append(el('p', { class: 'panel__empty', text: t('staffPickHint') }));
|
||||
return;
|
||||
}
|
||||
|
||||
renderPersonCard(this.card, card, (id) => void this.openRelative(id));
|
||||
this.appendActions(card.id);
|
||||
}
|
||||
|
||||
private async openRelative(personId: string): Promise<void> {
|
||||
const staffing = this.staffing;
|
||||
if (staffing !== null && staffing.staff.some((row) => row.id === personId)) {
|
||||
await this.select({ kind: 'staff', id: personId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (staffing !== null && staffing.applicants.some((row) => row.id === personId)) {
|
||||
await this.select({ kind: 'applicant', id: personId });
|
||||
return;
|
||||
}
|
||||
|
||||
await this.select({ kind: 'staff', id: personId });
|
||||
}
|
||||
|
||||
private appendActions(personId: string): void {
|
||||
const staffing = this.staffing;
|
||||
if (staffing === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const applicant = staffing.applicants.find((row) => row.id === personId);
|
||||
if (applicant !== undefined) {
|
||||
this.appendHire(applicant);
|
||||
return;
|
||||
}
|
||||
|
||||
const member = staffing.staff.find((row) => row.id === personId);
|
||||
if (member !== undefined && member.position === TEACHER) {
|
||||
this.appendSubjects(member);
|
||||
}
|
||||
}
|
||||
|
||||
private appendHire(applicant: StaffingApplicant): void {
|
||||
const staffing = this.staffing;
|
||||
if (staffing === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
fillSelect(
|
||||
this.positionSelect,
|
||||
staffing.positions.map((row) => ({ value: row.defName, label: row.label })),
|
||||
TEACHER,
|
||||
);
|
||||
|
||||
const actions = el('div', { class: 'staffing__actions' });
|
||||
actions.append(
|
||||
el('h4', { class: 'people__section-title', text: t('staffHireTitle') }),
|
||||
el('label', { class: 'people__field' }, el('span', { class: 'people__label', text: t('staffPosition') }), this.positionSelect),
|
||||
el('button', {
|
||||
class: 'button',
|
||||
type: 'button',
|
||||
text: t('staffHire'),
|
||||
disabled: this.busy,
|
||||
onClick: () => void this.hire(applicant.id),
|
||||
}),
|
||||
);
|
||||
this.card.append(actions);
|
||||
}
|
||||
|
||||
private appendSubjects(member: StaffMember): void {
|
||||
const staffing = this.staffing;
|
||||
if (staffing === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assigned = new Set(member.subjects.map((row) => row.defName));
|
||||
const available = staffing.subjects
|
||||
.filter((subject) => !assigned.has(subject.defName))
|
||||
.map((subject) => ({ value: subject.defName, label: subject.label }));
|
||||
fillSelect(this.subjectSelect, available, available[0]?.value ?? '');
|
||||
|
||||
const actions = el('div', { class: 'staffing__actions' });
|
||||
actions.append(el('h4', { class: 'people__section-title', text: t('staffSubjectsTitle') }));
|
||||
|
||||
if (member.subjects.length > 0) {
|
||||
const list = el('ul', { class: 'staffing__assigned' });
|
||||
for (const subject of member.subjects) {
|
||||
list.append(
|
||||
el(
|
||||
'li',
|
||||
{ class: 'staffing__assigned-row' },
|
||||
el('span', { text: subject.label }),
|
||||
el('button', {
|
||||
class: 'button button--small',
|
||||
type: 'button',
|
||||
text: t('staffUnassign'),
|
||||
disabled: this.busy,
|
||||
onClick: () => void this.unassign(member.id, subject.defName),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
actions.append(list);
|
||||
} else {
|
||||
actions.append(el('p', { class: 'panel__empty', text: t('staffNoSubjects') }));
|
||||
}
|
||||
|
||||
if (available.length > 0) {
|
||||
actions.append(
|
||||
el('label', { class: 'people__field' }, el('span', { class: 'people__label', text: t('staffSubject') }), this.subjectSelect),
|
||||
el('button', {
|
||||
class: 'button',
|
||||
type: 'button',
|
||||
text: t('staffAssign'),
|
||||
disabled: this.busy,
|
||||
onClick: () => void this.assign(member.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
this.card.append(actions);
|
||||
}
|
||||
|
||||
private async hire(personId: string): Promise<void> {
|
||||
const schoolId = this.schoolId;
|
||||
if (schoolId === null || this.busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.busy = true;
|
||||
this.clearError();
|
||||
try {
|
||||
const staffing = await hireStaff(schoolId, personId, this.positionSelect.value, getLocale());
|
||||
this.staffing = staffing;
|
||||
this.selection = { kind: 'staff', id: personId };
|
||||
} catch (error) {
|
||||
this.showError(actionError(error));
|
||||
} finally {
|
||||
this.busy = false;
|
||||
this.paint();
|
||||
if (this.selection !== null) {
|
||||
await this.openCard(this.selection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async assign(personId: string): Promise<void> {
|
||||
const schoolId = this.schoolId;
|
||||
const subject = this.subjectSelect.value;
|
||||
if (schoolId === null || this.busy || subject.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.busy = true;
|
||||
this.clearError();
|
||||
try {
|
||||
this.staffing = await assignSubject(schoolId, personId, subject, getLocale());
|
||||
this.selection = { kind: 'staff', id: personId };
|
||||
} catch (error) {
|
||||
this.showError(actionError(error));
|
||||
} finally {
|
||||
this.busy = false;
|
||||
this.paint();
|
||||
if (this.selection !== null) {
|
||||
await this.openCard(this.selection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async unassign(personId: string, subject: string): Promise<void> {
|
||||
const schoolId = this.schoolId;
|
||||
if (schoolId === null || this.busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.busy = true;
|
||||
this.clearError();
|
||||
try {
|
||||
this.staffing = await unassignSubject(schoolId, personId, subject, getLocale());
|
||||
this.selection = { kind: 'staff', id: personId };
|
||||
} catch (error) {
|
||||
this.showError(actionError(error));
|
||||
} finally {
|
||||
this.busy = false;
|
||||
this.paint();
|
||||
if (this.selection !== null) {
|
||||
await this.openCard(this.selection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private showError(message: string): void {
|
||||
this.error.hidden = false;
|
||||
this.error.textContent = message;
|
||||
}
|
||||
|
||||
private clearError(): void {
|
||||
this.error.hidden = true;
|
||||
this.error.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
function fillSelect(
|
||||
select: HTMLSelectElement,
|
||||
items: readonly { value: string; label: string }[],
|
||||
preferred: string,
|
||||
): void {
|
||||
const current = select.value;
|
||||
select.replaceChildren();
|
||||
for (const item of items) {
|
||||
const option = el('option', { text: item.label });
|
||||
option.value = item.value;
|
||||
select.append(option);
|
||||
}
|
||||
|
||||
if (items.some((item) => item.value === current)) {
|
||||
select.value = current;
|
||||
} else if (items.some((item) => item.value === preferred)) {
|
||||
select.value = preferred;
|
||||
}
|
||||
}
|
||||
|
||||
function formatMoney(value: number): string {
|
||||
return new Intl.NumberFormat(intlTag(), { maximumFractionDigits: 2 }).format(value);
|
||||
}
|
||||
|
||||
function actionError(error: unknown): string {
|
||||
if (!(error instanceof ApiError)) {
|
||||
return t('staffActionFailed');
|
||||
}
|
||||
|
||||
switch (error.code) {
|
||||
case 'payroll-exceeded':
|
||||
return t('staffErrorPayroll', {
|
||||
allocated: formatMoney(error.allocated ?? 0),
|
||||
payroll: formatMoney(error.payroll ?? 0),
|
||||
remaining: formatMoney(error.remaining ?? 0),
|
||||
attempted: formatMoney(error.attempted ?? 0),
|
||||
});
|
||||
case 'already-hired':
|
||||
return t('staffErrorHired');
|
||||
case 'no-opening':
|
||||
return t('staffErrorNoOpening');
|
||||
case 'unknown-applicant':
|
||||
return t('staffErrorUnknownApplicant');
|
||||
case 'unknown-position':
|
||||
return t('staffErrorPosition');
|
||||
case 'not-teacher':
|
||||
return t('staffErrorNotTeacher');
|
||||
case 'already-assigned':
|
||||
return t('staffErrorAssigned');
|
||||
case 'unknown-subject':
|
||||
return t('staffErrorSubject');
|
||||
default:
|
||||
return error.message.length > 0 ? error.message : t('staffActionFailed');
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,13 @@ import {
|
||||
fetchPerson,
|
||||
type PeoplePage,
|
||||
type PersonCard,
|
||||
type PersonListItem,
|
||||
type PersonRel,
|
||||
type PersonRole,
|
||||
type PersonSort,
|
||||
} from '../net/api.ts';
|
||||
import { getLocale } from '../i18n/locale.ts';
|
||||
import { t, type MessageKey } from '../i18n/strings.ts';
|
||||
import { clear, el } from './dom.ts';
|
||||
|
||||
const ROLE_KEYS: Record<PersonRole, MessageKey> = {
|
||||
student: 'peopleRoleStudent',
|
||||
staff: 'peopleRoleStaff',
|
||||
parent: 'peopleRoleParent',
|
||||
};
|
||||
import { placement, renderPersonCard, roleLabels } from './personCard.ts';
|
||||
|
||||
const COLUMNS: readonly { sort: PersonSort; label: MessageKey }[] = [
|
||||
{ sort: 'surname', label: 'peopleColName' },
|
||||
@@ -173,6 +166,12 @@ export class PeoplePanel {
|
||||
void this.reload();
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
if (this.schoolId !== null) {
|
||||
void this.reload();
|
||||
}
|
||||
}
|
||||
|
||||
private onFilterChange(): void {
|
||||
this.page = 1;
|
||||
void this.reload();
|
||||
@@ -357,23 +356,7 @@ export class PeoplePanel {
|
||||
return;
|
||||
}
|
||||
|
||||
this.card.append(
|
||||
el('h3', { class: 'people__card-name', text: card.fullName }),
|
||||
el('p', { class: 'people__card-meta', text: cardMeta(card) }),
|
||||
);
|
||||
appendPairs(this.card, t('peopleBody'), card.body);
|
||||
appendPairs(this.card, t('peopleSkills'), card.skills);
|
||||
appendTags(this.card, t('peopleTraits'), card.traits.map((row) => row.label));
|
||||
appendNeeds(this.card, t('peopleNeeds'), card.needs);
|
||||
|
||||
const family = section(t('peopleFamily'));
|
||||
appendRelatives(family, t('peopleParents'), card.family.parents, (id) => void this.openCard(id));
|
||||
appendRelatives(family, t('peopleChildren'), card.family.children, (id) => void this.openCard(id));
|
||||
appendRelatives(family, t('peopleSiblings'), card.family.siblings, (id) => void this.openCard(id));
|
||||
appendRelatives(family, t('peoplePartners'), card.family.partners, (id) => void this.openCard(id));
|
||||
if (family.childElementCount > 1) {
|
||||
this.card.append(family);
|
||||
}
|
||||
renderPersonCard(this.card, card, (id) => void this.openCard(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,139 +394,3 @@ function parseOptionalInt(value: string): number | undefined {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function placement(person: PersonListItem): string {
|
||||
const parts: string[] = [];
|
||||
if (person.classYear !== null && person.classLetter !== null) {
|
||||
parts.push(`${person.classYear}${person.classLetter}`);
|
||||
}
|
||||
|
||||
if (person.positionLabel !== null && person.positionLabel.length > 0) {
|
||||
parts.push(person.positionLabel);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(' · ') : '—';
|
||||
}
|
||||
|
||||
function roleLabels(roles: readonly string[]): string {
|
||||
return roles
|
||||
.map((role) => (role in ROLE_KEYS ? t(ROLE_KEYS[role as PersonRole]) : role))
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function cardMeta(card: PersonCard): string {
|
||||
const bits = [
|
||||
roleLabels(card.roles),
|
||||
card.female ? t('peopleFemale') : t('peopleMale'),
|
||||
String(card.age),
|
||||
placement(card),
|
||||
].filter((bit) => bit.length > 0 && bit !== '—');
|
||||
return bits.join(' · ');
|
||||
}
|
||||
|
||||
function section(title: string): HTMLElement {
|
||||
return el('div', { class: 'people__section' }, el('h4', { class: 'people__section-title', text: title }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Name and value in two columns, several columns per row. Fourteen skills as a bulleted list ran
|
||||
* the card past the fold while three quarters of its width sat empty.
|
||||
*/
|
||||
function appendPairs(
|
||||
parent: HTMLElement,
|
||||
title: string,
|
||||
rows: readonly { readonly label: string; readonly value: string }[],
|
||||
): void {
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const grid = el('dl', { class: 'people__pairs' });
|
||||
for (const row of rows) {
|
||||
grid.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__pair' },
|
||||
el('dt', { text: row.label }),
|
||||
el('dd', { text: row.value }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
parent.append(section(title), grid);
|
||||
}
|
||||
|
||||
function appendTags(parent: HTMLElement, title: string, values: readonly string[]): void {
|
||||
if (values.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tags = el('div', { class: 'people__tags' });
|
||||
for (const value of values) {
|
||||
tags.append(el('span', { class: 'people__tag', text: value }));
|
||||
}
|
||||
|
||||
parent.append(section(title), tags);
|
||||
}
|
||||
|
||||
function appendNeeds(
|
||||
parent: HTMLElement,
|
||||
title: string,
|
||||
rows: readonly { readonly label: string; readonly value: number }[],
|
||||
): void {
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const grid = el('div', { class: 'people__needs' });
|
||||
for (const row of rows) {
|
||||
const share = Math.max(0, Math.min(1, row.value));
|
||||
const fill = el('span', { class: 'people__need-fill' });
|
||||
fill.style.width = `${Math.round(share * 100)}%`;
|
||||
fill.classList.toggle('people__need-fill--low', share < 0.25);
|
||||
|
||||
grid.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__need' },
|
||||
el('span', { class: 'people__need-label', text: row.label }),
|
||||
el('span', { class: 'people__need-value', text: `${Math.round(share * 100)}%` }),
|
||||
el('span', { class: 'people__need-track' }, fill),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
parent.append(section(title), grid);
|
||||
}
|
||||
|
||||
function appendRelatives(
|
||||
parent: HTMLElement,
|
||||
title: string,
|
||||
relatives: readonly PersonRel[],
|
||||
open: (id: string) => void,
|
||||
): void {
|
||||
if (relatives.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = el('span', { class: 'people__rel-list' });
|
||||
for (const relative of relatives) {
|
||||
list.append(
|
||||
el('button', {
|
||||
class: 'people__link',
|
||||
type: 'button',
|
||||
text: relative.fullName,
|
||||
onClick: () => open(relative.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
parent.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__rel' },
|
||||
el('span', { class: 'people__rel-title', text: title }),
|
||||
list,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { PersonCard, PersonListItem, PersonRel, PersonRole } from '../net/api.ts';
|
||||
import { t, type MessageKey } from '../i18n/strings.ts';
|
||||
import { el } from './dom.ts';
|
||||
|
||||
const ROLE_KEYS: Record<PersonRole, MessageKey> = {
|
||||
student: 'peopleRoleStudent',
|
||||
staff: 'peopleRoleStaff',
|
||||
parent: 'peopleRoleParent',
|
||||
};
|
||||
|
||||
export function roleLabels(roles: readonly string[]): string {
|
||||
return roles
|
||||
.map((role) => (role in ROLE_KEYS ? t(ROLE_KEYS[role as PersonRole]) : role))
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
export function placement(person: Pick<PersonListItem, 'classYear' | 'classLetter' | 'positionLabel'>): string {
|
||||
const parts: string[] = [];
|
||||
if (person.classYear !== null && person.classLetter !== null) {
|
||||
parts.push(`${person.classYear}${person.classLetter}`);
|
||||
}
|
||||
|
||||
if (person.positionLabel !== null && person.positionLabel.length > 0) {
|
||||
parts.push(person.positionLabel);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(' · ') : '—';
|
||||
}
|
||||
|
||||
export function renderPersonCard(
|
||||
parent: HTMLElement,
|
||||
card: PersonCard,
|
||||
onRelative: (id: string) => void,
|
||||
): void {
|
||||
parent.append(
|
||||
el('h3', { class: 'people__card-name', text: card.fullName }),
|
||||
el('p', { class: 'people__card-meta', text: cardMeta(card) }),
|
||||
);
|
||||
appendPairs(parent, t('peopleBody'), card.body);
|
||||
appendPairs(parent, t('peopleSkills'), card.skills);
|
||||
appendTags(parent, t('peopleTraits'), card.traits.map((row) => row.label));
|
||||
appendNeeds(parent, t('peopleNeeds'), card.needs);
|
||||
|
||||
const family = section(t('peopleFamily'));
|
||||
appendRelatives(family, t('peopleParents'), card.family.parents, onRelative);
|
||||
appendRelatives(family, t('peopleChildren'), card.family.children, onRelative);
|
||||
appendRelatives(family, t('peopleSiblings'), card.family.siblings, onRelative);
|
||||
appendRelatives(family, t('peoplePartners'), card.family.partners, onRelative);
|
||||
if (family.childElementCount > 1) {
|
||||
parent.append(family);
|
||||
}
|
||||
}
|
||||
|
||||
function cardMeta(card: PersonCard): string {
|
||||
const bits = [
|
||||
roleLabels(card.roles),
|
||||
card.female ? t('peopleFemale') : t('peopleMale'),
|
||||
String(card.age),
|
||||
placement(card),
|
||||
].filter((bit) => bit.length > 0 && bit !== '—');
|
||||
return bits.join(' · ');
|
||||
}
|
||||
|
||||
function section(title: string): HTMLElement {
|
||||
return el('div', { class: 'people__section' }, el('h4', { class: 'people__section-title', text: title }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Name and value in two columns, several columns per row. Fourteen skills as a bulleted list ran
|
||||
* the card past the fold while three quarters of its width sat empty.
|
||||
*/
|
||||
function appendPairs(
|
||||
parent: HTMLElement,
|
||||
title: string,
|
||||
rows: readonly { readonly label: string; readonly value: string }[],
|
||||
): void {
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const grid = el('dl', { class: 'people__pairs' });
|
||||
for (const row of rows) {
|
||||
grid.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__pair' },
|
||||
el('dt', { text: row.label }),
|
||||
el('dd', { text: row.value }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
parent.append(section(title), grid);
|
||||
}
|
||||
|
||||
function appendTags(parent: HTMLElement, title: string, values: readonly string[]): void {
|
||||
if (values.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tags = el('div', { class: 'people__tags' });
|
||||
for (const value of values) {
|
||||
tags.append(el('span', { class: 'people__tag', text: value }));
|
||||
}
|
||||
|
||||
parent.append(section(title), tags);
|
||||
}
|
||||
|
||||
function appendNeeds(
|
||||
parent: HTMLElement,
|
||||
title: string,
|
||||
rows: readonly { readonly label: string; readonly value: number }[],
|
||||
): void {
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const grid = el('div', { class: 'people__needs' });
|
||||
for (const row of rows) {
|
||||
const share = Math.max(0, Math.min(1, row.value));
|
||||
const fill = el('span', { class: 'people__need-fill' });
|
||||
fill.style.width = `${Math.round(share * 100)}%`;
|
||||
fill.classList.toggle('people__need-fill--low', share < 0.25);
|
||||
|
||||
grid.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__need' },
|
||||
el('span', { class: 'people__need-label', text: row.label }),
|
||||
el('span', { class: 'people__need-value', text: `${Math.round(share * 100)}%` }),
|
||||
el('span', { class: 'people__need-track' }, fill),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
parent.append(section(title), grid);
|
||||
}
|
||||
|
||||
function appendRelatives(
|
||||
parent: HTMLElement,
|
||||
title: string,
|
||||
relatives: readonly PersonRel[],
|
||||
open: (id: string) => void,
|
||||
): void {
|
||||
if (relatives.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = el('span', { class: 'people__rel-list' });
|
||||
for (const relative of relatives) {
|
||||
list.append(
|
||||
el('button', {
|
||||
class: 'people__link',
|
||||
type: 'button',
|
||||
text: relative.fullName,
|
||||
onClick: () => open(relative.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
parent.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__rel' },
|
||||
el('span', { class: 'people__rel-title', text: title }),
|
||||
list,
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user