diff --git a/docs/phases/37-school-rules.md b/docs/phases/37-school-rules.md index 8473c0d..2223f7e 100644 --- a/docs/phases/37-school-rules.md +++ b/docs/phases/37-school-rules.md @@ -12,24 +12,24 @@ ## Задачи -- [ ] В «Управлении» вкладка **«Правила»** рядом со штатом, не новый верхний режим -- [ ] Четыре комбобокса: форма учеников, цвет учеников, форма сотрудников, цвет сотрудников. +- [x] В «Управлении» вкладка **«Правила»** рядом со штатом, не новый верхний режим +- [x] Четыре комбобокса: форма учеников, цвет учеников, форма сотрудников, цвет сотрудников. Значения — как в [`../design/inventory.md`](../design/inventory.md) -- [ ] Сохранение пишет завтрашнее правило; на экране видно, что действует сегодня и что +- [x] Сохранение пишет завтрашнее правило; на экране видно, что действует сегодня и что вступит завтра -- [ ] HTTP: чтение текущих+отложенных, запись отложенных. Отказ по неизвестному коду правила — +- [x] HTTP: чтение текущих+отложенных, запись отложенных. Отказ по неизвестному коду правила — `400`, не тишина -- [ ] На следующее рабочее утро отложенное становится текущим; фаза 35 уже одевает от него -- [ ] Строки через `t(...)`, обе локали -- [ ] Vitest: смена комбобокса не зовёт API до подтверждения; ошибка API видна текстом +- [x] На следующее рабочее утро отложенное становится текущим; фаза 35 уже одевает от него +- [x] Строки через `t(...)`, обе локали +- [x] Vitest: смена комбобокса не зовёт API до подтверждения; ошибка API видна текстом ## Тесты, без которых фаза не закрыта -- [ ] GET правил на новой школе — дефолт обычная / без яркого на обе роли -- [ ] POST «строгая» сегодня не меняет одежду до следующего рабочего утра -- [ ] После утра на теле учеников нет яркого, если отложили «без яркого» -- [ ] Неизвестный код политики — `400` -- [ ] Вкладка не ломает наём и сетку расписания +- [x] GET правил на новой школе — дефолт обычная / без яркого на обе роли +- [x] POST «строгая» сегодня не меняет одежду до следующего рабочего утра +- [x] После утра на теле учеников нет яркого, если отложили «без яркого» +- [x] Неизвестный код политики — `400` +- [x] Вкладка не ломает наём и сетку расписания ## Критерий готовности diff --git a/docs/phases/README.md b/docs/phases/README.md index e933880..164da76 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -149,7 +149,7 @@ | [34. Износ и дом](34-wear-home.md) | ✅ | Прочность, утренняя замена, из ветхого не выходят | | [35. Уместность](35-appropriateness.md) | ✅ | Правила, переодевание действием, физкультура | | [36. Вкладки карточки](36-person-card-tabs.md) | ✅ | Одежда, ноша, сейчас + история за сегодня | -| [37. Правила школы](37-school-rules.md) | 🔄 | Вкладка в «Управлении», четыре комбобокса, с завтра | +| [37. Правила школы](37-school-rules.md) | ✅ | Вкладка в «Управлении», четыре комбобокса, с завтра | 34 стоит на 31; 35 — на 32–34; 36 — на 31 и может идти параллельно с 35 в части вкладок одежды, история — после 35; 37 стоит на 35 и 36. diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 09c0bf5..b5e5d6f 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -270,6 +270,31 @@ const ru = { staffErrorAssigned: 'Этот предмет уже назначен.', staffErrorSubject: 'Такого предмета нет.', + manageTabStaff: 'Штат', + manageTabRules: 'Правила', + rulesCurrentTitle: 'Сегодня', + rulesPendingTitle: 'С завтра', + rulesPendingHint: 'Изменения вступают в силу на следующее рабочее утро.', + rulesStudentsRole: 'Ученики', + rulesStaffRole: 'Сотрудники', + rulesStudentsForm: 'Форма учеников', + rulesStudentsColor: 'Цвет учеников', + rulesStaffForm: 'Форма сотрудников', + rulesStaffColor: 'Цвет сотрудников', + rulesRoleSummary: '{role}: {form}, {color}', + rulesApply: 'Применить', + rulesLoadFailed: 'Не удалось загрузить правила одежды.', + rulesSaveFailed: 'Не удалось сохранить правила.', + rulesPickHint: 'Правила действуют на всю школу.', + dressFormRegular: 'обычная', + dressFormShort: 'короткая', + dressFormStrict: 'строгая', + dressColorFree: 'свободно', + dressColorNoBright: 'без яркого', + dressColorWhiteTopBlackBottom: 'белый верх, чёрный низ', + dressErrorUnknownForm: 'Такой формы нет.', + dressErrorUnknownColor: 'Такой цветовой политики нет.', + mapOccupancy: '{name} ({activity})', mapHeadcount: '{name} ({count})', mapHeadcountActivity: '{name} ({count} · {activity})', @@ -574,6 +599,31 @@ const en: Messages = { staffErrorAssigned: 'That subject is already assigned.', staffErrorSubject: 'That subject is not in the catalog.', + manageTabStaff: 'Staff', + manageTabRules: 'Rules', + rulesCurrentTitle: 'Today', + rulesPendingTitle: 'From tomorrow', + rulesPendingHint: 'Changes take effect on the next work morning.', + rulesStudentsRole: 'Students', + rulesStaffRole: 'Staff', + rulesStudentsForm: 'Student form', + rulesStudentsColor: 'Student colours', + rulesStaffForm: 'Staff form', + rulesStaffColor: 'Staff colours', + rulesRoleSummary: '{role}: {form}, {color}', + rulesApply: 'Apply', + rulesLoadFailed: 'Could not load dress rules.', + rulesSaveFailed: 'Could not save the rules.', + rulesPickHint: 'Rules apply to the whole school.', + dressFormRegular: 'regular', + dressFormShort: 'short', + dressFormStrict: 'strict', + dressColorFree: 'free colours', + dressColorNoBright: 'no bright colours', + dressColorWhiteTopBlackBottom: 'white top, black bottom', + dressErrorUnknownForm: 'That form policy is not in the catalog.', + dressErrorUnknownColor: 'That colour policy is not in the catalog.', + mapOccupancy: '{name} ({activity})', mapHeadcount: '{name} ({count})', mapHeadcountActivity: '{name} ({count} · {activity})', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index 31fade4..9601e68 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -643,6 +643,33 @@ export async function unassignSubject( ); } +export interface DressRulePair { + readonly form: string; + readonly color: string; +} + +export interface DressRules { + readonly students: DressRulePair; + readonly staff: DressRulePair; + readonly pendingStudents: DressRulePair | null; + readonly pendingStaff: DressRulePair | null; +} + +export async function fetchDressRules(schoolId: number): Promise { + return request(`/api/schools/${schoolId}/dress-rules`); +} + +export async function setDressRules( + schoolId: number, + body: { readonly students?: DressRulePair; readonly staff?: DressRulePair }, +): Promise { + return request(`/api/schools/${schoolId}/dress-rules`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + export interface TimetableLesson { readonly classId: string; readonly classYear: number; diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index eb6674e..c94e927 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -1047,6 +1047,21 @@ body { font-size: 13px; } +.rules__summary { + margin: 0 0 8px; + font-size: 14px; +} + +.rules__fieldset { + margin: 0 0 16px; + padding: 0; + border: 0; +} + +.rules__fieldset .people__field + .people__field { + margin-top: 8px; +} + .staffing__badge { margin-left: 8px; padding: 1px 6px; diff --git a/src/HSchool.Client/src/ui/dressRulesPanel.test.ts b/src/HSchool.Client/src/ui/dressRulesPanel.test.ts new file mode 100644 index 0000000..635ae45 --- /dev/null +++ b/src/HSchool.Client/src/ui/dressRulesPanel.test.ts @@ -0,0 +1,91 @@ +/** + * @vitest-environment happy-dom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ApiError, + fetchDressRules, + setDressRules, + type DressRules, +} from '../net/api.ts'; +import { getLocale, setLocale } from '../i18n/locale.ts'; +import { t } from '../i18n/strings.ts'; +import { DressRulesPanel } from './dressRulesPanel.ts'; + +vi.mock('../net/api.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchDressRules: vi.fn(), + setDressRules: vi.fn(), + }; +}); + +const initialLocale = getLocale(); + +function rules(): DressRules { + return { + students: { form: 'regular', color: 'noBright' }, + staff: { form: 'regular', color: 'noBright' }, + pendingStudents: null, + pendingStaff: null, + }; +} + +describe('DressRulesPanel', () => { + beforeEach(() => { + setLocale('en'); + vi.mocked(fetchDressRules).mockReset(); + vi.mocked(setDressRules).mockReset(); + vi.mocked(fetchDressRules).mockResolvedValue(rules()); + }); + + afterEach(() => { + document.body.replaceChildren(); + setLocale(initialLocale); + }); + + it('does not call save when only a combobox changes', async () => { + const panel = new DressRulesPanel(); + document.body.append(panel.element); + panel.show(3); + await vi.waitFor(() => expect(fetchDressRules).toHaveBeenCalled()); + + const colorSelect = panel.element.querySelector('select.input'); + if (!(colorSelect instanceof HTMLSelectElement)) { + throw new Error('student colour select is missing'); + } + + colorSelect.value = 'free'; + colorSelect.dispatchEvent(new Event('change', { bubbles: true })); + expect(setDressRules).not.toHaveBeenCalled(); + }); + + it('shows an API error as text after apply', async () => { + vi.mocked(setDressRules).mockRejectedValue(new ApiError(400, 'unknown-form', 'bad form')); + const panel = new DressRulesPanel(); + document.body.append(panel.element); + panel.show(3); + await vi.waitFor(() => expect(fetchDressRules).toHaveBeenCalled()); + + const formSelect = panel.element.querySelector('fieldset select.input'); + if (!(formSelect instanceof HTMLSelectElement)) { + throw new Error('student form select is missing'); + } + + formSelect.value = 'strict'; + const apply = panel.element.querySelector('button.button'); + if (!(apply instanceof HTMLButtonElement)) { + throw new Error('apply button is missing'); + } + + apply.click(); + const banner = panel.element.querySelector('.staffing__error'); + if (!(banner instanceof HTMLElement)) { + throw new Error('error banner is missing'); + } + + await vi.waitFor(() => expect(banner.hidden).toBe(false)); + expect(banner.textContent).toBe(t('dressErrorUnknownForm')); + }); +}); diff --git a/src/HSchool.Client/src/ui/dressRulesPanel.ts b/src/HSchool.Client/src/ui/dressRulesPanel.ts new file mode 100644 index 0000000..bd53983 --- /dev/null +++ b/src/HSchool.Client/src/ui/dressRulesPanel.ts @@ -0,0 +1,239 @@ +import { fetchDressRules, setDressRules, type DressRulePair, type DressRules } from '../net/api.ts'; +import { t } from '../i18n/strings.ts'; +import { clear, el } from './dom.ts'; +import { + dressColorLabel, + dressColorOptions, + dressFormLabel, + dressFormOptions, + dressRulesError, +} from './dressRulesUi.ts'; +import { fillSelect } from './staffingUi.ts'; + +/** + * School-wide dress code. Edits queue for the next work morning; today's rules stay visible. + */ +export class DressRulesPanel { + readonly element: HTMLElement; + + private readonly error = el('p', { class: 'staffing__error' }); + private readonly currentTitle = el('h3', { class: 'panel__section-title' }); + private readonly currentStudents = el('p', { class: 'rules__summary' }); + private readonly currentStaff = el('p', { class: 'rules__summary' }); + private readonly pendingTitle = el('h3', { class: 'panel__section-title' }); + private readonly pendingHint = el('p', { class: 'panel__meta' }); + private readonly studentsFormLabel = el('span', { class: 'people__label' }); + private readonly studentsFormSelect = el('select', { class: 'input people__input' }); + private readonly studentsColorLabel = el('span', { class: 'people__label' }); + private readonly studentsColorSelect = el('select', { class: 'input people__input' }); + private readonly staffFormLabel = el('span', { class: 'people__label' }); + private readonly staffFormSelect = el('select', { class: 'input people__input' }); + private readonly staffColorLabel = el('span', { class: 'people__label' }); + private readonly staffColorSelect = el('select', { class: 'input people__input' }); + private readonly applyButton = el('button', { class: 'button', type: 'button' }); + + private readonly studentsLegend: HTMLElement; + private readonly staffLegend: HTMLElement; + + private schoolId: number | null = null; + private rules: DressRules | null = null; + private loadToken = 0; + private busy = false; + + constructor() { + this.error.hidden = true; + this.applyButton.addEventListener('click', () => void this.apply()); + + this.element = el( + 'div', + { class: 'panel__body rules', hidden: true }, + this.error, + el('div', { class: 'panel__section' }, this.currentTitle, this.currentStudents, this.currentStaff), + el( + 'div', + { class: 'panel__section' }, + this.pendingTitle, + this.pendingHint, + el( + 'fieldset', + { class: 'rules__fieldset' }, + el('legend', { class: 'people__section-title', text: '' }), + el( + 'label', + { class: 'people__field' }, + this.studentsFormLabel, + this.studentsFormSelect, + ), + el( + 'label', + { class: 'people__field' }, + this.studentsColorLabel, + this.studentsColorSelect, + ), + ), + el( + 'fieldset', + { class: 'rules__fieldset' }, + el('legend', { class: 'people__section-title', text: '' }), + el('label', { class: 'people__field' }, this.staffFormLabel, this.staffFormSelect), + el('label', { class: 'people__field' }, this.staffColorLabel, this.staffColorSelect), + ), + this.applyButton, + ), + ); + + const legends = this.element.querySelectorAll('fieldset.rules__fieldset > legend'); + const studentsLegend = legends.item(0); + const staffLegend = legends.item(1); + if (!(studentsLegend instanceof HTMLElement) || !(staffLegend instanceof HTMLElement)) { + throw new Error('rules fieldset legends are missing'); + } + + this.studentsLegend = studentsLegend; + this.staffLegend = staffLegend; + this.localize(); + } + + localize(): void { + this.currentTitle.textContent = t('rulesCurrentTitle'); + this.pendingTitle.textContent = t('rulesPendingTitle'); + this.pendingHint.textContent = t('rulesPendingHint'); + this.studentsFormLabel.textContent = t('rulesStudentsForm'); + this.studentsColorLabel.textContent = t('rulesStudentsColor'); + this.staffFormLabel.textContent = t('rulesStaffForm'); + this.staffColorLabel.textContent = t('rulesStaffColor'); + this.applyButton.textContent = t('rulesApply'); + this.studentsLegend.textContent = t('rulesStudentsRole'); + this.staffLegend.textContent = t('rulesStaffRole'); + fillSelect(this.studentsFormSelect, dressFormOptions(), this.studentsFormSelect.value); + fillSelect(this.studentsColorSelect, dressColorOptions(), this.studentsColorSelect.value); + fillSelect(this.staffFormSelect, dressFormOptions(), this.staffFormSelect.value); + fillSelect(this.staffColorSelect, dressColorOptions(), this.staffColorSelect.value); + this.paint(); + } + + show(schoolId: number): void { + if (this.schoolId !== schoolId) { + this.schoolId = schoolId; + this.rules = null; + this.clearError(); + } + + void this.reload(); + } + + hide(): void { + this.element.hidden = true; + } + + reveal(): void { + this.element.hidden = false; + } + + private async reload(): Promise { + const schoolId = this.schoolId; + if (schoolId === null) { + return; + } + + const token = ++this.loadToken; + try { + const rules = await fetchDressRules(schoolId); + if (token !== this.loadToken) { + return; + } + + this.rules = rules; + this.paint(); + } catch { + if (token !== this.loadToken) { + return; + } + + this.showError(t('rulesLoadFailed')); + } + } + + private paint(): void { + const rules = this.rules; + this.applyButton.disabled = this.busy || rules === null; + + if (rules === null) { + this.currentStudents.textContent = '—'; + this.currentStaff.textContent = '—'; + return; + } + + this.currentStudents.textContent = t('rulesRoleSummary', { + role: t('rulesStudentsRole'), + form: dressFormLabel(rules.students.form), + color: dressColorLabel(rules.students.color), + }); + this.currentStaff.textContent = t('rulesRoleSummary', { + role: t('rulesStaffRole'), + form: dressFormLabel(rules.staff.form), + color: dressColorLabel(rules.staff.color), + }); + + const pendingStudents = rules.pendingStudents ?? rules.students; + const pendingStaff = rules.pendingStaff ?? rules.staff; + fillSelect(this.studentsFormSelect, dressFormOptions(), pendingStudents.form); + fillSelect(this.studentsColorSelect, dressColorOptions(), pendingStudents.color); + fillSelect(this.staffFormSelect, dressFormOptions(), pendingStaff.form); + fillSelect(this.staffColorSelect, dressColorOptions(), pendingStaff.color); + } + + private async apply(): Promise { + const schoolId = this.schoolId; + const rules = this.rules; + if (schoolId === null || rules === null || this.busy) { + return; + } + + const students = this.pair(this.studentsFormSelect, this.studentsColorSelect); + const staff = this.pair(this.staffFormSelect, this.staffColorSelect); + const body: { students?: DressRulePair; staff?: DressRulePair } = {}; + if (!samePair(students, rules.pendingStudents ?? rules.students)) { + body.students = students; + } + + if (!samePair(staff, rules.pendingStaff ?? rules.staff)) { + body.staff = staff; + } + + if (body.students === undefined && body.staff === undefined) { + return; + } + + this.busy = true; + this.clearError(); + this.paint(); + try { + this.rules = await setDressRules(schoolId, body); + this.paint(); + } catch (error) { + this.showError(dressRulesError(error)); + } finally { + this.busy = false; + this.paint(); + } + } + + private pair(formSelect: HTMLSelectElement, colorSelect: HTMLSelectElement): DressRulePair { + return { form: formSelect.value, color: colorSelect.value }; + } + + private showError(message: string): void { + this.error.hidden = false; + this.error.textContent = message; + } + + private clearError(): void { + this.error.hidden = true; + this.error.textContent = ''; + } +} + +function samePair(left: DressRulePair, right: DressRulePair): boolean { + return left.form === right.form && left.color === right.color; +} diff --git a/src/HSchool.Client/src/ui/dressRulesUi.ts b/src/HSchool.Client/src/ui/dressRulesUi.ts new file mode 100644 index 0000000..b9fbbd2 --- /dev/null +++ b/src/HSchool.Client/src/ui/dressRulesUi.ts @@ -0,0 +1,57 @@ +import { ApiError } from '../net/api.ts'; +import { t } from '../i18n/strings.ts'; + +export const DRESS_FORMS = ['regular', 'short', 'strict'] as const; +export const DRESS_COLORS = ['free', 'noBright', 'whiteTopBlackBottom'] as const; + +export type DressForm = (typeof DRESS_FORMS)[number]; +export type DressColor = (typeof DRESS_COLORS)[number]; + +export function dressFormOptions(): readonly { value: DressForm; label: string }[] { + return DRESS_FORMS.map((value) => ({ value, label: dressFormLabel(value) })); +} + +export function dressColorOptions(): readonly { value: DressColor; label: string }[] { + return DRESS_COLORS.map((value) => ({ value, label: dressColorLabel(value) })); +} + +export function dressFormLabel(form: string): string { + switch (form) { + case 'regular': + return t('dressFormRegular'); + case 'short': + return t('dressFormShort'); + case 'strict': + return t('dressFormStrict'); + default: + return form; + } +} + +export function dressColorLabel(color: string): string { + switch (color) { + case 'free': + return t('dressColorFree'); + case 'noBright': + return t('dressColorNoBright'); + case 'whiteTopBlackBottom': + return t('dressColorWhiteTopBlackBottom'); + default: + return color; + } +} + +export function dressRulesError(error: unknown): string { + if (!(error instanceof ApiError)) { + return t('rulesSaveFailed'); + } + + switch (error.code) { + case 'unknown-form': + return t('dressErrorUnknownForm'); + case 'unknown-color': + return t('dressErrorUnknownColor'); + default: + return error.message.length > 0 ? error.message : t('rulesSaveFailed'); + } +} diff --git a/src/HSchool.Client/src/ui/managementPanel.test.ts b/src/HSchool.Client/src/ui/managementPanel.test.ts index b7760b2..f1aa375 100644 --- a/src/HSchool.Client/src/ui/managementPanel.test.ts +++ b/src/HSchool.Client/src/ui/managementPanel.test.ts @@ -6,6 +6,7 @@ import { ApiError, fetchPerson, fetchGameStatus, + fetchDressRules, fetchStaffing, fetchTimetable, hireStaff, @@ -24,6 +25,7 @@ vi.mock('../net/api.ts', async (importOriginal) => { ...actual, fetchStaffing: vi.fn(), fetchTimetable: vi.fn(), + fetchDressRules: vi.fn(), fetchPerson: vi.fn(), fetchGameStatus: vi.fn().mockResolvedValue({ tick: 0, @@ -215,6 +217,7 @@ describe('ManagementPanel uncovered', () => { setLocale('en'); vi.mocked(fetchStaffing).mockReset(); vi.mocked(fetchTimetable).mockReset(); + vi.mocked(fetchDressRules).mockReset(); vi.mocked(fetchStaffing).mockResolvedValue({ ...staffing(), applicants: [], @@ -230,6 +233,12 @@ describe('ManagementPanel uncovered', () => { ], }); vi.mocked(fetchTimetable).mockResolvedValue(timetable()); + vi.mocked(fetchDressRules).mockResolvedValue({ + students: { form: 'regular', color: 'noBright' }, + staff: { form: 'regular', color: 'noBright' }, + pendingStudents: null, + pendingStaff: null, + }); }); afterEach(() => { @@ -248,3 +257,55 @@ describe('ManagementPanel uncovered', () => { ); }); }); + +describe('ManagementPanel rules tab', () => { + beforeEach(() => { + setLocale('en'); + vi.mocked(fetchStaffing).mockReset(); + vi.mocked(fetchTimetable).mockReset(); + vi.mocked(fetchDressRules).mockReset(); + vi.mocked(fetchStaffing).mockResolvedValue(staffing()); + vi.mocked(fetchTimetable).mockResolvedValue(timetable()); + vi.mocked(fetchDressRules).mockResolvedValue({ + students: { form: 'regular', color: 'noBright' }, + staff: { form: 'regular', color: 'noBright' }, + pendingStudents: null, + pendingStaff: null, + }); + }); + + afterEach(() => { + document.body.replaceChildren(); + setLocale(initialLocale); + }); + + it('keeps staffing and the timetable grid after visiting rules', async () => { + const panel = new ManagementPanel(); + document.body.append(panel.listElement, panel.cardElement); + panel.show(2); + await vi.waitFor(() => expect(fetchStaffing).toHaveBeenCalled()); + + const rulesTab = [...panel.listElement.querySelectorAll('.panel__tab')].find( + (button) => button.textContent === t('manageTabRules'), + ); + if (!(rulesTab instanceof HTMLButtonElement)) { + throw new Error('rules tab is missing'); + } + + rulesTab.click(); + expect(panel.listElement.querySelector('.staffing')).toBeNull(); + + const staffTab = [...panel.listElement.querySelectorAll('.panel__tab')].find( + (button) => button.textContent === t('manageTabStaff'), + ); + if (!(staffTab instanceof HTMLButtonElement)) { + throw new Error('staff tab is missing'); + } + + staffTab.click(); + await vi.waitFor(() => expect(panel.listElement.querySelector('.staffing')).not.toBeNull()); + expect(panel.listElement.textContent).toContain(t('staffHired')); + expect(panel.listElement.textContent).toContain(t('timetableTitle')); + expect(panel.listElement.querySelector('.timetable__grid')).not.toBeNull(); + }); +}); diff --git a/src/HSchool.Client/src/ui/managementPanel.ts b/src/HSchool.Client/src/ui/managementPanel.ts index b06a478..a60157f 100644 --- a/src/HSchool.Client/src/ui/managementPanel.ts +++ b/src/HSchool.Client/src/ui/managementPanel.ts @@ -12,6 +12,7 @@ import { import { getLocale } from '../i18n/locale.ts'; import { t } from '../i18n/strings.ts'; import { ApplicantsDialog } from './applicantsDialog.ts'; +import { DressRulesPanel } from './dressRulesPanel.ts'; import { clear, el } from './dom.ts'; import { formatPersonPlace } from './personCard.ts'; import { PersonCardHost } from './personCardHost.ts'; @@ -28,6 +29,10 @@ export class ManagementPanel { readonly listElement: HTMLElement; readonly cardElement: HTMLElement; + private readonly staffTab = el('button', { class: 'panel__tab', type: 'button' }); + private readonly rulesTab = el('button', { class: 'panel__tab', type: 'button' }); + private readonly staffPane = el('div', { class: 'panel__body staffing' }); + private readonly rulesPanel = new DressRulesPanel(); 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' }); @@ -65,13 +70,14 @@ export class ManagementPanel { private locate: ((id: string) => string) | null = null; private poolDialog: ApplicantsDialog | null = null; private readonly cardHost = new PersonCardHost(); + private manageTab: 'staff' | 'rules' = 'staff'; constructor() { this.error.hidden = true; this.applicantsButton.addEventListener('click', () => void this.openApplicants()); - this.listElement = el( - 'div', - { class: 'panel__body staffing' }, + this.staffTab.addEventListener('click', () => this.showManageTab('staff')); + this.rulesTab.addEventListener('click', () => this.showManageTab('rules')); + this.staffPane.append( this.error, this.money, el('div', { class: 'panel__section' }, this.uncoveredTitle, this.uncoveredEmpty, this.uncovered), @@ -96,6 +102,13 @@ export class ManagementPanel { this.timetableGrid.element, ), ); + this.listElement = el( + 'div', + {}, + el('div', { class: 'panel__tabs' }, this.staffTab, this.rulesTab), + this.staffPane, + this.rulesPanel.element, + ); this.cardElement = this.card; this.classSelect.addEventListener('change', () => { this.classId = this.classSelect.value || null; @@ -105,6 +118,8 @@ export class ManagementPanel { } localize(): void { + this.staffTab.textContent = t('manageTabStaff'); + this.rulesTab.textContent = t('manageTabRules'); this.uncoveredTitle.textContent = t('staffUncovered'); this.applicantsTitle.textContent = t('staffApplicants'); this.applicantsButton.textContent = t('staffApplicantsOpen'); @@ -116,6 +131,8 @@ export class ManagementPanel { this.timetableGrid.localize(); this.personGrid.localize(); this.personTimetableTitle.textContent = t('timetableTitle'); + this.rulesPanel.localize(); + this.paintManageTabs(); this.paint(); this.poolDialog?.localize(); if (this.selectedId !== null) { @@ -133,12 +150,43 @@ export class ManagementPanel { this.staffing = null; this.timetable = null; this.classId = null; + this.manageTab = 'staff'; this.clearError(); } + this.paintManageTabs(); + this.timetableGrid.attach(schoolId); this.cardHost.attach(schoolId); void this.reload(); + if (this.manageTab === 'rules') { + this.rulesPanel.show(schoolId); + } + } + + private showManageTab(tab: 'staff' | 'rules'): void { + this.manageTab = tab; + this.paintManageTabs(); + const schoolId = this.schoolId; + if (tab === 'rules' && schoolId !== null) { + this.rulesPanel.show(schoolId); + this.paintCard(null); + } else if (this.selectedId !== null) { + void this.openCard(this.selectedId); + } else { + this.paintCard(null); + } + } + + private paintManageTabs(): void { + this.staffTab.classList.toggle('panel__tab--active', this.manageTab === 'staff'); + this.rulesTab.classList.toggle('panel__tab--active', this.manageTab === 'rules'); + this.staffPane.hidden = this.manageTab !== 'staff'; + if (this.manageTab === 'rules') { + this.rulesPanel.reveal(); + } else { + this.rulesPanel.hide(); + } } setLocate(locate: (id: string) => string): void { @@ -402,6 +450,12 @@ export class ManagementPanel { } private paintCard(card: PersonCard | null): void { + if (this.manageTab === 'rules') { + clear(this.card); + this.card.append(el('p', { class: 'panel__empty', text: t('rulesPickHint') })); + return; + } + if (card === null) { clear(this.card); this.card.append(el('p', { class: 'panel__empty', text: t('staffPickHint') })); diff --git a/src/HSchool.Content/Appropriateness.cs b/src/HSchool.Content/Appropriateness.cs index 4d75f3a..0e28910 100644 --- a/src/HSchool.Content/Appropriateness.cs +++ b/src/HSchool.Content/Appropriateness.cs @@ -112,6 +112,21 @@ public static class Appropriateness return false; } + if (rules.Color.Equals(ColorPolicies.WhiteTopBlackBottom, StringComparison.Ordinal)) + { + if (def.Layers.Contains(ApparelLayers.Top, StringComparer.Ordinal) + && !color.Equals("White", StringComparison.Ordinal)) + { + return false; + } + + if (def.Layers.Contains(ApparelLayers.Bottom, StringComparer.Ordinal) + && !color.Equals("Black", StringComparison.Ordinal)) + { + return false; + } + } + return true; } diff --git a/tests/HSchool.AppHost.Tests/DressRulesApiTests.cs b/tests/HSchool.AppHost.Tests/DressRulesApiTests.cs index b50e9f2..8fa8e15 100644 --- a/tests/HSchool.AppHost.Tests/DressRulesApiTests.cs +++ b/tests/HSchool.AppHost.Tests/DressRulesApiTests.cs @@ -54,6 +54,38 @@ public class DressRulesApiTests(AppHostFixture fixture) Assert.Equal(before.Worn.Select(row => row.DefName), after.Worn.Select(row => row.DefName)); } + [Fact] + public async Task UnknownForm_Returns400() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Плохая форма", Start, seed: 35); + + using var response = await client.PostAsJsonAsync( + $"/api/schools/{school.Id}/dress-rules", + new { students = new { form = "tuxedo", color = "noBright" } }, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("unknown-form", await SchoolApiTests.ProblemCodeAsync(response)); + } + + [Fact] + public async Task UnknownColor_Returns400() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Плохой цвет", Start, seed: 35); + + using var response = await client.PostAsJsonAsync( + $"/api/schools/{school.Id}/dress-rules", + new { students = new { form = "regular", color = "neon" } }, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("unknown-color", await SchoolApiTests.ProblemCodeAsync(response)); + } + private sealed record DressRulesDto(DressRulePairDto Students, DressRulePairDto Staff); private sealed record DressRulePairDto(string Form, string Color); diff --git a/tests/HSchool.Content.Tests/AppropriatenessTests.cs b/tests/HSchool.Content.Tests/AppropriatenessTests.cs index 25d417c..6671179 100644 --- a/tests/HSchool.Content.Tests/AppropriatenessTests.cs +++ b/tests/HSchool.Content.Tests/AppropriatenessTests.cs @@ -46,6 +46,17 @@ public class AppropriatenessTests Assert.True(Appropriateness.SkirtLengthFits(context, catalog.Things["ShortSkirt"])); } + [Fact] + public void WhiteTopBlackBottom_RejectsWrongTopOrBottom() + { + var catalog = LoadCatalog(); + var rules = new DressRulePair(FormPolicies.Regular, ColorPolicies.WhiteTopBlackBottom); + Assert.False(Appropriateness.ColorAllowed(catalog, rules, catalog.Things["Shirt"], "Gray")); + Assert.True(Appropriateness.ColorAllowed(catalog, rules, catalog.Things["Shirt"], "White")); + Assert.False(Appropriateness.ColorAllowed(catalog, rules, catalog.Things["Jeans"], "Blue")); + Assert.True(Appropriateness.ColorAllowed(catalog, rules, catalog.Things["Jeans"], "Black")); + } + private static DefCatalog LoadCatalog() { var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); diff --git a/tests/HSchool.Simulation.Tests/AppropriatenessSimulationTests.cs b/tests/HSchool.Simulation.Tests/AppropriatenessSimulationTests.cs index 9ef4e98..12da2e9 100644 --- a/tests/HSchool.Simulation.Tests/AppropriatenessSimulationTests.cs +++ b/tests/HSchool.Simulation.Tests/AppropriatenessSimulationTests.cs @@ -152,6 +152,63 @@ public class AppropriatenessSimulationTests Assert.Equal(0, BrightWornCount(school)); } + [Fact] + public void PendingNoBright_AfterMorning_RemovesBrightWornColours() + { + using var school = Open(TuesdayMorning, seed: 31); + MorningDress.Apply(school); + school.DressRules = school.DressRules with + { + PendingStudents = new DressRulePair(FormPolicies.Regular, ColorPolicies.Free), + }; + AdvanceMorning(school); + Assert.True(BrightWornCount(school) > 0, "Free colours should allow at least one bright item."); + + school.DressRules = school.DressRules with + { + PendingStudents = new DressRulePair(FormPolicies.Regular, ColorPolicies.NoBright), + }; + AdvanceMorning(school); + Assert.Equal(0, BrightWornCount(school)); + } + + [Fact] + public void WhiteTopBlackBottom_AfterMorning_WhiteShirtsOnStudents() + { + using var school = Open(TuesdayMorning, seed: 31); + MorningDress.Apply(school); + school.DressRules = school.DressRules with + { + PendingStudents = new DressRulePair(FormPolicies.Regular, ColorPolicies.WhiteTopBlackBottom), + }; + AdvanceMorning(school); + + var checkedAny = false; + foreach (var pupil in school.Roster!.People.Where(person => person.IsStudent)) + { + foreach (var item in pupil.Items.Where(row => row.Location == ItemLocations.Worn)) + { + if (!school.Catalog!.Things[item.Def].Layers.Any(layer => + layer.Equals(ApparelLayers.Top, StringComparison.Ordinal))) + { + continue; + } + + checkedAny = true; + Assert.Equal("White", item.Color); + } + } + + Assert.True(checkedAny, "Expected at least one pupil with a top-layer shirt."); + } + + private static void AdvanceMorning(School school) + { + var before = school.Clock.Time; + school.Clock.JumpTo(before.Date.AddDays(1).AddHours(6)); + ApparelWear.Apply(school, gameMinutes: 0, before); + } + private static int BrightWornCount(School school) { var count = 0;