feat: request portal — bespoke forms + approval mechanism
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import * as React from 'react';
|
||||
import styles from './approval.module.scss';
|
||||
import { Icon } from '../../ui';
|
||||
import { IUseMatrixEditor } from '../useMatrixEditor';
|
||||
import { ISiteUser } from '../models/ISiteUser';
|
||||
import { UserPicker } from './UserPicker';
|
||||
|
||||
export interface IApprovalMatrixProps {
|
||||
editor: IUseMatrixEditor;
|
||||
search: (query: string) => Promise<ISiteUser[]>;
|
||||
}
|
||||
|
||||
/** Таблица маршрута согласования + (если editable) правка порядка/состава. */
|
||||
export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({ editor, search }) => {
|
||||
const { rows, loading, error, editable } = editor;
|
||||
|
||||
return (
|
||||
<div className={styles.matrixWrap}>
|
||||
<div className={styles.matrixTitle}>Маршрут согласования</div>
|
||||
{loading && <div className={styles.matrixNote}>Загрузка маршрута…</div>}
|
||||
{!loading && error && <div className={styles.matrixNote}>Маршрут недоступен: {error}</div>}
|
||||
{!loading && !error && rows.length === 0 && (
|
||||
<div className={styles.matrixNote}>Согласующие не заданы.</div>
|
||||
)}
|
||||
{!loading && !error && rows.length > 0 && (
|
||||
<table className={styles.matrix}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Согласующий</th>
|
||||
<th>Должность</th>
|
||||
<th>Статус</th>
|
||||
<th>Дата</th>
|
||||
{editable && <th />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td>{r.who}</td>
|
||||
<td>{r.position}</td>
|
||||
<td>{r.status}</td>
|
||||
<td>{r.date}</td>
|
||||
{editable && (
|
||||
<td className={styles.matrixActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
disabled={i === 0}
|
||||
onClick={() => editor.moveUp(i)}
|
||||
title="Выше"
|
||||
>
|
||||
<Icon name="chevron-down" size="sm" className={styles.flipY} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
disabled={i === rows.length - 1}
|
||||
onClick={() => editor.moveDown(i)}
|
||||
title="Ниже"
|
||||
>
|
||||
<Icon name="chevron-down" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
|
||||
onClick={() => editor.remove(i)}
|
||||
title="Удалить"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{editable && !loading && (
|
||||
<div className={styles.matrixPicker}>
|
||||
<UserPicker
|
||||
search={search}
|
||||
onPick={editor.addApprover}
|
||||
placeholder="Добавить согласующего: начните вводить ФИО…"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import * as React from 'react';
|
||||
import styles from './approval.module.scss';
|
||||
import { ISiteUser } from '../models/ISiteUser';
|
||||
|
||||
export interface IUserPickerProps {
|
||||
search: (query: string) => Promise<ISiteUser[]>;
|
||||
onPick: (user: ISiteUser) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/** Лёгкий поиск пользователя сайта: ввод → выпадающий список → выбор. */
|
||||
export const UserPicker: React.FC<IUserPickerProps> = ({ search, onPick, placeholder }) => {
|
||||
const [query, setQuery] = React.useState<string>('');
|
||||
const [results, setResults] = React.useState<ISiteUser[]>([]);
|
||||
const [busy, setBusy] = React.useState<boolean>(false);
|
||||
|
||||
const handleChange = (value: string): void => {
|
||||
setQuery(value);
|
||||
if (value.trim().length < 2) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
search(value)
|
||||
.then((r) => {
|
||||
setResults(r);
|
||||
setBusy(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setResults([]);
|
||||
setBusy(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handlePick = (user: ISiteUser): void => {
|
||||
onPick(user);
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.picker}>
|
||||
<input
|
||||
className={styles.input}
|
||||
value={query}
|
||||
placeholder={placeholder || 'Начните вводить ФИО…'}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
/>
|
||||
{busy && <div className={styles.pickerNote}>Поиск…</div>}
|
||||
{!busy && results.length > 0 && (
|
||||
<div className={styles.pickerList}>
|
||||
{results.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
type="button"
|
||||
className={styles.pickerItem}
|
||||
onClick={() => handlePick(u)}
|
||||
>
|
||||
{u.name}
|
||||
{u.email && <span className={styles.pickerEmail}>{u.email}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
// ---- Общий инпут (пикер) ----
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 11px 13px;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: $r-sm;
|
||||
background: $white;
|
||||
font-family: $font-sans;
|
||||
font-size: 13.5px;
|
||||
color: $gray-900;
|
||||
transition: .18s;
|
||||
|
||||
@include input-focus-gold;
|
||||
|
||||
&::placeholder {
|
||||
color: $gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Пикер пользователя ----
|
||||
.picker {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pickerNote {
|
||||
margin-top: $sp-1;
|
||||
font-size: $fs-caption;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.pickerList {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: $sp-1;
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-sm;
|
||||
box-shadow: $sh-2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pickerItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-sm;
|
||||
color: $gray-900;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: $navy-50;
|
||||
}
|
||||
}
|
||||
|
||||
.pickerEmail {
|
||||
font-size: $fs-caption;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
// ---- Матрица согласования ----
|
||||
.matrixWrap {
|
||||
margin-top: $sp-5;
|
||||
padding: $sp-4;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-md;
|
||||
background: $gray-50;
|
||||
}
|
||||
|
||||
.matrixTitle {
|
||||
font-family: $font-serif;
|
||||
font-size: $fs-section-title;
|
||||
font-weight: 600;
|
||||
color: $navy-800;
|
||||
margin-bottom: $sp-3;
|
||||
}
|
||||
|
||||
.matrixNote {
|
||||
font-size: $fs-sm;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.matrix {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: $fs-sm;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
color: $gray-500;
|
||||
font-size: $fs-label;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
color: $gray-900;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.matrixActions {
|
||||
display: flex;
|
||||
gap: $sp-1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: $r-sm;
|
||||
background: $white;
|
||||
color: $navy-700;
|
||||
cursor: pointer;
|
||||
transition: .16s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: $gold-400;
|
||||
color: $gold-600;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: .4;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.iconBtnDanger {
|
||||
&:hover:not(:disabled) {
|
||||
border-color: $red-500;
|
||||
color: $red-500;
|
||||
}
|
||||
}
|
||||
|
||||
.flipY {
|
||||
transform: scaleY(-1);
|
||||
}
|
||||
|
||||
.matrixPicker {
|
||||
margin-top: $sp-3;
|
||||
}
|
||||
Reference in New Issue
Block a user