Split the person card into overview, clothes, carry and now tabs.
Today's history is a paged HTTP log on the worker, not on the card or in people.json. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -136,6 +136,21 @@ const ru = {
|
||||
peopleChildren: 'Дети',
|
||||
peopleSiblings: 'Братья и сёстры',
|
||||
peoplePartners: 'Супруг(а)',
|
||||
peopleTabOverview: 'Обзор',
|
||||
peopleTabApparel: 'Одежда',
|
||||
peopleTabCarry: 'Ноша',
|
||||
peopleTabNow: 'Сейчас',
|
||||
peopleAtHome: 'дома',
|
||||
peopleApparelFit: 'Уместность: {value}',
|
||||
peopleLockerYes: 'Есть шкафчик',
|
||||
peopleLockerNone: 'Шкафчика нет',
|
||||
peopleHomeCount: 'ещё {n} дома',
|
||||
peopleLogSearch: 'Поиск',
|
||||
peopleLogNewest: 'Сначала новые',
|
||||
peopleLogOldest: 'Сначала старые',
|
||||
peopleLogTime: 'Время',
|
||||
peopleLogEvent: 'Событие',
|
||||
peopleLogEmpty: 'Сегодня записей нет.',
|
||||
|
||||
modeOverview: 'Обзор',
|
||||
modeManage: 'Управление',
|
||||
@@ -356,6 +371,21 @@ const en: Messages = {
|
||||
peopleChildren: 'Children',
|
||||
peopleSiblings: 'Siblings',
|
||||
peoplePartners: 'Spouse',
|
||||
peopleTabOverview: 'Overview',
|
||||
peopleTabApparel: 'Clothes',
|
||||
peopleTabCarry: 'Carried',
|
||||
peopleTabNow: 'Now',
|
||||
peopleAtHome: 'home',
|
||||
peopleApparelFit: 'Fit: {value}',
|
||||
peopleLockerYes: 'Has a locker',
|
||||
peopleLockerNone: 'No locker',
|
||||
peopleHomeCount: '{n} more at home',
|
||||
peopleLogSearch: 'Search',
|
||||
peopleLogNewest: 'Newest first',
|
||||
peopleLogOldest: 'Oldest first',
|
||||
peopleLogTime: 'Time',
|
||||
peopleLogEvent: 'Event',
|
||||
peopleLogEmpty: 'Nothing logged today.',
|
||||
|
||||
modeOverview: 'Overview',
|
||||
modeManage: 'Management',
|
||||
|
||||
@@ -295,6 +295,8 @@ export interface PersonCard {
|
||||
readonly carried: readonly CarriedItem[];
|
||||
readonly carryMass: number;
|
||||
readonly carryCapacity: number;
|
||||
readonly hasLocker: boolean;
|
||||
readonly homeCount: number;
|
||||
}
|
||||
|
||||
export interface WornItem {
|
||||
@@ -303,6 +305,8 @@ export interface WornItem {
|
||||
readonly color: string | null;
|
||||
readonly colorLabel: string | null;
|
||||
readonly layers: readonly DefLabel[];
|
||||
readonly condition?: number;
|
||||
readonly conditionLabel?: string | null;
|
||||
}
|
||||
|
||||
export interface CarriedItem {
|
||||
@@ -335,6 +339,50 @@ export async function fetchPerson(schoolId: number, personId: string, lang: stri
|
||||
);
|
||||
}
|
||||
|
||||
export type PersonLogDir = 'asc' | 'desc';
|
||||
|
||||
export interface PersonLogQuery {
|
||||
readonly q?: string;
|
||||
readonly sort?: 'time';
|
||||
readonly dir?: PersonLogDir;
|
||||
readonly page?: number;
|
||||
readonly pageSize?: number;
|
||||
}
|
||||
|
||||
export interface PersonLogEntry {
|
||||
readonly time: string;
|
||||
readonly type: string;
|
||||
readonly label: string;
|
||||
readonly thingDef: string | null;
|
||||
}
|
||||
|
||||
export interface PersonLogPage {
|
||||
readonly total: number;
|
||||
readonly page: number;
|
||||
readonly pageSize: number;
|
||||
readonly entries: readonly PersonLogEntry[];
|
||||
}
|
||||
|
||||
export async function fetchPersonLog(
|
||||
schoolId: number,
|
||||
personId: string,
|
||||
lang: string,
|
||||
query: PersonLogQuery,
|
||||
): Promise<PersonLogPage> {
|
||||
const params = new URLSearchParams({ lang });
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined || value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
params.set(key, String(value));
|
||||
}
|
||||
|
||||
return request<PersonLogPage>(
|
||||
`/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/log?${params.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export interface DirectoryPerson {
|
||||
readonly id: string;
|
||||
readonly fullName: string;
|
||||
|
||||
@@ -593,11 +593,93 @@ body {
|
||||
}
|
||||
|
||||
.people__card-place,
|
||||
.people__card-activity {
|
||||
.people__card-activity,
|
||||
.people__now-activity {
|
||||
margin: 4px 0 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.people__tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin: 10px 0 8px;
|
||||
}
|
||||
|
||||
.people__tab {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.people__tab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.people__tab--active {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.people__tab-panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.people__fit,
|
||||
.people__locker,
|
||||
.people__home-count {
|
||||
margin: 0 0 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.people__garments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.people__wear {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(4em, auto) 1fr;
|
||||
gap: 6px 8px;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.people__wear-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.people__wear .people__need-track {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.people__log-tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.people__log {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.people__log th,
|
||||
.people__log td {
|
||||
padding: 4px 6px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.people__section {
|
||||
margin-top: 10px;
|
||||
}
|
||||
@@ -625,8 +707,8 @@ body {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* The first block sits right under the name, where a divider would only add noise. */
|
||||
.people__card > .people__card-meta + .people__section > .people__section-title {
|
||||
/* The first block sits right under the tabs, where a divider would only add noise. */
|
||||
.people__tab-panel > .people__section:first-child > .people__section-title {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import {
|
||||
fetchPerson,
|
||||
fetchPersonLog,
|
||||
hireStaff,
|
||||
type PersonCard,
|
||||
type PersonLogPage,
|
||||
type PersonLogQuery,
|
||||
type Staffing,
|
||||
type StaffingApplicant,
|
||||
} from '../net/api.ts';
|
||||
@@ -9,7 +12,7 @@ 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 { formatPersonPlace, renderPersonCard, type PersonCardTab } from './personCard.ts';
|
||||
import {
|
||||
actionError,
|
||||
fillSelect,
|
||||
@@ -69,6 +72,10 @@ export class ApplicantsDialog {
|
||||
private cardToken = 0;
|
||||
private busy = false;
|
||||
private painted: PersonCard | null = null;
|
||||
private cardTab: PersonCardTab = 'overview';
|
||||
private logQuery: PersonLogQuery = { page: 1, pageSize: 20, dir: 'desc' };
|
||||
private logPage: PersonLogPage | null = null;
|
||||
private logToken = 0;
|
||||
|
||||
constructor(private readonly options: ApplicantsDialogOptions) {
|
||||
this.staffing = options.staffing;
|
||||
@@ -292,6 +299,9 @@ export class ApplicantsDialog {
|
||||
|
||||
private async openCard(personId: string): Promise<void> {
|
||||
const token = ++this.cardToken;
|
||||
this.cardTab = 'overview';
|
||||
this.logPage = null;
|
||||
this.logQuery = { page: 1, pageSize: 20, dir: 'desc' };
|
||||
try {
|
||||
const card = await fetchPerson(this.options.schoolId, personId, getLocale());
|
||||
if (token !== this.cardToken) {
|
||||
@@ -317,10 +327,66 @@ export class ApplicantsDialog {
|
||||
return;
|
||||
}
|
||||
|
||||
renderPersonCard(this.card, card, (id) => this.openRelative(id), this.placeOf(card.id));
|
||||
renderPersonCard(this.card, card, (id) => this.openRelative(id), this.cardOptions(card));
|
||||
this.appendHireIfNeeded();
|
||||
}
|
||||
|
||||
private cardOptions(card: PersonCard) {
|
||||
const place = this.placeOf(card.id);
|
||||
return {
|
||||
place,
|
||||
away: place === formatPersonPlace('away'),
|
||||
tab: this.cardTab,
|
||||
log: this.cardTab === 'now' ? this.logPage : null,
|
||||
logQuery: this.logQuery,
|
||||
onTab: (tab: PersonCardTab) => {
|
||||
this.cardTab = tab;
|
||||
if (tab === 'now') {
|
||||
void this.loadLog();
|
||||
} else {
|
||||
this.paintCard(this.painted);
|
||||
}
|
||||
},
|
||||
onLogSearch: (q: string) => {
|
||||
this.logQuery = { ...this.logQuery, q, page: 1 };
|
||||
void this.loadLog();
|
||||
},
|
||||
onLogDir: (dir: 'asc' | 'desc') => {
|
||||
this.logQuery = { ...this.logQuery, dir, page: 1 };
|
||||
void this.loadLog();
|
||||
},
|
||||
onLogPage: (page: number) => {
|
||||
this.logQuery = { ...this.logQuery, page };
|
||||
void this.loadLog();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async loadLog(): Promise<void> {
|
||||
const painted = this.painted;
|
||||
if (painted === null || this.cardTab !== 'now') {
|
||||
this.paintCard(painted);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = ++this.logToken;
|
||||
try {
|
||||
this.logPage = await fetchPersonLog(this.options.schoolId, painted.id, getLocale(), this.logQuery);
|
||||
if (token !== this.logToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.paintCard(this.painted);
|
||||
} catch {
|
||||
if (token !== this.logToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logPage = { total: 0, page: this.logQuery.page ?? 1, pageSize: this.logQuery.pageSize ?? 20, entries: [] };
|
||||
this.paintCard(this.painted);
|
||||
}
|
||||
}
|
||||
|
||||
private appendHireIfNeeded(): void {
|
||||
const applicant = this.staffing.applicants.find((row) => row.id === this.painted?.id);
|
||||
if (applicant === undefined) {
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock('../net/api.ts', async (importOriginal) => {
|
||||
fetchStaffing: vi.fn(),
|
||||
fetchTimetable: vi.fn(),
|
||||
fetchPerson: vi.fn(),
|
||||
fetchPersonLog: vi.fn(),
|
||||
hireStaff: vi.fn(),
|
||||
assignSubject: vi.fn(),
|
||||
unassignSubject: vi.fn(),
|
||||
@@ -59,6 +60,8 @@ function personCard(): PersonCard {
|
||||
carried: [],
|
||||
carryMass: 0,
|
||||
carryCapacity: 0,
|
||||
hasLocker: false,
|
||||
homeCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import {
|
||||
assignSubject,
|
||||
fetchPerson,
|
||||
fetchPersonLog,
|
||||
fetchStaffing,
|
||||
fetchTimetable,
|
||||
unassignSubject,
|
||||
type PersonCard,
|
||||
type PersonLogPage,
|
||||
type PersonLogQuery,
|
||||
type Staffing,
|
||||
type StaffMember,
|
||||
type Timetable,
|
||||
@@ -13,7 +16,7 @@ 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 { formatPersonPlace, nowActivityText, renderPersonCard, type PersonCardTab } from './personCard.ts';
|
||||
import { actionError, fillSelect, formatHours, formatMoney } from './staffingUi.ts';
|
||||
import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts';
|
||||
|
||||
@@ -64,6 +67,10 @@ export class ManagementPanel {
|
||||
private locate: ((id: string) => string) | null = null;
|
||||
private painted: PersonCard | null = null;
|
||||
private poolDialog: ApplicantsDialog | null = null;
|
||||
private cardTab: PersonCardTab = 'overview';
|
||||
private logQuery: PersonLogQuery = { page: 1, pageSize: 20, dir: 'desc' };
|
||||
private logPage: PersonLogPage | null = null;
|
||||
private logToken = 0;
|
||||
|
||||
constructor() {
|
||||
this.error.hidden = true;
|
||||
@@ -381,6 +388,9 @@ export class ManagementPanel {
|
||||
}
|
||||
|
||||
const token = ++this.cardToken;
|
||||
this.cardTab = 'overview';
|
||||
this.logPage = null;
|
||||
this.logQuery = { page: 1, pageSize: 20, dir: 'desc' };
|
||||
try {
|
||||
const card = await fetchPerson(schoolId, personId, getLocale());
|
||||
if (token !== this.cardToken) {
|
||||
@@ -406,18 +416,80 @@ export class ManagementPanel {
|
||||
return;
|
||||
}
|
||||
|
||||
renderPersonCard(this.card, card, (id) => void this.openRelative(id), this.placeOf(card.id));
|
||||
renderPersonCard(this.card, card, (id) => void this.openRelative(id), this.cardOptions(card));
|
||||
this.mountPersonTimetable(card);
|
||||
this.appendActions(card.id);
|
||||
}
|
||||
|
||||
private cardOptions(card: PersonCard) {
|
||||
const place = this.placeOf(card.id);
|
||||
return {
|
||||
place,
|
||||
away: place === formatPersonPlace('away'),
|
||||
tab: this.cardTab,
|
||||
log: this.cardTab === 'now' ? this.logPage : null,
|
||||
logQuery: this.logQuery,
|
||||
onTab: (tab: PersonCardTab) => {
|
||||
this.cardTab = tab;
|
||||
if (tab === 'now') {
|
||||
void this.loadLog();
|
||||
} else {
|
||||
this.paintCard(this.painted);
|
||||
}
|
||||
},
|
||||
onLogSearch: (q: string) => {
|
||||
this.logQuery = { ...this.logQuery, q, page: 1 };
|
||||
void this.loadLog();
|
||||
},
|
||||
onLogDir: (dir: 'asc' | 'desc') => {
|
||||
this.logQuery = { ...this.logQuery, dir, page: 1 };
|
||||
void this.loadLog();
|
||||
},
|
||||
onLogPage: (page: number) => {
|
||||
this.logQuery = { ...this.logQuery, page };
|
||||
void this.loadLog();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async loadLog(): Promise<void> {
|
||||
const schoolId = this.schoolId;
|
||||
const painted = this.painted;
|
||||
if (schoolId === null || painted === null || this.cardTab !== 'now') {
|
||||
this.paintCard(painted);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = ++this.logToken;
|
||||
try {
|
||||
this.logPage = await fetchPersonLog(schoolId, painted.id, getLocale(), this.logQuery);
|
||||
if (token !== this.logToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.paintCard(this.painted);
|
||||
} catch {
|
||||
if (token !== this.logToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logPage = { total: 0, page: this.logQuery.page ?? 1, pageSize: this.logQuery.pageSize ?? 20, entries: [] };
|
||||
this.paintCard(this.painted);
|
||||
}
|
||||
}
|
||||
|
||||
private relocate(): void {
|
||||
const line = this.card.querySelector('.people__card-place');
|
||||
if (!(line instanceof HTMLElement) || this.painted === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
line.textContent = this.placeOf(this.painted.id);
|
||||
const place = this.placeOf(this.painted.id);
|
||||
line.textContent = place;
|
||||
const now = this.card.querySelector('.people__now-activity');
|
||||
if (now instanceof HTMLElement) {
|
||||
now.textContent = nowActivityText(this.painted, place === formatPersonPlace('away'));
|
||||
}
|
||||
}
|
||||
|
||||
private placeOf(id: string): string {
|
||||
@@ -427,11 +499,12 @@ export class ManagementPanel {
|
||||
private mountPersonTimetable(card: PersonCard): void {
|
||||
const schoolId = this.schoolId;
|
||||
const query = personTimetableQuery(card);
|
||||
if (schoolId === null || query === null) {
|
||||
const overview = this.card.querySelector('[data-card-tab="overview"]');
|
||||
if (!(overview instanceof HTMLElement) || schoolId === null || query === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.card.append(this.personTimetableTitle, this.personGrid.element);
|
||||
overview.append(this.personTimetableTitle, this.personGrid.element);
|
||||
this.personGrid.attach(schoolId);
|
||||
const token = this.cardToken;
|
||||
void fetchTimetable(schoolId, getLocale(), query)
|
||||
|
||||
@@ -20,6 +20,7 @@ vi.mock('../net/api.ts', async (importOriginal) => {
|
||||
...actual,
|
||||
fetchPeople: vi.fn(),
|
||||
fetchPerson: vi.fn(),
|
||||
fetchPersonLog: vi.fn(),
|
||||
fetchTimetable: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { fetchPeople, fetchPerson, fetchTimetable, type PeoplePage, type PersonCard, type PersonRole, type PersonSort } from '../net/api.ts';
|
||||
import { fetchPeople, fetchPerson, fetchPersonLog, fetchTimetable, type PeoplePage, type PersonCard, type PersonLogPage, type PersonLogQuery, 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';
|
||||
import { formatPersonPlace, placement, renderPersonCard, roleLabels } from './personCard.ts';
|
||||
import { formatPersonPlace, nowActivityText, placement, renderPersonCard, roleLabels, type PersonCardTab } from './personCard.ts';
|
||||
import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts';
|
||||
|
||||
const COLUMNS: readonly { sort: PersonSort; label: MessageKey }[] = [
|
||||
@@ -62,6 +62,10 @@ export class PeoplePanel {
|
||||
private cardToken = 0;
|
||||
private locate: ((id: string) => string) | null = null;
|
||||
private painted: PersonCard | null = null;
|
||||
private cardTab: PersonCardTab = 'overview';
|
||||
private logQuery: PersonLogQuery = { page: 1, pageSize: 20, dir: 'desc' };
|
||||
private logPage: PersonLogPage | null = null;
|
||||
private logToken = 0;
|
||||
|
||||
constructor(private readonly options: PeoplePanelOptions) {
|
||||
this.ageMinInput.min = '0';
|
||||
@@ -340,6 +344,9 @@ export class PeoplePanel {
|
||||
// Also covers the links inside a card: opening a relative is picking somebody too.
|
||||
this.options.onSelect();
|
||||
|
||||
this.cardTab = 'overview';
|
||||
this.logPage = null;
|
||||
this.logQuery = { page: 1, pageSize: 20, dir: 'desc' };
|
||||
const token = ++this.cardToken;
|
||||
try {
|
||||
const card = await fetchPerson(schoolId, personId, getLocale());
|
||||
@@ -368,17 +375,79 @@ export class PeoplePanel {
|
||||
return;
|
||||
}
|
||||
|
||||
renderPersonCard(this.card, card, (id) => void this.openCard(id), this.placeOf(card.id));
|
||||
renderPersonCard(this.card, card, (id) => void this.openCard(id), this.cardOptions(card));
|
||||
this.mountPersonTimetable(card);
|
||||
}
|
||||
|
||||
private cardOptions(card: PersonCard) {
|
||||
const place = this.placeOf(card.id);
|
||||
return {
|
||||
place,
|
||||
away: place === formatPersonPlace('away'),
|
||||
tab: this.cardTab,
|
||||
log: this.cardTab === 'now' ? this.logPage : null,
|
||||
logQuery: this.logQuery,
|
||||
onTab: (tab: PersonCardTab) => {
|
||||
this.cardTab = tab;
|
||||
if (tab === 'now') {
|
||||
void this.loadLog();
|
||||
} else {
|
||||
this.paintCard(this.painted);
|
||||
}
|
||||
},
|
||||
onLogSearch: (q: string) => {
|
||||
this.logQuery = { ...this.logQuery, q, page: 1 };
|
||||
void this.loadLog();
|
||||
},
|
||||
onLogDir: (dir: 'asc' | 'desc') => {
|
||||
this.logQuery = { ...this.logQuery, dir, page: 1 };
|
||||
void this.loadLog();
|
||||
},
|
||||
onLogPage: (page: number) => {
|
||||
this.logQuery = { ...this.logQuery, page };
|
||||
void this.loadLog();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async loadLog(): Promise<void> {
|
||||
const schoolId = this.schoolId;
|
||||
const painted = this.painted;
|
||||
if (schoolId === null || painted === null || this.cardTab !== 'now') {
|
||||
this.paintCard(painted);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = ++this.logToken;
|
||||
try {
|
||||
this.logPage = await fetchPersonLog(schoolId, painted.id, getLocale(), this.logQuery);
|
||||
if (token !== this.logToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.paintCard(this.painted);
|
||||
} catch {
|
||||
if (token !== this.logToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logPage = { total: 0, page: this.logQuery.page ?? 1, pageSize: this.logQuery.pageSize ?? 20, entries: [] };
|
||||
this.paintCard(this.painted);
|
||||
}
|
||||
}
|
||||
|
||||
private relocate(): void {
|
||||
const line = this.card.querySelector('.people__card-place');
|
||||
if (!(line instanceof HTMLElement) || this.painted === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
line.textContent = this.placeOf(this.painted.id);
|
||||
const place = this.placeOf(this.painted.id);
|
||||
line.textContent = place;
|
||||
const now = this.card.querySelector('.people__now-activity');
|
||||
if (now instanceof HTMLElement) {
|
||||
now.textContent = nowActivityText(this.painted, place === formatPersonPlace('away'));
|
||||
}
|
||||
}
|
||||
|
||||
private placeOf(id: string): string {
|
||||
@@ -388,11 +457,12 @@ export class PeoplePanel {
|
||||
private mountPersonTimetable(card: PersonCard): void {
|
||||
const schoolId = this.schoolId;
|
||||
const query = personTimetableQuery(card);
|
||||
if (schoolId === null || query === null) {
|
||||
const overview = this.card.querySelector('[data-card-tab="overview"]');
|
||||
if (!(overview instanceof HTMLElement) || schoolId === null || query === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.card.append(this.personTimetableTitle, this.personGrid.element);
|
||||
overview.append(this.personTimetableTitle, this.personGrid.element);
|
||||
this.personGrid.attach(schoolId);
|
||||
const token = this.cardToken;
|
||||
void fetchTimetable(schoolId, getLocale(), query)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import type { PersonCard } from '../net/api.ts';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { PersonCard, PersonLogPage } from '../net/api.ts';
|
||||
import { getLocale, setLocale } from '../i18n/locale.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { renderPersonCard } from './personCard.ts';
|
||||
import { renderPersonCard, type PersonCardTab } from './personCard.ts';
|
||||
|
||||
const initial = getLocale();
|
||||
|
||||
@@ -27,7 +27,7 @@ function card(overrides: Partial<PersonCard> = {}): PersonCard {
|
||||
classId: 'class-1',
|
||||
position: null,
|
||||
positionLabel: null,
|
||||
body: [],
|
||||
body: [{ id: 'Height', label: 'Рост', value: '164' }],
|
||||
skills: [],
|
||||
traits: [],
|
||||
needs: [],
|
||||
@@ -41,6 +41,7 @@ function card(overrides: Partial<PersonCard> = {}): PersonCard {
|
||||
color: 'White',
|
||||
colorLabel: 'Белый',
|
||||
layers: [{ defName: 'Top', label: 'Верх' }],
|
||||
condition: 1,
|
||||
},
|
||||
],
|
||||
carried: [
|
||||
@@ -56,22 +57,100 @@ function card(overrides: Partial<PersonCard> = {}): PersonCard {
|
||||
],
|
||||
carryMass: 1.2,
|
||||
carryCapacity: 14,
|
||||
hasLocker: false,
|
||||
homeCount: 2,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function logPage(overrides: Partial<PersonLogPage> = {}): PersonLogPage {
|
||||
return {
|
||||
total: 3,
|
||||
page: 1,
|
||||
pageSize: 2,
|
||||
entries: [
|
||||
{ time: '2012-04-03T12:00:00Z', type: 'action-started', label: 'начал: Обед', thingDef: 'EatLunch' },
|
||||
{ time: '2012-04-03T12:15:00Z', type: 'action-ended', label: 'закончил: Обед', thingDef: 'EatLunch' },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function tabButton(root: HTMLElement, tab: PersonCardTab): HTMLButtonElement {
|
||||
const button = [...root.querySelectorAll('button')].find((node) => node.dataset.tab === tab);
|
||||
if (!(button instanceof HTMLButtonElement)) {
|
||||
throw new Error(`missing tab ${tab}`);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
describe('renderPersonCard', () => {
|
||||
it('shows worn layers with colour and carried mass in the same scroll', () => {
|
||||
it('switches overview, clothes, carried and now', () => {
|
||||
setLocale('ru');
|
||||
const root = document.createElement('div');
|
||||
renderPersonCard(root, card(), () => {});
|
||||
|
||||
expect(root.textContent).toContain(t('peopleApparel'));
|
||||
expect(root.textContent).toContain('Верх');
|
||||
expect(root.querySelector('.people__tabs')).not.toBeNull();
|
||||
expect(root.querySelector('[data-card-tab="overview"]')?.hasAttribute('hidden')).toBe(false);
|
||||
expect(root.textContent).toContain('Рост');
|
||||
expect(root.querySelector('.people__log')).toBeNull();
|
||||
|
||||
tabButton(root, 'apparel').click();
|
||||
expect(root.querySelector('[data-card-tab="apparel"]')?.hasAttribute('hidden')).toBe(false);
|
||||
expect(root.querySelector('[data-card-tab="overview"]')?.hasAttribute('hidden')).toBe(true);
|
||||
expect(root.textContent).toContain('Рубашка · Белый');
|
||||
expect(root.textContent).toContain(t('peopleCarry'));
|
||||
expect(root.querySelector('.people__log')).toBeNull();
|
||||
|
||||
tabButton(root, 'carry').click();
|
||||
expect(root.textContent).toContain(t('peopleCarryMass', { held: '1.2', cap: '14' }));
|
||||
expect(root.textContent).toContain(t('peopleLockerNone'));
|
||||
expect(root.textContent).toContain(t('peopleHomeCount', { n: 2 }));
|
||||
expect(root.textContent).toContain('Учебник · Математика');
|
||||
expect(root.querySelector('.people__tabs')).toBeNull();
|
||||
|
||||
tabButton(root, 'now').click();
|
||||
expect(root.querySelector('[data-card-tab="now"]')?.hasAttribute('hidden')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not mount the log table on the clothes tab', () => {
|
||||
setLocale('ru');
|
||||
const root = document.createElement('div');
|
||||
renderPersonCard(root, card(), () => {}, { tab: 'apparel', log: logPage() });
|
||||
|
||||
expect(root.querySelector('[data-card-tab="apparel"]')?.hasAttribute('hidden')).toBe(false);
|
||||
expect(root.querySelector('.people__log')).toBeNull();
|
||||
expect(root.querySelector('.people__log-tools')).toBeNull();
|
||||
});
|
||||
|
||||
it('pages the log on the now tab', () => {
|
||||
setLocale('ru');
|
||||
const onLogPage = vi.fn();
|
||||
const root = document.createElement('div');
|
||||
renderPersonCard(root, card(), () => {}, {
|
||||
tab: 'now',
|
||||
log: logPage(),
|
||||
onLogPage,
|
||||
});
|
||||
|
||||
expect(root.querySelector('.people__log')).not.toBeNull();
|
||||
expect(root.textContent).toContain('начал: Обед');
|
||||
expect(root.textContent).toContain(t('peoplePager', { page: 1, pages: 2, total: 3 }));
|
||||
const next = [...root.querySelectorAll('.people__pager button')].at(-1);
|
||||
expect(next).toBeInstanceOf(HTMLButtonElement);
|
||||
(next as HTMLButtonElement).click();
|
||||
expect(onLogPage).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('shows home on now when activity is empty and the person is away', () => {
|
||||
setLocale('ru');
|
||||
const root = document.createElement('div');
|
||||
renderPersonCard(root, card({ activity: null, activityLabel: null }), () => {}, {
|
||||
tab: 'now',
|
||||
away: true,
|
||||
place: t('presenceAway'),
|
||||
});
|
||||
|
||||
expect(root.querySelector('.people__now-activity')?.textContent).toBe(t('peopleAtHome'));
|
||||
expect(root.querySelector('.people__now-activity')?.textContent).not.toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import type { PersonCard, PersonListItem, PersonRel, PersonRole } from '../net/api.ts';
|
||||
import type {
|
||||
PersonCard,
|
||||
PersonListItem,
|
||||
PersonLogDir,
|
||||
PersonLogPage,
|
||||
PersonLogQuery,
|
||||
PersonRel,
|
||||
PersonRole,
|
||||
} from '../net/api.ts';
|
||||
import { formatGameTimeOfDay } from '../format/gameTime.ts';
|
||||
import { t, type MessageKey } from '../i18n/strings.ts';
|
||||
import { el } from './dom.ts';
|
||||
|
||||
@@ -8,6 +17,20 @@ const ROLE_KEYS: Record<PersonRole, MessageKey> = {
|
||||
parent: 'peopleRoleParent',
|
||||
};
|
||||
|
||||
export type PersonCardTab = 'overview' | 'apparel' | 'carry' | 'now';
|
||||
|
||||
export interface RenderPersonCardOptions {
|
||||
readonly place?: string;
|
||||
readonly away?: boolean;
|
||||
readonly tab?: PersonCardTab;
|
||||
readonly log?: PersonLogPage | null;
|
||||
readonly logQuery?: PersonLogQuery;
|
||||
readonly onTab?: (tab: PersonCardTab) => void;
|
||||
readonly onLogSearch?: (query: string) => void;
|
||||
readonly onLogDir?: (dir: PersonLogDir) => void;
|
||||
readonly onLogPage?: (page: number) => void;
|
||||
}
|
||||
|
||||
export function roleLabels(roles: readonly string[]): string {
|
||||
return roles
|
||||
.map((role) => (role in ROLE_KEYS ? t(ROLE_KEYS[role as PersonRole]) : role))
|
||||
@@ -27,28 +50,90 @@ export function placement(person: Pick<PersonListItem, 'classYear' | 'classLette
|
||||
return parts.length > 0 ? parts.join(' · ') : '—';
|
||||
}
|
||||
|
||||
export function nowActivityText(card: PersonCard, away: boolean): string {
|
||||
if (away && (card.activityLabel === null || card.activityLabel.length === 0)) {
|
||||
return t('peopleAtHome');
|
||||
}
|
||||
|
||||
return card.activityLabel ?? '';
|
||||
}
|
||||
|
||||
export function renderPersonCard(
|
||||
parent: HTMLElement,
|
||||
card: PersonCard,
|
||||
onRelative: (id: string) => void,
|
||||
place?: string,
|
||||
options: RenderPersonCardOptions = {},
|
||||
): void {
|
||||
const tab = options.tab ?? 'overview';
|
||||
parent.append(
|
||||
el('h3', { class: 'people__card-name', text: card.fullName }),
|
||||
el('p', { class: 'people__card-meta', text: cardMeta(card) }),
|
||||
);
|
||||
if (place !== undefined && place.length > 0) {
|
||||
parent.append(el('p', { class: 'people__card-place', text: place }));
|
||||
if (options.place !== undefined && options.place.length > 0) {
|
||||
parent.append(el('p', { class: 'people__card-place', text: options.place }));
|
||||
}
|
||||
if (card.activityLabel !== null && card.activityLabel.length > 0) {
|
||||
parent.append(el('p', { class: 'people__card-activity', text: card.activityLabel }));
|
||||
|
||||
const tabs = el('div', { class: 'people__tabs' });
|
||||
const panels: Record<PersonCardTab, HTMLElement> = {
|
||||
overview: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'overview' } }),
|
||||
apparel: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'apparel' } }),
|
||||
carry: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'carry' } }),
|
||||
now: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'now' } }),
|
||||
};
|
||||
|
||||
const labels: Record<PersonCardTab, MessageKey> = {
|
||||
overview: 'peopleTabOverview',
|
||||
apparel: 'peopleTabApparel',
|
||||
carry: 'peopleTabCarry',
|
||||
now: 'peopleTabNow',
|
||||
};
|
||||
|
||||
const buttons: Partial<Record<PersonCardTab, HTMLButtonElement>> = {};
|
||||
for (const id of Object.keys(labels) as PersonCardTab[]) {
|
||||
const button = el('button', {
|
||||
class: 'people__tab',
|
||||
type: 'button',
|
||||
text: t(labels[id]),
|
||||
dataset: { tab: id },
|
||||
onClick: () => {
|
||||
showTab(id);
|
||||
options.onTab?.(id);
|
||||
},
|
||||
});
|
||||
button.classList.toggle('people__tab--active', id === tab);
|
||||
buttons[id] = button;
|
||||
tabs.append(button);
|
||||
}
|
||||
|
||||
fillOverview(panels.overview, card, onRelative);
|
||||
fillApparel(panels.apparel, card);
|
||||
fillCarry(panels.carry, card);
|
||||
fillNow(panels.now, card, options.away === true, tab === 'now' ? options.log : null, options);
|
||||
|
||||
const showTab = (next: PersonCardTab): void => {
|
||||
for (const id of Object.keys(panels) as PersonCardTab[]) {
|
||||
panels[id].hidden = id !== next;
|
||||
buttons[id]?.classList.toggle('people__tab--active', id === next);
|
||||
}
|
||||
|
||||
const log = panels.now.querySelector('.people__log, .people__log-tools');
|
||||
if (next !== 'now' && log !== null) {
|
||||
for (const node of [...panels.now.querySelectorAll('.people__log, .people__log-tools, .people__pager, .panel__empty')]) {
|
||||
node.remove();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
showTab(tab);
|
||||
|
||||
parent.append(tabs, panels.overview, panels.apparel, panels.carry, panels.now);
|
||||
}
|
||||
|
||||
function fillOverview(parent: HTMLElement, card: PersonCard, onRelative: (id: string) => void): void {
|
||||
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);
|
||||
appendApparel(parent, card.worn);
|
||||
appendCarry(parent, card);
|
||||
|
||||
const family = section(t('peopleFamily'));
|
||||
appendRelatives(family, t('peopleParents'), card.family.parents, onRelative);
|
||||
@@ -60,6 +145,179 @@ export function renderPersonCard(
|
||||
}
|
||||
}
|
||||
|
||||
function fillApparel(parent: HTMLElement, card: PersonCard): void {
|
||||
parent.append(el('p', { class: 'people__fit', text: t('peopleApparelFit', { value: '—' }) }));
|
||||
if (card.worn.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = el('div', { class: 'people__garments' });
|
||||
for (const row of card.worn) {
|
||||
const layers = row.layers.map((layer) => layer.label).join(', ');
|
||||
const color = row.colorLabel ?? row.color;
|
||||
const value = color !== null && color.length > 0 ? `${row.label} · ${color}` : row.label;
|
||||
const garment = el(
|
||||
'div',
|
||||
{ class: 'people__garment' },
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__pair' },
|
||||
el('dt', { text: layers.length > 0 ? layers : row.label }),
|
||||
el('dd', { text: value }),
|
||||
),
|
||||
);
|
||||
if (row.condition !== undefined) {
|
||||
const share = Math.max(0, Math.min(1, row.condition));
|
||||
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);
|
||||
const caption = row.conditionLabel !== undefined && row.conditionLabel !== null && row.conditionLabel.length > 0
|
||||
? row.conditionLabel
|
||||
: '—';
|
||||
garment.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__wear' },
|
||||
el('span', { class: 'people__wear-label', text: caption }),
|
||||
el('span', { class: 'people__need-track' }, fill),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
list.append(garment);
|
||||
}
|
||||
|
||||
parent.append(list);
|
||||
}
|
||||
|
||||
function fillCarry(parent: HTMLElement, card: PersonCard): void {
|
||||
parent.append(
|
||||
el('p', {
|
||||
class: 'people__carry-mass',
|
||||
text: t('peopleCarryMass', { held: formatMass(card.carryMass), cap: formatMass(card.carryCapacity) }),
|
||||
}),
|
||||
el('p', {
|
||||
class: 'people__locker',
|
||||
text: card.hasLocker ? t('peopleLockerYes') : t('peopleLockerNone'),
|
||||
}),
|
||||
el('p', {
|
||||
class: 'people__home-count',
|
||||
text: t('peopleHomeCount', { n: card.homeCount }),
|
||||
}),
|
||||
);
|
||||
if (card.carried.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const grid = el('dl', { class: 'people__pairs' });
|
||||
for (const row of card.carried) {
|
||||
const extra = row.subjectLabel ?? row.colorLabel;
|
||||
const value = extra !== null && extra.length > 0 ? `${row.label} · ${extra}` : row.label;
|
||||
grid.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__pair' },
|
||||
el('dt', { text: value }),
|
||||
el('dd', { text: formatMass(row.mass) }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
parent.append(grid);
|
||||
}
|
||||
|
||||
function fillNow(
|
||||
parent: HTMLElement,
|
||||
card: PersonCard,
|
||||
away: boolean,
|
||||
log: PersonLogPage | null | undefined,
|
||||
options: RenderPersonCardOptions,
|
||||
): void {
|
||||
const activity = nowActivityText(card, away);
|
||||
if (activity.length > 0) {
|
||||
parent.append(el('p', { class: 'people__now-activity', text: activity }));
|
||||
}
|
||||
|
||||
if (log === null || log === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const search = el('input', { class: 'input people__input', type: 'search' });
|
||||
search.placeholder = t('peopleLogSearch');
|
||||
search.value = options.logQuery?.q ?? '';
|
||||
search.addEventListener('change', () => options.onLogSearch?.(search.value));
|
||||
|
||||
const dir = el('select', { class: 'input people__input' });
|
||||
const desc = el('option', { text: t('peopleLogNewest') });
|
||||
desc.value = 'desc';
|
||||
const asc = el('option', { text: t('peopleLogOldest') });
|
||||
asc.value = 'asc';
|
||||
dir.append(desc, asc);
|
||||
dir.value = options.logQuery?.dir ?? 'desc';
|
||||
dir.addEventListener('change', () => options.onLogDir?.(dir.value === 'asc' ? 'asc' : 'desc'));
|
||||
|
||||
const table = el('table', { class: 'people__log' });
|
||||
const body = el('tbody');
|
||||
table.append(
|
||||
el(
|
||||
'thead',
|
||||
{},
|
||||
el(
|
||||
'tr',
|
||||
{},
|
||||
el('th', { text: t('peopleLogTime') }),
|
||||
el('th', { text: t('peopleLogEvent') }),
|
||||
),
|
||||
),
|
||||
body,
|
||||
);
|
||||
if (log.entries.length === 0) {
|
||||
parent.append(
|
||||
el('div', { class: 'people__log-tools' }, search, dir),
|
||||
el('p', { class: 'panel__empty', text: t('peopleLogEmpty') }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of log.entries) {
|
||||
body.append(
|
||||
el(
|
||||
'tr',
|
||||
{},
|
||||
el('td', { text: formatGameTimeOfDay(new Date(row.time)) }),
|
||||
el('td', { text: row.label }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const pages = Math.max(1, Math.ceil(log.total / log.pageSize));
|
||||
const prev = el('button', {
|
||||
class: 'button button--small',
|
||||
type: 'button',
|
||||
text: t('peoplePrev'),
|
||||
disabled: log.page <= 1,
|
||||
onClick: () => options.onLogPage?.(log.page - 1),
|
||||
});
|
||||
const next = el('button', {
|
||||
class: 'button button--small',
|
||||
type: 'button',
|
||||
text: t('peopleNext'),
|
||||
disabled: log.page >= pages,
|
||||
onClick: () => options.onLogPage?.(log.page + 1),
|
||||
});
|
||||
parent.append(
|
||||
el('div', { class: 'people__log-tools' }, search, dir),
|
||||
table,
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__pager' },
|
||||
prev,
|
||||
el('span', { class: 'people__pager-label', text: t('peoplePager', { page: log.page, pages, total: log.total }) }),
|
||||
next,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function cardMeta(card: PersonCard): string {
|
||||
const bits = [
|
||||
roleLabels(card.roles),
|
||||
@@ -114,54 +372,6 @@ function appendPairs(
|
||||
parent.append(section(title), grid);
|
||||
}
|
||||
|
||||
function appendApparel(parent: HTMLElement, rows: PersonCard['worn']): void {
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const grid = el('dl', { class: 'people__pairs' });
|
||||
for (const row of rows) {
|
||||
const layers = row.layers.map((layer) => layer.label).join(', ');
|
||||
const color = row.colorLabel ?? row.color;
|
||||
const value = color !== null && color.length > 0 ? `${row.label} · ${color}` : row.label;
|
||||
grid.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__pair' },
|
||||
el('dt', { text: layers.length > 0 ? layers : row.label }),
|
||||
el('dd', { text: value }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
parent.append(section(t('peopleApparel')), grid);
|
||||
}
|
||||
|
||||
function appendCarry(parent: HTMLElement, card: PersonCard): void {
|
||||
const mass = el('p', {
|
||||
class: 'people__carry-mass',
|
||||
text: t('peopleCarryMass', { held: formatMass(card.carryMass), cap: formatMass(card.carryCapacity) }),
|
||||
});
|
||||
const grid = el('dl', { class: 'people__pairs' });
|
||||
for (const row of card.carried) {
|
||||
const extra = row.subjectLabel ?? row.colorLabel;
|
||||
const value = extra !== null && extra.length > 0 ? `${row.label} · ${extra}` : row.label;
|
||||
grid.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__pair' },
|
||||
el('dt', { text: value }),
|
||||
el('dd', { text: formatMass(row.mass) }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
parent.append(section(t('peopleCarry')), mass);
|
||||
if (card.carried.length > 0) {
|
||||
parent.append(grid);
|
||||
}
|
||||
}
|
||||
|
||||
function formatMass(value: number): string {
|
||||
return String(Math.round(value * 100) / 100);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user