Feature/routes export (#29)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/29
This commit was merged in pull request #29.
This commit is contained in:
+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;
|
||||
}
|
||||
|
||||
+71
-17
@@ -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>
|
||||
<SearchableSelect
|
||||
options={employees.items.map(toEmployeeOption)}
|
||||
value={email}
|
||||
loading={employees.isLoading}
|
||||
placeholder={texts.requestTypeAccess.emailPlaceholder}
|
||||
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||
emptyMessage={texts.select.employeesEmpty}
|
||||
onChange={setEmail}
|
||||
/>
|
||||
<div>
|
||||
<SearchableSelect
|
||||
options={employeeOptions}
|
||||
value=""
|
||||
loading={employees.isLoading}
|
||||
placeholder={texts.requestTypeAccess.emailPlaceholder}
|
||||
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||
emptyMessage={texts.select.employeesEmpty}
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import * as React from 'react';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { texts } from '../../texts';
|
||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
import { reportError } from '../../utils/errors';
|
||||
import { Alert, Breadcrumbs, Button, Checkbox, PageHeader, Panel } from '../ui';
|
||||
|
||||
export interface IRoutesExportScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
}
|
||||
|
||||
// Выбранные типы заявок переживают перезагрузку страницы — выгрузку обычно
|
||||
// делают по одному и тому же набору типов.
|
||||
const SELECTED_STORAGE_KEY = 'approvalAdmin.routesExport.selected';
|
||||
|
||||
function download(blob: Blob, fileName: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api }) => {
|
||||
const requestTypes = useRequestTypes(api);
|
||||
|
||||
const [storedSelected, setSelected] = useLocalStorageState<number[]>(SELECTED_STORAGE_KEY, []);
|
||||
const [isBusy, setIsBusy] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | undefined>();
|
||||
|
||||
const allIds = requestTypes.items.map(rt => rt.id);
|
||||
// Удалённые с тех пор типы из сохранённого выбора не считаем.
|
||||
const selected = storedSelected.filter(id => allIds.indexOf(id) !== -1);
|
||||
const allSelected = allIds.length > 0 && allIds.every(id => selected.indexOf(id) !== -1);
|
||||
|
||||
const toggle = (id: number, checked: boolean): void =>
|
||||
setSelected(current => (checked ? [...current.filter(x => x !== id), id] : current.filter(x => x !== id)));
|
||||
|
||||
const runExport = (): void => {
|
||||
setError(undefined);
|
||||
setIsBusy(true);
|
||||
api.reports.routes(selected)
|
||||
.then(file => download(file.blob, file.fileName ?? texts.routesExport.fileName))
|
||||
.catch((e: Error) => setError(texts.routesExport.failed(reportError(e, 'RoutesExportScreen: export'))))
|
||||
.then(() => setIsBusy(false), () => setIsBusy(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.routesExport.breadcrumb }]} />
|
||||
<PageHeader
|
||||
icon="clipboard"
|
||||
subtitle={texts.routesExport.subtitle}
|
||||
title={texts.routesExport.title}
|
||||
actions={
|
||||
<Button
|
||||
variant="accent"
|
||||
icon="check"
|
||||
disabled={selected.length === 0 || isBusy}
|
||||
onClick={runExport}
|
||||
>
|
||||
{isBusy ? texts.routesExport.exporting : texts.routesExport.export}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{requestTypes.error && <Alert kind="err">{requestTypes.error}</Alert>}
|
||||
{error && <Alert kind="err">{error}</Alert>}
|
||||
|
||||
<Panel>
|
||||
<p>{texts.routesExport.hint}</p>
|
||||
{requestTypes.isLoading && <p>{texts.common.loading}</p>}
|
||||
{!requestTypes.isLoading && requestTypes.items.length === 0 && <p>{texts.routesExport.emptyTypes}</p>}
|
||||
{requestTypes.items.length > 0 && (
|
||||
<div>
|
||||
<Checkbox
|
||||
label={texts.routesExport.selectAll}
|
||||
checked={allSelected}
|
||||
onChange={checked => setSelected(checked ? allIds : [])}
|
||||
/>
|
||||
{requestTypes.items.map(rt => (
|
||||
<Checkbox
|
||||
key={rt.id}
|
||||
label={rt.name}
|
||||
checked={selected.indexOf(rt.id) !== -1}
|
||||
onChange={checked => toggle(rt.id, checked)}
|
||||
/>
|
||||
))}
|
||||
<p>{texts.routesExport.selected(selected.length)}</p>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user