2 Commits

Author SHA1 Message Date
gorbenko.pavel e88593a39c Доступ к заявкам: сотрудники с уже выданным доступом показываются в поиске с пометкой и недоступны для выбора
Tests / frontend-test (pull_request) Successful in 1m37s
Tests / backend-test (pull_request) Successful in 2m5s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 12:10:40 +05:00
gorbenko.pavel d62e800e96 Доступ к заявкам: выдача доступа сразу нескольким сотрудникам по одному типу заявки
В форме можно добавить несколько сотрудников списком; уже имеющие доступ
к выбранному типу не предлагаются. Ошибки по отдельным сотрудникам не отменяют
остальных — неудавшиеся остаются в списке.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 12:09:22 +05:00
5 changed files with 105 additions and 20 deletions
@@ -10,3 +10,24 @@
max-width: 320px; max-width: 320px;
flex: 1; flex: 1;
} }
.chosen {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 10px;
}
.chosenItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 4px 8px 4px 12px;
border: 1px solid #e1e4ea;
border-radius: 6px;
}
.chosenEmpty {
opacity: 0.6;
}
@@ -53,7 +53,7 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
const [showForm, setShowForm] = React.useState(false); const [showForm, setShowForm] = React.useState(false);
const [requestTypeId, setRequestTypeId] = React.useState(''); const [requestTypeId, setRequestTypeId] = React.useState('');
const [email, setEmail] = React.useState(''); const [emails, setEmails] = React.useState<string[]>([]);
const submit = useAsyncSubmit(); const submit = useAsyncSubmit();
const [filterRequestTypeId, setFilterRequestTypeId] = useLocalStorageState(ACCESS_FILTER_TYPE_STORAGE_KEY, ''); const [filterRequestTypeId, setFilterRequestTypeId] = useLocalStorageState(ACCESS_FILTER_TYPE_STORAGE_KEY, '');
@@ -97,7 +97,7 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
const openCreate = (): void => { const openCreate = (): void => {
setRequestTypeId(''); setRequestTypeId('');
setEmail(''); setEmails([]);
setShowForm(true); setShowForm(true);
}; };
@@ -106,17 +106,56 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
submit.fail(texts.requestTypeAccess.validationRequestType); submit.fail(texts.requestTypeAccess.validationRequestType);
return; return;
} }
if (!email.trim()) { if (emails.length === 0) {
submit.fail(texts.requestTypeAccess.validationEmail); submit.fail(texts.requestTypeAccess.validationEmail);
return; return;
} }
submit.run(() => // По одному запросу на сотрудника (бэкенд выдаёт доступ по одному). Падение на
api.requestTypeAccess.create({ requestTypeId: Number(requestTypeId), email: email.trim() }).then(() => { // одном не отменяет остальных: неудавшиеся остаются в списке с причиной, чтобы
// можно было поправить и нажать ещё раз.
submit.run(async () => {
const failures: string[] = [];
const failedEmails: string[] = [];
for (const email of emails) {
try {
await api.requestTypeAccess.create({ requestTypeId: Number(requestTypeId), email });
} catch (e) {
failedEmails.push(email);
failures.push(`${email}: ${(e as Error).message}`);
}
}
if (failedEmails.length < emails.length) access.reload();
if (failures.length === 0) {
setShowForm(false); setShowForm(false);
access.reload(); return;
}) }
setEmails(failedEmails);
throw new Error(texts.requestTypeAccess.createFailed(failures));
});
};
// Тех, у кого доступ к этому типу уже есть (и кого уже добавили в список), показываем
// в поиске с пометкой, но выбрать нельзя — так видно, что человек не потерялся.
const grantedEmails = new Set(
access.items.filter(a => String(a.requestTypeId) === requestTypeId).map(a => a.email.toLowerCase())
); );
const chosenEmails = new Set(emails.map(e => e.toLowerCase()));
const employeeOptions = employees.items.map(employee => {
const option = toEmployeeOption(employee);
const key = employee.email.toLowerCase();
if (grantedEmails.has(key)) {
return { ...option, disabled: true, sublabel: `${option.sublabel ?? ''} · ${texts.requestTypeAccess.alreadyGranted}` };
}
if (chosenEmails.has(key)) {
return { ...option, disabled: true, sublabel: `${option.sublabel ?? ''} · ${texts.requestTypeAccess.alreadyChosen}` };
}
return option;
});
const employeeLabel = (email: string): string => {
const employee = employees.items.find(e => e.email.toLowerCase() === email.toLowerCase());
return employee ? `${employee.fio} · ${employee.email}` : email;
}; };
return ( return (
@@ -207,15 +246,30 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
/> />
</Field> </Field>
<Field label={texts.requestTypeAccess.emailLabel} required full> <Field label={texts.requestTypeAccess.emailLabel} required full>
<div>
<SearchableSelect <SearchableSelect
options={employees.items.map(toEmployeeOption)} options={employeeOptions}
value={email} value=""
loading={employees.isLoading} loading={employees.isLoading}
placeholder={texts.requestTypeAccess.emailPlaceholder} placeholder={texts.requestTypeAccess.emailPlaceholder}
searchPlaceholder={texts.select.employeeSearchPlaceholder} searchPlaceholder={texts.select.employeeSearchPlaceholder}
emptyMessage={texts.select.employeesEmpty} emptyMessage={texts.select.employeesEmpty}
onChange={setEmail} onChange={value => value && setEmails(current => [...current, value])}
/> />
<div className={styles.chosen}>
{emails.length === 0 && <span className={styles.chosenEmpty}>{texts.requestTypeAccess.noEmailsSelected}</span>}
{emails.map(email => (
<div key={email} className={styles.chosenItem}>
<span>{employeeLabel(email)}</span>
<IconButton
icon="close"
label={texts.requestTypeAccess.removeEmail}
onClick={() => setEmails(current => current.filter(x => x !== email))}
/>
</div>
))}
</div>
</div>
</Field> </Field>
</FormGrid> </FormGrid>
</FormModal> </FormModal>
@@ -116,6 +116,7 @@
&:hover, &.active { background: $gold-50; color: $navy-900; } &:hover, &.active { background: $gold-50; color: $navy-900; }
&.selected { font-weight: 600; color: $navy-900; } &.selected { font-weight: 600; color: $navy-900; }
&:disabled { cursor: not-allowed; opacity: 0.55; background: transparent; color: $gray-700; }
} }
.itemSub { .itemSub {
@@ -9,6 +9,8 @@ export interface ISearchableOption {
label: string; label: string;
/** Второстепенная строка под названием — например, компания у отдела. */ /** Второстепенная строка под названием — например, компания у отдела. */
sublabel?: string; sublabel?: string;
/** Показывается в списке (и находится поиском), но выбрать нельзя. */
disabled?: boolean;
} }
export interface ISearchableSelectProps { export interface ISearchableSelectProps {
@@ -135,6 +137,7 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
type="button" type="button"
key={opt.value} key={opt.value}
className={`${styles.item} ${opt.value === value ? styles.selected : ''}`} className={`${styles.item} ${opt.value === value ? styles.selected : ''}`}
disabled={opt.disabled}
onClick={() => pick(opt.value)} onClick={() => pick(opt.value)}
> >
{opt.label} {opt.label}
+9 -3
View File
@@ -198,10 +198,16 @@ export const texts = {
createTitle: 'Выдать доступ', createTitle: 'Выдать доступ',
requestTypeLabel: 'Тип заявки', requestTypeLabel: 'Тип заявки',
requestTypePlaceholder: 'Выберите тип заявки', requestTypePlaceholder: 'Выберите тип заявки',
emailLabel: 'Сотрудник', emailLabel: 'Сотрудники',
emailPlaceholder: 'Найдите по ФИО или email', emailPlaceholder: 'Добавьте сотрудника — найдите по ФИО или email',
removeEmail: 'Убрать из списка',
alreadyGranted: 'доступ уже есть',
alreadyChosen: 'уже в списке',
noEmailsSelected: 'Пока никого не выбрано.',
createFailed: (failures: string[]): string =>
`Не удалось выдать доступ (остались в списке): ${failures.join('; ')}`,
validationRequestType: 'Выберите тип заявки.', validationRequestType: 'Выберите тип заявки.',
validationEmail: 'Выберите сотрудника.', validationEmail: 'Выберите хотя бы одного сотрудника.',
deleteTitle: 'Отозвать доступ?', deleteTitle: 'Отозвать доступ?',
deleteText: (email: string, requestTypeName: string): string => deleteText: (email: string, requestTypeName: string): string =>
`${email} больше не будет видеть все заявки типа «${requestTypeName}».` `${email} больше не будет видеть все заявки типа «${requestTypeName}».`