All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m9s
Единая страница /team для ролей admin/manager (backend сам скоупит список по org-изоляции) — таблица сотрудников с пагинацией, создание сотрудника с ручным паролем, изменение месячной квоты + сброс пароля одним PATCH, drawer с историей оценок. Nav-пункт «Команда» в Topbar виден только admin/manager (доп. roleGate поверх isPathAllowed — legacy analyst-роль иначе тоже прошла бы path-фильтр).
219 lines
7.4 KiB
TypeScript
219 lines
7.4 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* Модалка создания сотрудника (#2556, эпик #2549) — `POST /api/v1/team/employees`.
|
||
*
|
||
* Пароль задаётся вручную менеджером/админом — email-рассылки нет (см. issue
|
||
* DoD), поэтому после успешного создания показываем явное «передайте пароль
|
||
* сотруднику» вместо тихого закрытия модалки.
|
||
*/
|
||
|
||
import { useState } from "react";
|
||
import type { FormEvent } from "react";
|
||
|
||
import {
|
||
USERNAME_HINT,
|
||
USERNAME_PATTERN,
|
||
teamErrorMessage,
|
||
useCreateEmployee,
|
||
} from "@/lib/team-api";
|
||
|
||
interface CreateEmployeeFormProps {
|
||
onClose: () => void;
|
||
}
|
||
|
||
export function CreateEmployeeForm({ onClose }: CreateEmployeeFormProps) {
|
||
const [username, setUsername] = useState("");
|
||
const [password, setPassword] = useState("");
|
||
const [showPassword, setShowPassword] = useState(false);
|
||
const [displayName, setDisplayName] = useState("");
|
||
const [orgName, setOrgName] = useState("");
|
||
const [email, setEmail] = useState("");
|
||
const [monthlyLimit, setMonthlyLimit] = useState("");
|
||
const [usernameTouched, setUsernameTouched] = useState(false);
|
||
|
||
const createMutation = useCreateEmployee();
|
||
|
||
const usernameValid = USERNAME_PATTERN.test(username);
|
||
const usernameInvalid = usernameTouched && username.length > 0 && !usernameValid;
|
||
|
||
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||
e.preventDefault();
|
||
setUsernameTouched(true);
|
||
if (createMutation.isPending) return;
|
||
if (!USERNAME_PATTERN.test(username)) return;
|
||
if (password.length === 0) return;
|
||
|
||
const parsedLimit = monthlyLimit.trim() === "" ? undefined : Number(monthlyLimit);
|
||
|
||
createMutation.mutate({
|
||
username: username.trim(),
|
||
password,
|
||
display_name: displayName.trim() || undefined,
|
||
org_name: orgName.trim() || undefined,
|
||
email: email.trim() || undefined,
|
||
monthly_limit:
|
||
parsedLimit !== undefined && Number.isFinite(parsedLimit) && parsedLimit >= 1
|
||
? Math.trunc(parsedLimit)
|
||
: undefined,
|
||
});
|
||
}
|
||
|
||
if (createMutation.isSuccess) {
|
||
return (
|
||
<div className="team-modal-backdrop" role="presentation" onClick={onClose}>
|
||
<div
|
||
className="team-modal"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="Сотрудник создан"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<h2>Сотрудник создан</h2>
|
||
<p className="team-form-success">
|
||
Логин «{createMutation.data.username}» готов. Передайте пароль сотруднику —
|
||
он не сохраняется в системе и не отправляется автоматически.
|
||
</p>
|
||
<div className="team-modal-actions">
|
||
<button type="button" className="team-btn-primary" onClick={onClose}>
|
||
Готово
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="team-modal-backdrop" role="presentation" onClick={onClose}>
|
||
<form
|
||
className="team-modal"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="Новый сотрудник"
|
||
onClick={(e) => e.stopPropagation()}
|
||
onSubmit={handleSubmit}
|
||
>
|
||
<h2>Новый сотрудник</h2>
|
||
<p className="team-modal-subtitle">
|
||
Пароль задаётся вручную — передайте его сотруднику лично, рассылки нет.
|
||
</p>
|
||
|
||
{createMutation.isError ? (
|
||
<p role="alert" className="team-form-error">
|
||
{teamErrorMessage(createMutation.error)}
|
||
</p>
|
||
) : null}
|
||
|
||
<div className="team-field">
|
||
<label htmlFor="team-new-username">Логин</label>
|
||
<input
|
||
id="team-new-username"
|
||
name="username"
|
||
type="text"
|
||
autoComplete="off"
|
||
required
|
||
value={username}
|
||
onChange={(e) => setUsername(e.target.value)}
|
||
onBlur={() => setUsernameTouched(true)}
|
||
disabled={createMutation.isPending}
|
||
aria-invalid={usernameInvalid}
|
||
/>
|
||
<p className="team-field-hint">{USERNAME_HINT}</p>
|
||
{usernameInvalid ? (
|
||
<p className="team-field-hint" style={{ color: "var(--danger, #b3261e)" }}>
|
||
Логин не соответствует формату
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="team-field team-password-row">
|
||
<label htmlFor="team-new-password">Пароль</label>
|
||
<input
|
||
id="team-new-password"
|
||
name="password"
|
||
type={showPassword ? "text" : "password"}
|
||
autoComplete="new-password"
|
||
required
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
disabled={createMutation.isPending}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="team-password-toggle"
|
||
onClick={() => setShowPassword((v) => !v)}
|
||
tabIndex={-1}
|
||
>
|
||
{showPassword ? "Скрыть" : "Показать"}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="team-field">
|
||
<label htmlFor="team-new-display-name">Имя (необязательно)</label>
|
||
<input
|
||
id="team-new-display-name"
|
||
type="text"
|
||
value={displayName}
|
||
onChange={(e) => setDisplayName(e.target.value)}
|
||
disabled={createMutation.isPending}
|
||
/>
|
||
</div>
|
||
|
||
<div className="team-field">
|
||
<label htmlFor="team-new-org">Организация (необязательно)</label>
|
||
<input
|
||
id="team-new-org"
|
||
type="text"
|
||
value={orgName}
|
||
onChange={(e) => setOrgName(e.target.value)}
|
||
disabled={createMutation.isPending}
|
||
/>
|
||
</div>
|
||
|
||
<div className="team-field">
|
||
<label htmlFor="team-new-email">Email (необязательно)</label>
|
||
<input
|
||
id="team-new-email"
|
||
type="email"
|
||
value={email}
|
||
onChange={(e) => setEmail(e.target.value)}
|
||
disabled={createMutation.isPending}
|
||
/>
|
||
</div>
|
||
|
||
<div className="team-field">
|
||
<label htmlFor="team-new-limit">Месячный лимит оценок (необязательно)</label>
|
||
<input
|
||
id="team-new-limit"
|
||
type="number"
|
||
min={1}
|
||
step={1}
|
||
placeholder="по умолчанию"
|
||
value={monthlyLimit}
|
||
onChange={(e) => setMonthlyLimit(e.target.value)}
|
||
disabled={createMutation.isPending}
|
||
/>
|
||
</div>
|
||
|
||
<div className="team-modal-actions">
|
||
<button
|
||
type="button"
|
||
className="team-btn-secondary"
|
||
onClick={onClose}
|
||
disabled={createMutation.isPending}
|
||
>
|
||
Отмена
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="team-btn-primary"
|
||
disabled={createMutation.isPending || password.length === 0 || !usernameValid}
|
||
>
|
||
{createMutation.isPending ? "Создаём…" : "Создать"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|