feat: add manager employee and timesheet workflows

This commit is contained in:
Ochenstarik 2026-09-24 16:32:30 +07:00
parent 3464998168
commit 32e3c6f7f7
5 changed files with 314 additions and 9 deletions

View file

@ -87,6 +87,51 @@ paths:
application/json: application/json:
schema: { type: object } schema: { type: object }
'403': { description: Недостаточно прав } '403': { description: Недостаточно прав }
/employees:
post:
summary: Добавить сотрудника
operationId: createStaffEmployee
tags: [Employees]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/EmployeeCreate'
responses:
'201':
description: Сотрудник создан
content:
application/json:
schema: { $ref: '#/components/schemas/Employee' }
'403': { description: Недостаточно прав }
'409': { description: Табельный номер уже используется }
/employees/{employeeId}:
patch:
summary: Изменить карточку или статус сотрудника
operationId: updateStaffEmployee
tags: [Employees]
security: [{ bearerAuth: [] }]
parameters:
- in: path
name: employeeId
required: true
schema: { $ref: '#/components/schemas/Ulid' }
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/EmployeeUpdate'
responses:
'200':
description: Сотрудник изменён
content:
application/json:
schema: { $ref: '#/components/schemas/Employee' }
'403': { description: Недостаточно прав }
'404': { description: Сотрудник не найден }
/attendance/events: /attendance/events:
post: post:
summary: Принять идемпотентную отметку терминала summary: Принять идемпотентную отметку терминала
@ -165,6 +210,40 @@ components:
required: [status] required: [status]
properties: properties:
status: { type: string } status: { type: string }
Employee:
type: object
additionalProperties: false
required: [id, personnel_number, display_name, position, gender, status]
properties:
id: { $ref: '#/components/schemas/Ulid' }
core_user_id:
oneOf:
- { $ref: '#/components/schemas/Ulid' }
- { type: 'null' }
personnel_number: { type: string }
display_name: { type: string }
position: { type: string }
gender: { enum: ['', male, female] }
status: { enum: [active, blocked, dismissed] }
EmployeeCreate:
type: object
additionalProperties: false
required: [personnel_number, display_name]
properties:
personnel_number: { type: string, minLength: 1, maxLength: 80 }
display_name: { type: string, minLength: 1, maxLength: 240 }
position: { type: string, maxLength: 160 }
gender: { enum: ['', male, female] }
EmployeeUpdate:
type: object
additionalProperties: false
minProperties: 1
properties:
personnel_number: { type: string, minLength: 1, maxLength: 80 }
display_name: { type: string, minLength: 1, maxLength: 240 }
position: { type: string, maxLength: 160 }
gender: { enum: ['', male, female] }
status: { enum: [active, blocked, dismissed] }
Problem: Problem:
type: object type: object
additionalProperties: false additionalProperties: false

View file

@ -0,0 +1,15 @@
# Design brief: кабинет руководителя
- **Назначение:** ежедневный контроль присутствия, сотрудников и ручное заполнение табеля руководителем небольшой организации.
- **Контекст:** защищённый операционный кабинет, а не маркетинговая страница.
- **Направление:** спокойный плотный рабочий интерфейс с природной зелёной основой и золотым акцентом бренда Krona.
- **Отличительная идея:** хронология отметок и табель остаются главным рабочим полотном, а управление сотрудниками доступно рядом и не уводит на отдельную техническую страницу.
- **Ограничения:** существующий Fastify/PWA без нового frontend-фреймворка; адаптивность от мобильного браузера до рабочего монитора; клавиатурная навигация; reduced motion; никакой биометрии и секретов в браузере.
## Локальная система
- Цвета: тёплый рабочий фон, светлые поверхности, зелёный primary, золотой brand accent, семантические danger/info состояния.
- Типографика: Georgia для крупных смысловых заголовков, Segoe UI для плотных управляющих элементов и таблиц.
- Плотность: 44 px минимальная высота интерактивного элемента, компактные строки таблицы, адаптивный toolbar.
- Формы: явные подписи, видимый focus, диалог для карточки сотрудника, встроенная строка создания записи табеля.
- Движение: только короткие переходы состояния; полностью отключается через `prefers-reduced-motion`.

File diff suppressed because one or more lines are too long

View file

@ -23,6 +23,21 @@ interface TimesheetBody {
note?: string; note?: string;
} }
interface EmployeeBody {
personnel_number: string;
display_name: string;
position?: string;
gender?: 'male' | 'female' | '';
}
interface EmployeePatchBody {
personnel_number?: string;
display_name?: string;
position?: string;
gender?: 'male' | 'female' | '';
status?: 'active' | 'blocked' | 'dismissed';
}
interface SessionBody { interface SessionBody {
access_token: string; access_token: string;
} }
@ -115,6 +130,121 @@ export async function registerPortalRoutes(
}); });
}); });
api.post<{ Body: EmployeeBody }>('/employees', {
schema: {
body: {
type: 'object', additionalProperties: false,
required: ['personnel_number', 'display_name'],
properties: {
personnel_number: { type: 'string', minLength: 1, maxLength: 80 },
display_name: { type: 'string', minLength: 1, maxLength: 240 },
position: { type: 'string', maxLength: 160 },
gender: { enum: ['', 'male', 'female'] },
},
},
},
}, async (request, reply) => {
assertCookieMutationOrigin(request);
const context = await requirePermission(identity, request, 'staff:employee:write');
const employeeId = newUlid();
const personnelNumber = request.body.personnel_number.trim();
const displayName = request.body.display_name.trim();
if (!personnelNumber || !displayName) return reply.badRequest('Табельный номер и имя обязательны.');
const employee = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
const saved = await client.query(
`INSERT INTO staff_employees
(id, tenant_id, personnel_number, display_name, position, gender, status)
VALUES ($1, $2, $3, $4, $5, $6, 'active')
ON CONFLICT (tenant_id, personnel_number) DO NOTHING
RETURNING id, core_user_id, personnel_number, display_name, position, gender, status`,
[employeeId, context.tenantId, personnelNumber, displayName,
request.body.position?.trim() ?? '', request.body.gender ?? ''],
);
if (saved.rowCount === 0) return null;
const result = saved.rows[0] as Record<string, unknown>;
await client.query(
`INSERT INTO staff_audit_log
(id, tenant_id, actor_id, action, entity_type, entity_id, new_values, correlation_id)
VALUES ($1, $2, $3, 'employee.created', 'Employee', $4, $5, $6)`,
[newUlid(), context.tenantId, context.actorId, employeeId, result, context.correlationId],
);
return result;
});
if (!employee) return reply.conflict('Сотрудник с таким табельным номером уже существует.');
return reply.code(201).send(employee);
});
api.patch<{ Params: { employeeId: string }; Body: EmployeePatchBody }>('/employees/:employeeId', {
schema: {
params: { type: 'object', required: ['employeeId'], properties: {
employeeId: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' },
} },
body: {
type: 'object', additionalProperties: false, minProperties: 1,
properties: {
personnel_number: { type: 'string', minLength: 1, maxLength: 80 },
display_name: { type: 'string', minLength: 1, maxLength: 240 },
position: { type: 'string', maxLength: 160 },
gender: { enum: ['', 'male', 'female'] },
status: { enum: ['active', 'blocked', 'dismissed'] },
},
},
},
}, async (request, reply) => {
assertCookieMutationOrigin(request);
const context = await requirePermission(identity, request, 'staff:employee:write');
const employeeId = parseUlid(request.params.employeeId);
const personnelNumber = request.body.personnel_number?.trim();
const displayName = request.body.display_name?.trim();
if (personnelNumber === '' || displayName === '') return reply.badRequest('Табельный номер и имя не могут быть пустыми.');
const outcome = await database.tenantTransaction(context.tenantId, context.actorId, async (client) => {
const current = await client.query(
`SELECT id, core_user_id, personnel_number, display_name, position, gender, status
FROM staff_employees WHERE tenant_id = $1 AND id = $2 FOR UPDATE`,
[context.tenantId, employeeId],
);
if (current.rowCount === 0) return { kind: 'missing' as const };
if (personnelNumber) {
const duplicate = await client.query(
`SELECT id FROM staff_employees
WHERE tenant_id = $1 AND personnel_number = $2 AND id <> $3 LIMIT 1`,
[context.tenantId, personnelNumber, employeeId],
);
if ((duplicate.rowCount ?? 0) > 0) return { kind: 'conflict' as const };
}
const saved = await client.query(
`UPDATE staff_employees SET
personnel_number = COALESCE($3, personnel_number),
display_name = COALESCE($4, display_name),
position = COALESCE($5, position),
gender = COALESCE($6, gender),
status = COALESCE($7, status),
updated_at = now()
WHERE tenant_id = $1 AND id = $2
RETURNING id, core_user_id, personnel_number, display_name, position, gender, status`,
[context.tenantId, employeeId, personnelNumber, displayName,
request.body.position?.trim(), request.body.gender, request.body.status],
);
const result = saved.rows[0] as Record<string, unknown>;
await client.query(
`INSERT INTO staff_audit_log
(id, tenant_id, actor_id, action, entity_type, entity_id,
old_values, new_values, correlation_id)
VALUES ($1, $2, $3, 'employee.updated', 'Employee', $4, $5, $6, $7)`,
[newUlid(), context.tenantId, context.actorId, employeeId,
current.rows[0], result, context.correlationId],
);
return { kind: 'saved' as const, employee: result };
});
if (outcome.kind === 'missing') return reply.notFound('Сотрудник не найден.');
if (outcome.kind === 'conflict') {
return reply.conflict('Сотрудник с таким табельным номером уже существует.');
}
return reply.send(outcome.employee);
});
api.post<{ Body: AttendanceBody }>('/attendance/events', { api.post<{ Body: AttendanceBody }>('/attendance/events', {
schema: { schema: {
body: { body: {

View file

@ -21,7 +21,8 @@ function fakeDatabase(rows: Array<Array<Record<string, unknown>>>) {
const calls: Array<[string, readonly unknown[] | undefined]> = []; const calls: Array<[string, readonly unknown[] | undefined]> = [];
const query = async (sql: string, parameters?: readonly unknown[]) => { const query = async (sql: string, parameters?: readonly unknown[]) => {
calls.push([sql, parameters]); calls.push([sql, parameters]);
return { rows: rows.shift() ?? [], rowCount: 1 }; const resultRows = rows.shift() ?? [];
return { rows: resultRows, rowCount: resultRows.length };
}; };
return { return {
calls, calls,
@ -134,4 +135,74 @@ describe('attendance portal', () => {
expect(response.statusCode).toBe(403); expect(response.statusCode).toBe(403);
await app.close(); await app.close();
}); });
it('allows a manager to create an employee and writes an audit record', async () => {
const employeeId = '01J00000000000000000000004';
const { database, calls } = fakeDatabase([[{
id: employeeId,
personnel_number: 'A-17',
display_name: 'Анна Орлова',
position: 'Администратор',
gender: 'female',
status: 'active',
}]]);
const app = await buildApp({
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
database,
});
const response = await app.inject({
method: 'POST',
url: '/api/v1/staff/employees',
headers: { authorization: `Bearer ${managerToken}` },
payload: {
personnel_number: 'A-17',
display_name: 'Анна Орлова',
position: 'Администратор',
gender: 'female',
},
});
expect(response.statusCode).toBe(201);
expect(calls).toHaveLength(2);
expect(calls[0]?.[0]).toContain('INSERT INTO staff_employees');
expect(calls[1]?.[0]).toContain('staff_audit_log');
await app.close();
});
it('does not allow an employee to create another employee', async () => {
const { database } = fakeDatabase([]);
const app = await buildApp({
config: loadConfig({ ...base, STANDALONE_ACCESS: 'employee' }),
database,
});
const response = await app.inject({
method: 'POST',
url: '/api/v1/staff/employees',
payload: { personnel_number: 'A-18', display_name: 'Посторонний' },
});
expect(response.statusCode).toBe(403);
await app.close();
});
it('updates an employee with before and after values in the audit trail', async () => {
const employeeId = '01J00000000000000000000004';
const before = { id: employeeId, core_user_id: null, personnel_number: 'A-17', display_name: 'Анна Орлова', position: 'Администратор', gender: 'female', status: 'active' };
const after = { ...before, position: 'Старший администратор' };
const { database, calls } = fakeDatabase([[before], [after], []]);
const app = await buildApp({
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
database,
});
const response = await app.inject({
method: 'PATCH',
url: `/api/v1/staff/employees/${employeeId}`,
headers: { authorization: `Bearer ${managerToken}` },
payload: { position: 'Старший администратор' },
});
expect(response.statusCode).toBe(200);
expect(calls).toHaveLength(3);
expect(calls[0]?.[0]).toContain('FOR UPDATE');
expect(calls[2]?.[1]?.[4]).toEqual(before);
expect(calls[2]?.[1]?.[5]).toEqual(after);
await app.close();
});
}); });