Compare commits
2 Commits
32fe3904e3
...
e88593a39c
| Author | SHA1 | Date | |
|---|---|---|---|
| e88593a39c | |||
| d62e800e96 |
+21
@@ -10,3 +10,24 @@
|
||||
max-width: 320px;
|
||||
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;
|
||||
}
|
||||
|
||||
+64
-10
@@ -53,7 +53,7 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
|
||||
|
||||
const [showForm, setShowForm] = React.useState(false);
|
||||
const [requestTypeId, setRequestTypeId] = React.useState('');
|
||||
const [email, setEmail] = React.useState('');
|
||||
const [emails, setEmails] = React.useState<string[]>([]);
|
||||
const submit = useAsyncSubmit();
|
||||
|
||||
const [filterRequestTypeId, setFilterRequestTypeId] = useLocalStorageState(ACCESS_FILTER_TYPE_STORAGE_KEY, '');
|
||||
@@ -97,7 +97,7 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
|
||||
|
||||
const openCreate = (): void => {
|
||||
setRequestTypeId('');
|
||||
setEmail('');
|
||||
setEmails([]);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
@@ -106,17 +106,56 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
|
||||
submit.fail(texts.requestTypeAccess.validationRequestType);
|
||||
return;
|
||||
}
|
||||
if (!email.trim()) {
|
||||
if (emails.length === 0) {
|
||||
submit.fail(texts.requestTypeAccess.validationEmail);
|
||||
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);
|
||||
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 (
|
||||
@@ -207,15 +246,30 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.requestTypeAccess.emailLabel} required full>
|
||||
<div>
|
||||
<SearchableSelect
|
||||
options={employees.items.map(toEmployeeOption)}
|
||||
value={email}
|
||||
options={employeeOptions}
|
||||
value=""
|
||||
loading={employees.isLoading}
|
||||
placeholder={texts.requestTypeAccess.emailPlaceholder}
|
||||
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||
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>
|
||||
</FormGrid>
|
||||
</FormModal>
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
|
||||
&:hover, &.active { background: $gold-50; color: $navy-900; }
|
||||
&.selected { font-weight: 600; color: $navy-900; }
|
||||
&:disabled { cursor: not-allowed; opacity: 0.55; background: transparent; color: $gray-700; }
|
||||
}
|
||||
|
||||
.itemSub {
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface ISearchableOption {
|
||||
label: string;
|
||||
/** Второстепенная строка под названием — например, компания у отдела. */
|
||||
sublabel?: string;
|
||||
/** Показывается в списке (и находится поиском), но выбрать нельзя. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ISearchableSelectProps {
|
||||
@@ -135,6 +137,7 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
|
||||
type="button"
|
||||
key={opt.value}
|
||||
className={`${styles.item} ${opt.value === value ? styles.selected : ''}`}
|
||||
disabled={opt.disabled}
|
||||
onClick={() => pick(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
|
||||
@@ -198,10 +198,16 @@ export const texts = {
|
||||
createTitle: 'Выдать доступ',
|
||||
requestTypeLabel: 'Тип заявки',
|
||||
requestTypePlaceholder: 'Выберите тип заявки',
|
||||
emailLabel: 'Сотрудник',
|
||||
emailPlaceholder: 'Найдите по ФИО или email',
|
||||
emailLabel: 'Сотрудники',
|
||||
emailPlaceholder: 'Добавьте сотрудника — найдите по ФИО или email',
|
||||
removeEmail: 'Убрать из списка',
|
||||
alreadyGranted: 'доступ уже есть',
|
||||
alreadyChosen: 'уже в списке',
|
||||
noEmailsSelected: 'Пока никого не выбрано.',
|
||||
createFailed: (failures: string[]): string =>
|
||||
`Не удалось выдать доступ (остались в списке): ${failures.join('; ')}`,
|
||||
validationRequestType: 'Выберите тип заявки.',
|
||||
validationEmail: 'Выберите сотрудника.',
|
||||
validationEmail: 'Выберите хотя бы одного сотрудника.',
|
||||
deleteTitle: 'Отозвать доступ?',
|
||||
deleteText: (email: string, requestTypeName: string): string =>
|
||||
`${email} больше не будет видеть все заявки типа «${requestTypeName}».`
|
||||
|
||||
Reference in New Issue
Block a user