feat: request portal — bespoke forms + approval mechanism

This commit is contained in:
Тимур Городецкий
2026-08-03 09:35:14 +05:00
commit bc91c9861d
110 changed files with 30562 additions and 0 deletions
@@ -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;
}