feat: add manager employee and timesheet workflows
This commit is contained in:
parent
3464998168
commit
32e3c6f7f7
5 changed files with 314 additions and 9 deletions
|
|
@ -87,6 +87,51 @@ paths:
|
|||
application/json:
|
||||
schema: { type: object }
|
||||
'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:
|
||||
post:
|
||||
summary: Принять идемпотентную отметку терминала
|
||||
|
|
@ -165,6 +210,40 @@ components:
|
|||
required: [status]
|
||||
properties:
|
||||
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:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
|
|
|
|||
15
docs/DESIGN-BRIEF-MANAGER-PORTAL.md
Normal file
15
docs/DESIGN-BRIEF-MANAGER-PORTAL.md
Normal 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
130
src/portal.ts
130
src/portal.ts
|
|
@ -23,6 +23,21 @@ interface TimesheetBody {
|
|||
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 {
|
||||
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', {
|
||||
schema: {
|
||||
body: {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ function fakeDatabase(rows: Array<Array<Record<string, unknown>>>) {
|
|||
const calls: Array<[string, readonly unknown[] | undefined]> = [];
|
||||
const query = async (sql: string, parameters?: readonly unknown[]) => {
|
||||
calls.push([sql, parameters]);
|
||||
return { rows: rows.shift() ?? [], rowCount: 1 };
|
||||
const resultRows = rows.shift() ?? [];
|
||||
return { rows: resultRows, rowCount: resultRows.length };
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
|
|
@ -134,4 +135,74 @@ describe('attendance portal', () => {
|
|||
expect(response.statusCode).toBe(403);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue