Enhance school creation and staffing management with native language support
- Updated protocol documentation to include `nativeLanguages` in `nameSets` and added `nativeLanguage` to school creation options. - Enhanced the UI for school creation to allow selection of native languages, improving user experience. - Revised API interfaces to accommodate new native language features, ensuring proper data handling. - Improved localization strings to support new native language functionalities in both English and Russian. - Updated tests to validate the new native language features and ensure robust functionality in staffing scenarios.
This commit is contained in:
@@ -42,10 +42,14 @@ const ru = {
|
||||
errorUnknownMod: 'Выбранный мод не найден.',
|
||||
errorInvalidCatalog: 'Не удалось загрузить выбранные моды.',
|
||||
errorUnknownNameSet: 'Выбранный набор имён не найден.',
|
||||
errorUnknownNativeLanguage: 'Выбранный родной язык не входит в этот набор имён.',
|
||||
catalogLoadFailed: 'Не удалось загрузить каталог модов.',
|
||||
|
||||
modsTitle: 'Моды',
|
||||
nameSetTitle: 'Набор имён',
|
||||
nativeLanguageTitle: 'Родной язык',
|
||||
nativeLanguageRandom: 'Случайный',
|
||||
randomNativeTitle: 'Выбрать язык наугад',
|
||||
coreModLocked: '{id} (всегда включён)',
|
||||
mapEditorTitle: 'Карта',
|
||||
editMap: 'Редактировать карту',
|
||||
@@ -135,6 +139,16 @@ const ru = {
|
||||
staffUncoveredEmpty: 'Все предметы существующих параллелей кем-то ведутся.',
|
||||
staffApplicants: 'Соискатели',
|
||||
staffApplicantsEmpty: 'Пул пуст.',
|
||||
staffApplicantsOpen: 'Открыть список',
|
||||
staffApplicantsCount: 'В пуле: {n}',
|
||||
staffApplicantsSearch: 'Имя',
|
||||
staffApplicantsSearchHint: 'Поиск по имени',
|
||||
staffApplicantsKind: 'Кто',
|
||||
staffApplicantsKindAll: 'Все',
|
||||
staffApplicantsKindParent: 'Родители',
|
||||
staffApplicantsKindOther: 'Со стороны',
|
||||
staffApplicantsNoMatches: 'Никто не подходит под фильтр.',
|
||||
staffApplicantsPickHint: 'Выберите соискателя в списке.',
|
||||
staffHired: 'Штат',
|
||||
staffHiredEmpty: 'Никого не наняли.',
|
||||
staffColName: 'ФИО',
|
||||
@@ -148,7 +162,7 @@ const ru = {
|
||||
staffAsk: '{hourly}/ч · {monthly}/мес',
|
||||
staffSubjectRange: '{label} ({min}–{max})',
|
||||
staffParent: 'родитель',
|
||||
staffPickHint: 'Выберите соискателя или работника в списке.',
|
||||
staffPickHint: 'Выберите сотрудника в списке или откройте соискателей.',
|
||||
staffHireTitle: 'Наём',
|
||||
staffHire: 'Нанять',
|
||||
staffPosition: 'Должность',
|
||||
@@ -240,10 +254,14 @@ const en: Messages = {
|
||||
errorUnknownMod: 'A selected mod is missing.',
|
||||
errorInvalidCatalog: 'The selected packs could not be loaded.',
|
||||
errorUnknownNameSet: 'The selected name set is not in the catalog.',
|
||||
errorUnknownNativeLanguage: 'The selected native language is not in that name set.',
|
||||
catalogLoadFailed: 'Could not load the mod catalog.',
|
||||
|
||||
modsTitle: 'Mods',
|
||||
nameSetTitle: 'Name set',
|
||||
nativeLanguageTitle: 'Native language',
|
||||
nativeLanguageRandom: 'Random',
|
||||
randomNativeTitle: 'Pick a language at random',
|
||||
coreModLocked: '{id} (always on)',
|
||||
mapEditorTitle: 'Map',
|
||||
editMap: 'Edit map',
|
||||
@@ -333,6 +351,16 @@ const en: Messages = {
|
||||
staffUncoveredEmpty: 'Every subject in the existing year-groups has a teacher.',
|
||||
staffApplicants: 'Applicants',
|
||||
staffApplicantsEmpty: 'The pool is empty.',
|
||||
staffApplicantsOpen: 'Open list',
|
||||
staffApplicantsCount: '{n} in the pool',
|
||||
staffApplicantsSearch: 'Name',
|
||||
staffApplicantsSearchHint: 'Search by name',
|
||||
staffApplicantsKind: 'Who',
|
||||
staffApplicantsKindAll: 'All',
|
||||
staffApplicantsKindParent: 'Parents',
|
||||
staffApplicantsKindOther: 'Outside',
|
||||
staffApplicantsNoMatches: 'Nobody matches the filter.',
|
||||
staffApplicantsPickHint: 'Select an applicant in the list.',
|
||||
staffHired: 'Staff',
|
||||
staffHiredEmpty: 'Nobody hired yet.',
|
||||
staffColName: 'Name',
|
||||
@@ -346,7 +374,7 @@ const en: Messages = {
|
||||
staffAsk: '{hourly}/h · {monthly}/mo',
|
||||
staffSubjectRange: '{label} ({min}–{max})',
|
||||
staffParent: 'parent',
|
||||
staffPickHint: 'Select an applicant or a staff member in the list.',
|
||||
staffPickHint: 'Select a staff member in the list, or open the applicants.',
|
||||
staffHireTitle: 'Hire',
|
||||
staffHire: 'Hire',
|
||||
staffPosition: 'Position',
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface CreateSchoolOptions {
|
||||
readonly modIds?: readonly string[];
|
||||
readonly map?: MapLayout;
|
||||
readonly nameSetId?: string;
|
||||
readonly nativeLanguage?: string;
|
||||
}
|
||||
|
||||
export async function createSchool(
|
||||
@@ -66,6 +67,7 @@ export async function createSchool(
|
||||
modIds: extras.modIds ?? [],
|
||||
map: extras.map ?? null,
|
||||
nameSetId: extras.nameSetId ?? null,
|
||||
nativeLanguage: extras.nativeLanguage ?? null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -81,6 +83,12 @@ export interface DefInfo {
|
||||
readonly pupilSlots?: number;
|
||||
}
|
||||
|
||||
export interface NameSetInfo {
|
||||
readonly defName: string;
|
||||
readonly label: string;
|
||||
readonly nativeLanguages: readonly DefInfo[];
|
||||
}
|
||||
|
||||
export interface RoomSlotInfo {
|
||||
readonly key: string;
|
||||
readonly thing: string;
|
||||
@@ -153,7 +161,7 @@ export interface CatalogResponse {
|
||||
readonly rooms: readonly RoomInfo[];
|
||||
readonly things: readonly DefInfo[];
|
||||
readonly defaultMap: MapLayout;
|
||||
readonly nameSets: readonly DefInfo[];
|
||||
readonly nameSets: readonly NameSetInfo[];
|
||||
readonly subjects: readonly SubjectInfo[];
|
||||
readonly dayFrame: DayFrameInfo | null;
|
||||
readonly holidays: readonly HolidayInfo[];
|
||||
@@ -324,6 +332,7 @@ export interface StaffingApplicant {
|
||||
readonly hourlyWageAsk: number;
|
||||
readonly monthlyBase: number;
|
||||
readonly skills: readonly LabeledStat[];
|
||||
readonly traits: readonly DefLabel[];
|
||||
}
|
||||
|
||||
export interface StaffMember {
|
||||
|
||||
@@ -792,8 +792,14 @@ body {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.staffing__summary {
|
||||
margin: 0 0 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.staffing__list-section .people__table-wrap {
|
||||
max-height: 220px;
|
||||
max-height: 320px;
|
||||
}
|
||||
|
||||
.timetable {
|
||||
@@ -1015,6 +1021,50 @@ body {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.dialog--applicants .screen__header,
|
||||
.dialog--applicants .staffing__error,
|
||||
.dialog--applicants .people__toolbar {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.dialog--applicants[open] {
|
||||
width: min(1280px, calc(100vw - 32px));
|
||||
height: min(840px, calc(100dvh - 48px));
|
||||
max-height: calc(100dvh - 48px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dialog--applicants .applicants__split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.7fr) minmax(280px, 0.9fr);
|
||||
gap: 16px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dialog--applicants .applicants__table {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dialog--applicants .applicants__card {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dialog--applicants .applicants__count {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.people__table td.applicants__cell--wrap {
|
||||
white-space: normal;
|
||||
max-width: 28em;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.dialog::backdrop {
|
||||
background: rgba(6, 9, 14, 0.7);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
import {
|
||||
fetchPerson,
|
||||
hireStaff,
|
||||
type PersonCard,
|
||||
type Staffing,
|
||||
type StaffingApplicant,
|
||||
} from '../net/api.ts';
|
||||
import { getLocale } from '../i18n/locale.ts';
|
||||
import { t, type MessageKey } from '../i18n/strings.ts';
|
||||
import { clear, el } from './dom.ts';
|
||||
import { Modal } from './modal.ts';
|
||||
import { formatPersonPlace, renderPersonCard } from './personCard.ts';
|
||||
import {
|
||||
actionError,
|
||||
fillSelect,
|
||||
formatMoney,
|
||||
formatSkill,
|
||||
formatTraits,
|
||||
parseOptionalInt,
|
||||
} from './staffingUi.ts';
|
||||
|
||||
const TEACHER = 'Teacher';
|
||||
|
||||
type ApplicantSort = 'name' | 'age' | 'ask';
|
||||
|
||||
const SORT_COLUMNS: readonly { sort: ApplicantSort; label: MessageKey }[] = [
|
||||
{ sort: 'name', label: 'staffColName' },
|
||||
{ sort: 'age', label: 'peopleColAge' },
|
||||
{ sort: 'ask', label: 'staffColAsk' },
|
||||
];
|
||||
|
||||
export interface ApplicantsDialogOptions {
|
||||
readonly schoolId: number;
|
||||
readonly staffing: Staffing;
|
||||
readonly locate: (id: string) => string;
|
||||
readonly selectedId?: string;
|
||||
readonly onChange: (staffing: Staffing, hiredId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-size applicant browser. The management pane only has room for a summary; comparing
|
||||
* skills and asks needs a table that can actually scroll, plus the person card beside it.
|
||||
*/
|
||||
export class ApplicantsDialog {
|
||||
private readonly error = el('p', { class: 'staffing__error' });
|
||||
private readonly title = el('h2', { class: 'screen__title' });
|
||||
private readonly count = el('p', { class: 'applicants__count' });
|
||||
private readonly searchLabel = el('span', { class: 'people__label' });
|
||||
private readonly search = el('input', { class: 'input people__input', type: 'search' });
|
||||
private readonly sexLabel = el('span', { class: 'people__label' });
|
||||
private readonly sexSelect = el('select', { class: 'input people__input' });
|
||||
private readonly kindLabel = el('span', { class: 'people__label' });
|
||||
private readonly kindSelect = el('select', { class: 'input people__input' });
|
||||
private readonly ageFromLabel = el('span', { class: 'people__label' });
|
||||
private readonly ageMinInput = el('input', { class: 'input input--count', type: 'number' });
|
||||
private readonly ageToLabel = el('span', { class: 'people__label' });
|
||||
private readonly ageMaxInput = el('input', { class: 'input input--count', type: 'number' });
|
||||
private readonly table = el('table', { class: 'people__table' });
|
||||
private readonly empty = el('p', { class: 'panel__empty' });
|
||||
private readonly card = el('aside', { class: 'panel__body people__card applicants__card' });
|
||||
private readonly positionSelect = el('select', { class: 'input people__input' });
|
||||
private readonly closeButton = el('button', { class: 'button button--primary', type: 'button' });
|
||||
|
||||
private modal: Modal<void> | null = null;
|
||||
private staffing: Staffing;
|
||||
private selectedId: string | null;
|
||||
private sort: ApplicantSort = 'name';
|
||||
private dir: 'asc' | 'desc' = 'asc';
|
||||
private cardToken = 0;
|
||||
private busy = false;
|
||||
private painted: PersonCard | null = null;
|
||||
|
||||
constructor(private readonly options: ApplicantsDialogOptions) {
|
||||
this.staffing = options.staffing;
|
||||
this.selectedId = options.selectedId ?? null;
|
||||
this.error.hidden = true;
|
||||
this.ageMinInput.min = '0';
|
||||
this.ageMaxInput.min = '0';
|
||||
this.search.addEventListener('input', () => this.paintList());
|
||||
this.sexSelect.addEventListener('change', () => this.paintList());
|
||||
this.kindSelect.addEventListener('change', () => this.paintList());
|
||||
this.ageMinInput.addEventListener('change', () => this.paintList());
|
||||
this.ageMaxInput.addEventListener('change', () => this.paintList());
|
||||
this.closeButton.addEventListener('click', () => this.modal?.close(undefined));
|
||||
}
|
||||
|
||||
open(): Promise<void> {
|
||||
const modal = new Modal<void>(undefined);
|
||||
this.modal = modal;
|
||||
modal.element.classList.add('dialog--screen', 'dialog--applicants');
|
||||
modal.element.append(
|
||||
el('header', { class: 'screen__header' }, this.title, el('div', { class: 'screen__actions' }, this.count)),
|
||||
this.error,
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__toolbar' },
|
||||
el('label', { class: 'people__field' }, this.searchLabel, this.search),
|
||||
el('label', { class: 'people__field' }, this.sexLabel, this.sexSelect),
|
||||
el('label', { class: 'people__field' }, this.kindLabel, this.kindSelect),
|
||||
el('label', { class: 'people__field' }, this.ageFromLabel, this.ageMinInput),
|
||||
el('label', { class: 'people__field' }, this.ageToLabel, this.ageMaxInput),
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'applicants__split' },
|
||||
el('div', { class: 'people__table-wrap applicants__table' }, this.table, this.empty),
|
||||
this.card,
|
||||
),
|
||||
el('div', { class: 'dialog__actions' }, this.closeButton),
|
||||
);
|
||||
this.localize();
|
||||
return modal.open(this.search).finally(() => {
|
||||
this.modal = null;
|
||||
});
|
||||
}
|
||||
|
||||
localize(): void {
|
||||
this.title.textContent = t('staffApplicants');
|
||||
this.searchLabel.textContent = t('staffApplicantsSearch');
|
||||
this.search.placeholder = t('staffApplicantsSearchHint');
|
||||
this.sexLabel.textContent = t('peopleSex');
|
||||
this.kindLabel.textContent = t('staffApplicantsKind');
|
||||
this.ageFromLabel.textContent = t('peopleAgeFrom');
|
||||
this.ageToLabel.textContent = t('peopleAgeTo');
|
||||
this.closeButton.textContent = t('done');
|
||||
this.empty.textContent = t('staffApplicantsEmpty');
|
||||
fillFixedSelect(this.sexSelect, [
|
||||
{ value: '', label: t('peopleSexAll') },
|
||||
{ value: 'female', label: t('peopleFemale') },
|
||||
{ value: 'male', label: t('peopleMale') },
|
||||
]);
|
||||
fillFixedSelect(this.kindSelect, [
|
||||
{ value: '', label: t('staffApplicantsKindAll') },
|
||||
{ value: 'parent', label: t('staffApplicantsKindParent') },
|
||||
{ value: 'other', label: t('staffApplicantsKindOther') },
|
||||
]);
|
||||
this.paintList();
|
||||
if (this.selectedId !== null) {
|
||||
void this.openCard(this.selectedId);
|
||||
} else {
|
||||
this.paintCard(null);
|
||||
}
|
||||
}
|
||||
|
||||
setStaffing(staffing: Staffing): void {
|
||||
this.staffing = staffing;
|
||||
if (this.selectedId !== null && !staffing.applicants.some((row) => row.id === this.selectedId)) {
|
||||
this.selectedId = null;
|
||||
this.paintCard(null);
|
||||
}
|
||||
|
||||
this.paintList();
|
||||
if (this.selectedId !== null) {
|
||||
this.appendHireIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
select(id: string): void {
|
||||
this.selectedId = id;
|
||||
this.clearError();
|
||||
this.paintList();
|
||||
void this.openCard(id);
|
||||
}
|
||||
|
||||
private filtered(): StaffingApplicant[] {
|
||||
const query = this.search.value.trim().toLocaleLowerCase(getLocale());
|
||||
const sex = this.sexSelect.value;
|
||||
const kind = this.kindSelect.value;
|
||||
const ageMin = parseOptionalInt(this.ageMinInput.value);
|
||||
const ageMax = parseOptionalInt(this.ageMaxInput.value);
|
||||
const rows = this.staffing.applicants.filter((applicant) => {
|
||||
if (query.length > 0 && !applicant.fullName.toLocaleLowerCase(getLocale()).includes(query)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (sex === 'female' && !applicant.female) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (sex === 'male' && applicant.female) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (kind === 'parent' && !applicant.isParent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (kind === 'other' && applicant.isParent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ageMin !== undefined && applicant.age < ageMin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ageMax !== undefined && applicant.age > ageMax) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const factor = this.dir === 'asc' ? 1 : -1;
|
||||
rows.sort((left, right) => {
|
||||
const compared =
|
||||
this.sort === 'age'
|
||||
? left.age - right.age
|
||||
: this.sort === 'ask'
|
||||
? left.hourlyWageAsk - right.hourlyWageAsk
|
||||
: left.fullName.localeCompare(right.fullName, getLocale());
|
||||
return compared === 0 ? left.id.localeCompare(right.id, getLocale()) : compared * factor;
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
private paintList(): void {
|
||||
const total = this.staffing.applicants.length;
|
||||
this.count.textContent = t('staffApplicantsCount', { n: total });
|
||||
const rows = this.filtered();
|
||||
const hasRows = rows.length > 0;
|
||||
this.empty.hidden = hasRows;
|
||||
this.table.hidden = !hasRows;
|
||||
this.empty.textContent = total === 0 ? t('staffApplicantsEmpty') : t('staffApplicantsNoMatches');
|
||||
clear(this.table);
|
||||
if (!hasRows) {
|
||||
return;
|
||||
}
|
||||
|
||||
const head = el('tr');
|
||||
for (const column of SORT_COLUMNS) {
|
||||
const button = el('button', {
|
||||
class: 'people__sort',
|
||||
type: 'button',
|
||||
text: t(column.label),
|
||||
onClick: () => this.onSort(column.sort),
|
||||
});
|
||||
button.setAttribute(
|
||||
'aria-sort',
|
||||
this.sort === column.sort ? (this.dir === 'asc' ? 'ascending' : 'descending') : 'none',
|
||||
);
|
||||
head.append(el('th', {}, button));
|
||||
}
|
||||
|
||||
head.append(
|
||||
el('th', { text: t('staffColPay') }),
|
||||
el('th', { text: t('peopleColSex') }),
|
||||
el('th', { text: t('staffColSkills') }),
|
||||
el('th', { text: t('peopleTraits') }),
|
||||
);
|
||||
|
||||
const body = el('tbody');
|
||||
for (const applicant of rows) {
|
||||
const selected = applicant.id === this.selectedId;
|
||||
const row = el('tr', {
|
||||
class: selected ? 'people__row people__row--active' : 'people__row',
|
||||
dataset: { personId: applicant.id },
|
||||
onClick: () => this.select(applicant.id),
|
||||
});
|
||||
const name = el('td', { text: applicant.fullName });
|
||||
if (applicant.isParent) {
|
||||
name.append(el('span', { class: 'staffing__badge', text: t('staffParent') }));
|
||||
}
|
||||
|
||||
row.append(
|
||||
name,
|
||||
el('td', { text: String(applicant.age) }),
|
||||
el('td', { text: formatMoney(applicant.hourlyWageAsk) }),
|
||||
el('td', { text: formatMoney(applicant.monthlyBase) }),
|
||||
el('td', { text: applicant.female ? t('peopleFemale') : t('peopleMale') }),
|
||||
el('td', {
|
||||
class: 'applicants__cell--wrap',
|
||||
text: applicant.skills.map(formatSkill).join(' · ') || '—',
|
||||
}),
|
||||
el('td', { class: 'applicants__cell--wrap', text: formatTraits(applicant.traits) }),
|
||||
);
|
||||
body.append(row);
|
||||
}
|
||||
|
||||
this.table.append(el('thead', {}, head), body);
|
||||
}
|
||||
|
||||
private onSort(sort: ApplicantSort): void {
|
||||
if (this.sort === sort) {
|
||||
this.dir = this.dir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.sort = sort;
|
||||
this.dir = 'asc';
|
||||
}
|
||||
|
||||
this.paintList();
|
||||
}
|
||||
|
||||
private async openCard(personId: string): Promise<void> {
|
||||
const token = ++this.cardToken;
|
||||
try {
|
||||
const card = await fetchPerson(this.options.schoolId, personId, 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);
|
||||
this.painted = card;
|
||||
if (card === null) {
|
||||
this.card.append(el('p', { class: 'panel__empty', text: t('staffApplicantsPickHint') }));
|
||||
return;
|
||||
}
|
||||
|
||||
renderPersonCard(this.card, card, (id) => this.openRelative(id), this.placeOf(card.id));
|
||||
this.appendHireIfNeeded();
|
||||
}
|
||||
|
||||
private appendHireIfNeeded(): void {
|
||||
const applicant = this.staffing.applicants.find((row) => row.id === this.painted?.id);
|
||||
if (applicant === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
fillSelect(
|
||||
this.positionSelect,
|
||||
this.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 openRelative(personId: string): void {
|
||||
if (this.staffing.applicants.some((row) => row.id === personId)) {
|
||||
this.select(personId);
|
||||
}
|
||||
}
|
||||
|
||||
private placeOf(id: string): string {
|
||||
return this.options.locate(id) || formatPersonPlace('away');
|
||||
}
|
||||
|
||||
private async hire(personId: string): Promise<void> {
|
||||
if (this.busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.busy = true;
|
||||
this.clearError();
|
||||
try {
|
||||
const staffing = await hireStaff(this.options.schoolId, personId, this.positionSelect.value, getLocale());
|
||||
this.staffing = staffing;
|
||||
this.selectedId = null;
|
||||
this.options.onChange(staffing, personId);
|
||||
this.paintList();
|
||||
this.paintCard(null);
|
||||
} catch (error) {
|
||||
this.showError(actionError(error));
|
||||
} finally {
|
||||
this.busy = false;
|
||||
if (this.painted !== null && this.selectedId === this.painted.id) {
|
||||
this.paintCard(this.painted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private showError(message: string): void {
|
||||
this.error.hidden = false;
|
||||
this.error.textContent = message;
|
||||
}
|
||||
|
||||
private clearError(): void {
|
||||
this.error.hidden = true;
|
||||
this.error.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
function fillFixedSelect(select: HTMLSelectElement, items: readonly { value: string; label: 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 ([...select.options].some((option) => option.value === current)) {
|
||||
select.value = current;
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
let catalog: CatalogResponse | null = null;
|
||||
let currentMap: MapLayout | null = null;
|
||||
let nameSetId: string | null = null;
|
||||
let nativeLanguageId: string | null = null;
|
||||
|
||||
const modsField = el('div', { class: 'field' });
|
||||
const modsLabel = el('span', { class: 'field__label' });
|
||||
@@ -44,6 +45,23 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
const nameSetField = el('label', { class: 'field' }, nameSetLabel, nameSetSelect);
|
||||
nameSetSelect.addEventListener('change', () => {
|
||||
nameSetId = nameSetSelect.value === '' ? null : nameSetSelect.value;
|
||||
nativeLanguageId = null;
|
||||
if (catalog !== null) {
|
||||
paintNativeLanguages(catalog);
|
||||
}
|
||||
});
|
||||
|
||||
const nativeLabel = el('span', { class: 'field__label' });
|
||||
const nativeSelect = el('select', { class: 'input' });
|
||||
const nativeRandomButton = el('button', { class: 'button', type: 'button' });
|
||||
const nativeField = el(
|
||||
'div',
|
||||
{ class: 'field' },
|
||||
nativeLabel,
|
||||
el('div', { class: 'field__row' }, nativeSelect, nativeRandomButton),
|
||||
);
|
||||
nativeSelect.addEventListener('change', () => {
|
||||
nativeLanguageId = nativeSelect.value === '' ? null : nativeSelect.value;
|
||||
});
|
||||
|
||||
const mapLabel = el('span', { class: 'field__label' });
|
||||
@@ -87,6 +105,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
el('div', { class: 'field' }, startLabel, el('div', { class: 'field__row' }, dateInput, timeInput)),
|
||||
modsField,
|
||||
nameSetField,
|
||||
nativeField,
|
||||
mapField,
|
||||
error,
|
||||
el('div', { class: 'dialog__actions' }, cancelButton, submitButton),
|
||||
@@ -99,6 +118,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
const waiting = value || catalog === null;
|
||||
submitButton.toggleAttribute('disabled', waiting);
|
||||
randomButton.toggleAttribute('disabled', value);
|
||||
nativeRandomButton.toggleAttribute('disabled', value || languagesOf(catalog, nameSetId).length <= 1);
|
||||
editMapButton.toggleAttribute('disabled', waiting);
|
||||
};
|
||||
|
||||
@@ -123,7 +143,11 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
currentMap = structuredClone(next.defaultMap);
|
||||
const stillThere = next.nameSets.some((set) => set.defName === nameSetId);
|
||||
nameSetId = stillThere ? nameSetId : (next.nameSets[0]?.defName ?? null);
|
||||
if (!languagesOf(next, nameSetId).some((language) => language.defName === nativeLanguageId)) {
|
||||
nativeLanguageId = null;
|
||||
}
|
||||
paintNameSets(next);
|
||||
paintNativeLanguages(next);
|
||||
paintMapHint();
|
||||
setBusy(busy);
|
||||
};
|
||||
@@ -140,6 +164,24 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
nameSetSelect.disabled = next.nameSets.length <= 1;
|
||||
};
|
||||
|
||||
const paintNativeLanguages = (next: CatalogResponse): void => {
|
||||
const languages = languagesOf(next, nameSetId);
|
||||
nativeSelect.replaceChildren();
|
||||
const random = el('option', { text: t('nativeLanguageRandom') });
|
||||
random.value = '';
|
||||
nativeSelect.append(random);
|
||||
for (const language of languages) {
|
||||
const option = el('option', { text: language.label });
|
||||
option.value = language.defName;
|
||||
nativeSelect.append(option);
|
||||
}
|
||||
|
||||
nativeSelect.value = nativeLanguageId ?? '';
|
||||
nativeSelect.disabled = languages.length <= 1;
|
||||
nativeRandomButton.toggleAttribute('disabled', busy || languages.length <= 1);
|
||||
nativeField.hidden = languages.length === 0;
|
||||
};
|
||||
|
||||
const reloadCatalog = async (): Promise<void> => {
|
||||
try {
|
||||
applyCatalog(await fetchCatalog(getLocale(), [...extraModIds]));
|
||||
@@ -193,6 +235,9 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
title.textContent = t('newSchool');
|
||||
modsLabel.textContent = t('modsTitle');
|
||||
nameSetLabel.textContent = t('nameSetTitle');
|
||||
nativeLabel.textContent = t('nativeLanguageTitle');
|
||||
nativeRandomButton.textContent = t('randomName');
|
||||
nativeRandomButton.title = t('randomNativeTitle');
|
||||
nameLabel.textContent = t('schoolName');
|
||||
startLabel.textContent = t('gameStart');
|
||||
mapLabel.textContent = t('mapEditorTitle');
|
||||
@@ -205,6 +250,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
paintMapHint();
|
||||
if (catalog !== null) {
|
||||
paintNameSets(catalog);
|
||||
paintNativeLanguages(catalog);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -222,6 +268,20 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
});
|
||||
});
|
||||
|
||||
nativeRandomButton.addEventListener('click', () => {
|
||||
if (busy || catalog === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const languages = languagesOf(catalog, nameSetId);
|
||||
if (languages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
nativeLanguageId = languages[Math.floor(Math.random() * languages.length)]?.defName ?? null;
|
||||
nativeSelect.value = nativeLanguageId ?? '';
|
||||
});
|
||||
|
||||
randomButton.addEventListener('click', () => {
|
||||
if (busy) {
|
||||
return;
|
||||
@@ -256,6 +316,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
modIds: [...extraModIds],
|
||||
map: currentMap,
|
||||
nameSetId: nameSetId ?? undefined,
|
||||
nativeLanguage: nativeLanguageId ?? undefined,
|
||||
})
|
||||
.then((school) => modal.close(school))
|
||||
.catch((reason: unknown) => {
|
||||
@@ -270,6 +331,14 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
return modal.open(nameInput);
|
||||
}
|
||||
|
||||
function languagesOf(catalog: CatalogResponse | null, nameSetId: string | null): readonly { readonly defName: string; readonly label: string }[] {
|
||||
if (catalog === null || nameSetId === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return catalog.nameSets.find((set) => set.defName === nameSetId)?.nativeLanguages ?? [];
|
||||
}
|
||||
|
||||
function mapsEqual(left: MapLayout, right: MapLayout): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
@@ -294,6 +363,8 @@ function describe(reason: unknown): string {
|
||||
return t('errorInvalidCatalog');
|
||||
case 'unknown-name-set':
|
||||
return t('errorUnknownNameSet');
|
||||
case 'unknown-native-language':
|
||||
return t('errorUnknownNativeLanguage');
|
||||
default:
|
||||
return reason.message;
|
||||
}
|
||||
|
||||
@@ -1,32 +1,27 @@
|
||||
import {
|
||||
ApiError,
|
||||
assignSubject,
|
||||
fetchPerson,
|
||||
fetchStaffing,
|
||||
fetchTimetable,
|
||||
hireStaff,
|
||||
unassignSubject,
|
||||
type PersonCard,
|
||||
type Staffing,
|
||||
type StaffingApplicant,
|
||||
type StaffMember,
|
||||
type Timetable,
|
||||
} from '../net/api.ts';
|
||||
import { getLocale, intlTag } from '../i18n/locale.ts';
|
||||
import { getLocale } from '../i18n/locale.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { ApplicantsDialog } from './applicantsDialog.ts';
|
||||
import { clear, el } from './dom.ts';
|
||||
import { formatPersonPlace, renderPersonCard } from './personCard.ts';
|
||||
import { actionError, fillSelect, formatHours, formatMoney } from './staffingUi.ts';
|
||||
import { personTimetableQuery, TimetableGrid } from './timetableGrid.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.
|
||||
* Left: money, coverage, a door into the applicant window, staff. Right: the person card plus
|
||||
* subject actions. HTTP only — the clock stays on the bar above.
|
||||
*/
|
||||
export class ManagementPanel {
|
||||
readonly listElement: HTMLElement;
|
||||
@@ -38,8 +33,8 @@ export class ManagementPanel {
|
||||
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 applicantsSummary = el('p', { class: 'staffing__summary' });
|
||||
private readonly applicantsButton = el('button', { class: 'button', type: 'button' });
|
||||
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' });
|
||||
@@ -56,22 +51,23 @@ export class ManagementPanel {
|
||||
private readonly personGrid = new TimetableGrid({ editable: false, showClass: true });
|
||||
private readonly personTimetableTitle = el('h4', { class: 'people__section-title' });
|
||||
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 timetable: Timetable | null = null;
|
||||
private classId: string | null = null;
|
||||
private selection: Selection | null = null;
|
||||
private selectedId: string | null = null;
|
||||
private loadToken = 0;
|
||||
private cardToken = 0;
|
||||
private busy = false;
|
||||
private locate: ((id: string) => string) | null = null;
|
||||
private painted: PersonCard | null = null;
|
||||
private poolDialog: ApplicantsDialog | null = null;
|
||||
|
||||
constructor() {
|
||||
this.error.hidden = true;
|
||||
this.applicantsButton.addEventListener('click', () => void this.openApplicants());
|
||||
this.listElement = el(
|
||||
'div',
|
||||
{ class: 'panel__body staffing' },
|
||||
@@ -80,9 +76,10 @@ export class ManagementPanel {
|
||||
el('div', { class: 'panel__section' }, this.uncoveredTitle, this.uncoveredEmpty, this.uncovered),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'panel__section staffing__list-section' },
|
||||
{ class: 'panel__section' },
|
||||
this.applicantsTitle,
|
||||
el('div', { class: 'people__table-wrap' }, this.applicantsTable, this.applicantsEmpty),
|
||||
this.applicantsSummary,
|
||||
this.applicantsButton,
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
@@ -109,18 +106,19 @@ export class ManagementPanel {
|
||||
localize(): void {
|
||||
this.uncoveredTitle.textContent = t('staffUncovered');
|
||||
this.applicantsTitle.textContent = t('staffApplicants');
|
||||
this.applicantsButton.textContent = t('staffApplicantsOpen');
|
||||
this.staffTitle.textContent = t('staffHired');
|
||||
this.timetableTitle.textContent = t('timetableTitle');
|
||||
this.classLabel.textContent = t('timetableClass');
|
||||
this.uncoveredEmpty.textContent = t('staffUncoveredEmpty');
|
||||
this.applicantsEmpty.textContent = t('staffApplicantsEmpty');
|
||||
this.staffEmpty.textContent = t('staffHiredEmpty');
|
||||
this.timetableGrid.localize();
|
||||
this.personGrid.localize();
|
||||
this.personTimetableTitle.textContent = t('timetableTitle');
|
||||
this.paint();
|
||||
if (this.selection !== null) {
|
||||
void this.openCard(this.selection);
|
||||
this.poolDialog?.localize();
|
||||
if (this.selectedId !== null) {
|
||||
void this.openCard(this.selectedId);
|
||||
} else {
|
||||
this.paintCard(null);
|
||||
}
|
||||
@@ -130,7 +128,7 @@ export class ManagementPanel {
|
||||
const switched = this.schoolId !== schoolId;
|
||||
this.schoolId = schoolId;
|
||||
if (switched) {
|
||||
this.selection = null;
|
||||
this.selectedId = null;
|
||||
this.staffing = null;
|
||||
this.timetable = null;
|
||||
this.classId = null;
|
||||
@@ -167,9 +165,10 @@ export class ManagementPanel {
|
||||
this.syncClass(timetable);
|
||||
this.syncSelection();
|
||||
this.paint();
|
||||
this.poolDialog?.setStaffing(staffing);
|
||||
this.timetableGrid.setTable(timetable, this.classId);
|
||||
if (this.selection !== null) {
|
||||
void this.openCard(this.selection);
|
||||
if (this.selectedId !== null) {
|
||||
void this.openCard(this.selectedId);
|
||||
} else {
|
||||
this.paintCard(null);
|
||||
}
|
||||
@@ -207,21 +206,16 @@ export class ManagementPanel {
|
||||
|
||||
private syncSelection(): void {
|
||||
const staffing = this.staffing;
|
||||
const selected = this.selection;
|
||||
const selected = this.selectedId;
|
||||
if (staffing === null || selected === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected.kind === 'applicant' && staffing.applicants.some((row) => row.id === selected.id)) {
|
||||
if (staffing.staff.some((row) => row.id === selected)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (staffing.staff.some((row) => row.id === selected.id)) {
|
||||
this.selection = { kind: 'staff', id: selected.id };
|
||||
return;
|
||||
}
|
||||
|
||||
this.selection = null;
|
||||
this.selectedId = null;
|
||||
}
|
||||
|
||||
private paint(): void {
|
||||
@@ -274,55 +268,10 @@ export class ManagementPanel {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
const count = staffing?.applicants.length ?? 0;
|
||||
this.applicantsSummary.textContent =
|
||||
count === 0 ? t('staffApplicantsEmpty') : t('staffApplicantsCount', { n: count });
|
||||
this.applicantsButton.disabled = count === 0;
|
||||
}
|
||||
|
||||
private paintStaff(staffing: Staffing | null): void {
|
||||
@@ -343,9 +292,12 @@ export class ManagementPanel {
|
||||
'tr',
|
||||
{},
|
||||
el('th', { text: t('staffColName') }),
|
||||
el('th', { text: t('peopleColAge') }),
|
||||
el('th', { text: t('peopleColSex') }),
|
||||
el('th', { text: t('staffColPosition') }),
|
||||
el('th', { text: t('staffColSubjects') }),
|
||||
el('th', { text: t('staffColHours') }),
|
||||
el('th', { text: t('staffColAsk') }),
|
||||
el('th', { text: t('staffColPay') }),
|
||||
),
|
||||
),
|
||||
@@ -353,11 +305,11 @@ export class ManagementPanel {
|
||||
);
|
||||
|
||||
for (const member of rows) {
|
||||
const selected = this.selection?.kind === 'staff' && this.selection.id === member.id;
|
||||
const selected = this.selectedId === 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 }),
|
||||
onClick: () => void this.select(member.id),
|
||||
});
|
||||
const name = el('td', { text: member.fullName });
|
||||
if (member.isParent) {
|
||||
@@ -366,23 +318,62 @@ export class ManagementPanel {
|
||||
|
||||
row.append(
|
||||
name,
|
||||
el('td', { text: String(member.age) }),
|
||||
el('td', { text: member.female ? t('peopleFemale') : t('peopleMale') }),
|
||||
el('td', { text: member.positionLabel }),
|
||||
el('td', { text: member.subjects.map((subject) => subject.label).join(', ') || '—' }),
|
||||
el('td', { class: 'applicants__cell--wrap', text: member.subjects.map((subject) => subject.label).join(', ') || '—' }),
|
||||
el('td', { text: t('staffHours', { n: formatHours(member.weeklyHours) }) }),
|
||||
el('td', { text: formatMoney(member.hourlyWageAsk) }),
|
||||
el('td', { text: formatMoney(member.monthlyPay) }),
|
||||
);
|
||||
body.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
private async select(selection: Selection): Promise<void> {
|
||||
this.selection = selection;
|
||||
private async select(personId: string): Promise<void> {
|
||||
this.selectedId = personId;
|
||||
this.clearError();
|
||||
this.paint();
|
||||
await this.openCard(selection);
|
||||
await this.openCard(personId);
|
||||
}
|
||||
|
||||
private async openCard(selection: Selection): Promise<void> {
|
||||
private async openApplicants(selectedId?: string): Promise<void> {
|
||||
const schoolId = this.schoolId;
|
||||
const staffing = this.staffing;
|
||||
if (schoolId === null || staffing === null || staffing.applicants.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.poolDialog !== null) {
|
||||
if (selectedId !== undefined) {
|
||||
this.poolDialog.select(selectedId);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const dialog = new ApplicantsDialog({
|
||||
schoolId,
|
||||
staffing,
|
||||
locate: (id) => this.placeOf(id),
|
||||
selectedId,
|
||||
onChange: (next, hiredId) => {
|
||||
this.staffing = next;
|
||||
this.selectedId = hiredId;
|
||||
this.paint();
|
||||
void this.refreshTimetable();
|
||||
void this.openCard(hiredId);
|
||||
},
|
||||
});
|
||||
this.poolDialog = dialog;
|
||||
try {
|
||||
await dialog.open();
|
||||
} finally {
|
||||
this.poolDialog = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async openCard(personId: string): Promise<void> {
|
||||
const schoolId = this.schoolId;
|
||||
if (schoolId === null) {
|
||||
return;
|
||||
@@ -390,7 +381,7 @@ export class ManagementPanel {
|
||||
|
||||
const token = ++this.cardToken;
|
||||
try {
|
||||
const card = await fetchPerson(schoolId, selection.id, getLocale());
|
||||
const card = await fetchPerson(schoolId, personId, getLocale());
|
||||
if (token !== this.cardToken) {
|
||||
return;
|
||||
}
|
||||
@@ -462,16 +453,16 @@ export class ManagementPanel {
|
||||
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 });
|
||||
await this.select(personId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (staffing !== null && staffing.applicants.some((row) => row.id === personId)) {
|
||||
await this.select({ kind: 'applicant', id: personId });
|
||||
await this.openApplicants(personId);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.select({ kind: 'staff', id: personId });
|
||||
await this.select(personId);
|
||||
}
|
||||
|
||||
private appendActions(personId: string): void {
|
||||
@@ -480,45 +471,12 @@ export class ManagementPanel {
|
||||
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) {
|
||||
@@ -574,30 +532,6 @@ export class ManagementPanel {
|
||||
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 };
|
||||
await this.refreshTimetable();
|
||||
} 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;
|
||||
@@ -609,15 +543,16 @@ export class ManagementPanel {
|
||||
this.clearError();
|
||||
try {
|
||||
this.staffing = await assignSubject(schoolId, personId, subject, getLocale());
|
||||
this.selection = { kind: 'staff', id: personId };
|
||||
this.selectedId = personId;
|
||||
this.poolDialog?.setStaffing(this.staffing);
|
||||
await this.refreshTimetable();
|
||||
} catch (error) {
|
||||
this.showError(actionError(error));
|
||||
} finally {
|
||||
this.busy = false;
|
||||
this.paint();
|
||||
if (this.selection !== null) {
|
||||
await this.openCard(this.selection);
|
||||
if (this.selectedId !== null) {
|
||||
await this.openCard(this.selectedId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -632,15 +567,16 @@ export class ManagementPanel {
|
||||
this.clearError();
|
||||
try {
|
||||
this.staffing = await unassignSubject(schoolId, personId, subject, getLocale());
|
||||
this.selection = { kind: 'staff', id: personId };
|
||||
this.selectedId = personId;
|
||||
this.poolDialog?.setStaffing(this.staffing);
|
||||
await this.refreshTimetable();
|
||||
} catch (error) {
|
||||
this.showError(actionError(error));
|
||||
} finally {
|
||||
this.busy = false;
|
||||
this.paint();
|
||||
if (this.selection !== null) {
|
||||
await this.openCard(this.selection);
|
||||
if (this.selectedId !== null) {
|
||||
await this.openCard(this.selectedId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -670,63 +606,3 @@ export class ManagementPanel {
|
||||
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 formatHours(value: number): string {
|
||||
return new Intl.NumberFormat(intlTag(), { maximumFractionDigits: 1 }).format(value);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ApiError, type DefLabel } from '../net/api.ts';
|
||||
import { intlTag } from '../i18n/locale.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { el } from './dom.ts';
|
||||
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatHours(value: number): string {
|
||||
return new Intl.NumberFormat(intlTag(), { maximumFractionDigits: 1 }).format(value);
|
||||
}
|
||||
|
||||
export function formatMoney(value: number): string {
|
||||
return new Intl.NumberFormat(intlTag(), { maximumFractionDigits: 2 }).format(value);
|
||||
}
|
||||
|
||||
export function formatSkill(skill: { readonly label: string; readonly value: string }): string {
|
||||
return `${skill.label} ${skill.value}`;
|
||||
}
|
||||
|
||||
export function formatTraits(traits: readonly DefLabel[]): string {
|
||||
return traits.map((trait) => trait.label).join(', ') || '—';
|
||||
}
|
||||
|
||||
export 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');
|
||||
}
|
||||
}
|
||||
|
||||
export function parseOptionalInt(value: string): number | undefined {
|
||||
if (value === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) ? parsed : undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user