feat: added app routing

This commit is contained in:
Тимур Городецкий
2026-08-03 17:58:24 +05:00
parent e6203e7093
commit dc60d2dbee
22 changed files with 460 additions and 61 deletions
+8 -1
View File
@@ -58,7 +58,8 @@ SharePoint; у части заявок есть **согласование** (м
Согласующий приходит **по ссылке из потока Power Automate** (не ищет заявку сам).
Контракт ссылки (портал — одностраничник, поэтому нужен и тип формы):
`…/СтраницаПортала.aspx?mode=approve&type=<форма>&id=<RowID>`
`…/СтраницаПортала.aspx?rpmode=approve&rptype=<форма>&rpid=<RowID>`
(префикс `rp`: имена `id/mode/type` зарезервированы SharePoint и ломают страницу)
- [x] `readOnly` у примитивов формы
- [x] Скелет маршрутизации: `shared/routing.ts` (parseRoute) + ветка в оболочке →
@@ -136,6 +137,12 @@ SharePoint; у части заявок есть **согласование** (м
5. **Плитка:** уже есть в `data/requests.ts` (id совпадает с реестром).
6. **Проверка:** `heft build` → тест на hosted workbench сайта `/sites/requests`.
### Обязательные примитивы под тип поля (единообразие во ВСЕХ формах)
- **Телефон** → `PhoneInput` (маска `+7 (XXX) XXX-XX-XX`). Не `TextInput`.
- **Сумма/деньги/оклад/ставка** → `MoneyInput` (разделитель тысяч, в модели — чистые цифры). Не `TextInput type=number`.
- Пользователь(и) → `PeopleField` / `PeopleFieldMulti`; дата → `TextInput type=date`; да/нет → `Toggle`; выбор → `Select`.
- **Вложения** (если у формы есть файлы): секция с `FileDropzone` (состояние `File[]`); при сабмите — `uploadAttachments(листДанных, id, files)` ПОСЛЕ создания элемента; в режиме согласующего — `AttachmentList` из `getAttachments(...)`. Хранятся как вложения элемента списка (`AttachmentFiles`), без отдельной библиотеки.
---
## 7. Открытые вопросы (решить по ходу)
+3 -12
View File
@@ -1,9 +1,8 @@
import { IRequestType } from '../models/types';
/**
* Типы заявок (плитки) по категориям. Первая партия.
* Пока захардкожены; сами формы появятся позже (см. forms/registry).
* id — латиницей, т.к. позже станет ключом в реестре форм.
* Типы заявок (плитки) по категориям, в порядке отображения.
* id — латиницей, т.к. это ключ в реестре форм (forms/registry) и в ссылках (rptype).
*/
export const REQUEST_TYPES: IRequestType[] = [
// Отдел рекрутинга
@@ -13,16 +12,8 @@ export const REQUEST_TYPES: IRequestType[] = [
// IT
{ id: 'it-workplace-setup', categoryId: 'it', title: 'Заявка на организацию рабочего места', icon: 'monitor' },
// Бухгалтерия
{ id: 'accounting-advance', categoryId: 'accounting', title: 'Заявка на аванс', icon: 'calculator' },
// Отдел кадров
{ id: 'hr-labor-leave', categoryId: 'hr', title: 'Заявка на трудовой отпуск', icon: 'calendar' },
{ id: 'hr-day-off', categoryId: 'hr', title: 'на отгул', icon: 'calendar' },
{ id: 'hr-dismissal', categoryId: 'hr', title: 'на увольнение', icon: 'document' },
{ id: 'hr-clearance-sheet', categoryId: 'hr', title: 'обходной лист', icon: 'clipboard' },
{ id: 'hr-bs', categoryId: 'hr', title: 'БС', icon: 'document' },
{ id: 'hr-business-trip', categoryId: 'hr', title: 'на командировку', icon: 'send' }
{ id: 'hr-transfer', categoryId: 'hr', title: 'на перемещение сотрудников', icon: 'send' }
];
/** Заявки, относящиеся к указанной категории. */
@@ -1,7 +1,7 @@
import * as React from 'react';
import { useState, useMemo, useCallback } from 'react';
import styles from './RecruitingHireForm.module.scss';
import { Button, Alert, FormCard, FormActions, Icon } from '../../shared/ui';
import { Button, Alert, FormCard, FormActions, FileDropzone, CollapseSection, FormField, Icon } from '../../shared/ui';
import {
ApprovalMatrix,
useMatrixEditor,
@@ -9,9 +9,10 @@ import {
REQUEST_STATUS,
IApproverToWrite
} from '../../shared/approval';
import { uploadAttachments } from '../../shared/attachments/attachments.service';
import { IRequestFormProps } from '../../models/types';
import { IFormData, EMPTY_DATA, SetField } from './model';
import { saveHireRequest, hireApprovalService, HIRE_REQUEST_TYPE_ID } from './submit';
import { saveHireRequest, hireApprovalService, HIRE_REQUEST_TYPE_ID, DATA_LIST_TITLE } from './submit';
import { ApplicationSection } from './sections/ApplicationSection';
import { CandidateSection } from './sections/CandidateSection';
import { PpeSection } from './sections/PpeSection';
@@ -27,6 +28,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
const [actionKind, setActionKind] = useState<ActionKind | undefined>(undefined);
const [status, setStatus] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | null>(null);
const [files, setFiles] = useState<File[]>([]);
const set: SetField = (key, value) => {
setData((prev) => ({ ...prev, [key]: value }));
@@ -60,7 +62,8 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
setActionKind(kind);
setActionStatus('saving');
try {
await saveHireRequest(data, matrixApprovers(), requestStatus);
const id = await saveHireRequest(data, matrixApprovers(), requestStatus);
await uploadAttachments(DATA_LIST_TITLE, id, files); // файлы — после создания элемента
setStatus(requestStatus);
setActionStatus('success');
if (onSaved) {
@@ -106,6 +109,12 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
<ConditionsSection {...sectionProps} />
<ResponsibleSection {...sectionProps} />
<CollapseSection title="Вложения" icon="upload">
<FormField label="Файлы" full>
<FileDropzone files={files} onChange={setFiles} />
</FormField>
</CollapseSection>
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
<FormActions>
@@ -2,8 +2,9 @@ import * as React from 'react';
import { useState, useEffect, useMemo } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './RecruitingHireView.module.scss';
import { Alert, FormCard, FormModeProvider } from '../../shared/ui';
import { Alert, FormCard, FormModeProvider, CollapseSection, FormField, AttachmentList } from '../../shared/ui';
import { ApproverService, DecisionPanel } from '../../shared/approval';
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
import { IFormData, EMPTY_DATA, SetField } from './model';
import { loadHireRequest } from './load';
import { hireApprovalService, DATA_LIST_TITLE } from './submit';
@@ -26,6 +27,7 @@ const NOOP_SET: SetField = () => undefined;
// через <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemId }) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
@@ -38,10 +40,11 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
useEffect(() => {
let alive = true;
setLoading(true);
loadHireRequest(itemId)
.then((loaded) => {
Promise.all([loadHireRequest(itemId), getAttachments(DATA_LIST_TITLE, itemId)])
.then(([loaded, files]) => {
if (alive) {
setData(loaded);
setAttachments(files);
setError(null);
setLoading(false);
}
@@ -80,6 +83,12 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
<ConditionsSection {...sectionProps} />
<ResponsibleSection {...sectionProps} />
</FormModeProvider>
<CollapseSection title="Вложения" icon="upload">
<FormField label="Файлы" full>
<AttachmentList items={attachments} />
</FormField>
</CollapseSection>
</FormCard>
{/* Панель решения — ВНЕ FormModeProvider, чтобы комментарий был редактируемым. */}
@@ -62,10 +62,13 @@ export const EMPTY_DATA: IFormData = {
SecuritySpecialist: ''
};
/** Значение «Тип приёма», при котором показывается поле «Взамен кого». */
export const HIRE_TYPE_REPLACEMENT = 'Вместо уволенного';
/** Варианты «Тип приёма» — совпадают с choices колонки HireType. */
export const HIRE_TYPE_OPTIONS: ISelectOption[] = [
{ value: 'Новый сотрудник', label: 'Новый сотрудник' },
{ value: 'Вместо уволенного', label: 'Вместо уволенного' }
{ value: HIRE_TYPE_REPLACEMENT, label: HIRE_TYPE_REPLACEMENT }
];
/** Варианты «Вид оформления» — совпадают с choices колонки EmploymentForm. */
@@ -1,21 +1,32 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, Select } from '../../../shared/ui';
import { ISectionProps, HIRE_TYPE_OPTIONS } from '../model';
import { CollapseSection, FormField, TextInput, PhoneInput, Select } from '../../../shared/ui';
import { ISectionProps, HIRE_TYPE_OPTIONS, HIRE_TYPE_REPLACEMENT } from '../model';
export const ApplicationSection: React.FC<ISectionProps> = ({ data, set }) => (
export const ApplicationSection: React.FC<ISectionProps> = ({ data, set }) => {
const isReplacement = data.HireType === HIRE_TYPE_REPLACEMENT;
const handleHireTypeChange = (value: string): void => {
set('HireType', value);
if (value !== HIRE_TYPE_REPLACEMENT) {
set('ReplacedEmployee', ''); // при не-замене очищаем скрытое поле
}
};
return (
<CollapseSection title="Заявка" icon="clipboard">
<FormField label="Телефон" htmlFor="Phone">
<TextInput id="Phone" value={data.Phone} onChange={(v) => set('Phone', v)} />
<PhoneInput id="Phone" value={data.Phone} onChange={(v) => set('Phone', v)} />
</FormField>
<FormField label="Тип приёма" htmlFor="HireType">
<Select
id="HireType"
value={data.HireType}
onChange={(v) => set('HireType', v)}
onChange={handleHireTypeChange}
options={HIRE_TYPE_OPTIONS}
placeholder="— выберите —"
/>
</FormField>
{isReplacement && (
<FormField label="Взамен кого (Ф.И.О. уволенного)" htmlFor="ReplacedEmployee" full>
<TextInput
id="ReplacedEmployee"
@@ -23,5 +34,7 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set }) => (
onChange={(v) => set('ReplacedEmployee', v)}
/>
</FormField>
)}
</CollapseSection>
);
};
@@ -1,21 +1,19 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, Select } from '../../../shared/ui';
import { CollapseSection, FormField, TextInput, MoneyInput, Select } from '../../../shared/ui';
import { ISectionProps, EMPLOYMENT_FORM_OPTIONS } from '../model';
export const ConditionsSection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title="Условия оплаты и оформления" icon="calculator">
<FormField label="Оклад при испытательном сроке" htmlFor="SalaryProbation">
<TextInput
<MoneyInput
id="SalaryProbation"
type="number"
value={data.SalaryProbation}
onChange={(v) => set('SalaryProbation', v)}
/>
</FormField>
<FormField label="Оклад по завершении ИС" htmlFor="SalaryAfterProbation">
<TextInput
<MoneyInput
id="SalaryAfterProbation"
type="number"
value={data.SalaryAfterProbation}
onChange={(v) => set('SalaryAfterProbation', v)}
/>
@@ -3,7 +3,7 @@ import { CollapseSection, FormField, TextInput } from '../../../shared/ui';
import { ISectionProps } from '../model';
export const PpeSection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title="СИЗ (спецодежда)" icon="folder">
<CollapseSection title="СИЗ (спецодежда)" icon="helmet">
<FormField label="Рост, см" htmlFor="PpeHeight">
<TextInput id="PpeHeight" type="number" value={data.PpeHeight} onChange={(v) => set('PpeHeight', v)} />
</FormField>
@@ -3,7 +3,7 @@ import { CollapseSection, FormField, PeopleField } from '../../../shared/ui';
import { ISectionProps, PERSON_PLACEHOLDER } from '../model';
export const ResponsibleSection: React.FC<ISectionProps> = ({ data, set, context }) => (
<CollapseSection title="Ответственные лица" icon="check">
<CollapseSection title="Ответственные лица" icon="user-check">
<FormField label="Руководитель участка" full>
<PeopleField
context={context}
@@ -1,16 +1,15 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, TextArea, Toggle, PeopleField, PeopleFieldMulti } from '../../../shared/ui';
import { CollapseSection, FormField, TextInput, TextArea, MoneyInput, Toggle, PeopleField, PeopleFieldMulti } from '../../../shared/ui';
import { ISectionProps, PERSON_PLACEHOLDER } from '../model';
export const ConditionsSection: React.FC<ISectionProps> = ({ data, set, context }) => (
<CollapseSection title="Условия работы" icon="chart">
<FormField label="Ставка на ИС" htmlFor="RateProbation">
<TextInput id="RateProbation" type="number" value={data.RateProbation} onChange={(v) => set('RateProbation', v)} />
<MoneyInput id="RateProbation" value={data.RateProbation} onChange={(v) => set('RateProbation', v)} />
</FormField>
<FormField label="Ставка после ИС" htmlFor="RateAfterProbation">
<TextInput
<MoneyInput
id="RateAfterProbation"
type="number"
value={data.RateAfterProbation}
onChange={(v) => set('RateAfterProbation', v)}
/>
@@ -0,0 +1,47 @@
import { getSP } from '../pnp/pnpjs';
import '@pnp/sp/attachments';
export interface IAttachmentInfo {
fileName: string;
/** ServerRelativeUrl — прямая ссылка на скачивание. */
url: string;
}
/** Прочитать файл как ArrayBuffer (FileReader — совместимо со всеми браузерами). */
function readAsArrayBuffer(file: File): Promise<ArrayBuffer> {
return new Promise<ArrayBuffer>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as ArrayBuffer);
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(file);
});
}
/** Вложения элемента списка (для режима согласующего — ссылки на скачивание). */
export async function getAttachments(listTitle: string, itemId: number): Promise<IAttachmentInfo[]> {
const files = await getSP()
.web.lists.getByTitle(listTitle)
.items.getById(itemId)
.attachmentFiles();
return files.map((f) => ({ fileName: f.FileName, url: f.ServerRelativeUrl }));
}
/**
* Прикрепить файлы к уже созданному элементу заявки.
* Вызывать ПОСЛЕ создания элемента (нужен его id).
*/
export async function uploadAttachments(
listTitle: string,
itemId: number,
files: File[]
): Promise<void> {
if (files.length === 0) {
return;
}
const item = getSP().web.lists.getByTitle(listTitle).items.getById(itemId);
for (let i = 0; i < files.length; i++) {
const file = files[i];
const content = await readAsArrayBuffer(file);
await item.attachmentFiles.add(file.name, content);
}
}
@@ -2,6 +2,7 @@ import { spfi, SPFI, SPFx } from '@pnp/sp';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
import '@pnp/sp/attachments';
import '@pnp/sp/site-users/web';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { DATA_SITE_URL } from '../config';
+9 -5
View File
@@ -2,8 +2,12 @@
//
// Обычный режим (нет параметров) — подача заявок (features/submit).
// Режим согласующего — переход по ссылке из потока Power Automate:
// …/СтраницаПортала.aspx?mode=approve&type=<форма>&id=<RowID>
// где type = ключ формы в реестре (forms/registry), id = RowID заявки в её списке данных.
// …/СтраницаПортала.aspx?rpmode=approve&rptype=<форма>&rpid=<RowID>
// где rptype = ключ формы в реестре (forms/registry), rpid = RowID заявки в списке данных.
//
// Префикс rp (request-portal): имена id/mode/type зарезервированы SharePoint
// (напр. ?id= он трактует как навигацию к элементу → «Элемент не существует»),
// поэтому используем собственные, неконфликтные имена параметров.
export type PortalMode = 'submit' | 'approve';
@@ -18,9 +22,9 @@ export interface IRoute {
/** Разобрать строку запроса (по умолчанию — из адреса страницы) в маршрут. */
export function parseRoute(search: string = window.location.search): IRoute {
const params = new URLSearchParams(search);
const mode = params.get('mode');
const formKey = params.get('type') ?? undefined;
const idRaw = params.get('id');
const mode = params.get('rpmode');
const formKey = params.get('rptype') ?? undefined;
const idRaw = params.get('rpid');
const itemId = idRaw ? Number(idRaw) : undefined;
// Режим согласующего валиден только при полном наборе параметров.
@@ -0,0 +1,36 @@
@import '../../../../../styles/design-tokens';
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border: 1px solid $gray-200;
border-radius: $r-sm;
background: $gray-50;
font-size: 13px;
}
.link {
color: $navy-700;
text-decoration: none;
&:hover {
color: $gold-600;
text-decoration: underline;
}
}
.empty {
color: $gray-500;
font-size: $fs-sm;
}
@@ -0,0 +1,28 @@
import * as React from 'react';
import styles from './AttachmentList.module.scss';
import { Icon } from '../Icon/Icon';
import { IAttachmentInfo } from '../../attachments/attachments.service';
interface IAttachmentListProps {
items: IAttachmentInfo[];
}
/** Список вложений заявки как ссылки на скачивание (режим чтения/согласующего). */
export const AttachmentList: React.FC<IAttachmentListProps> = ({ items }) => {
if (items.length === 0) {
return <p className={styles.empty}>Вложений нет.</p>;
}
return (
<ul className={styles.list}>
{items.map((attachment) => (
<li key={attachment.url} className={styles.item}>
<Icon name="document" size="sm" />
<a href={attachment.url} target="_blank" rel="noreferrer" className={styles.link}>
{attachment.fileName}
</a>
</li>
))}
</ul>
);
};
@@ -0,0 +1,75 @@
@import '../../../../../styles/design-tokens';
.zone {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 24px;
border: 1.5px dashed $gray-300;
border-radius: $r-md;
color: $gray-400;
font-size: 12.5px;
cursor: pointer;
transition: .18s;
&:hover,
&.over {
border-color: $gold-500;
color: $gold-600;
background: $gold-50;
}
}
.hidden {
display: none;
}
.list {
list-style: none;
margin: 10px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border: 1px solid $gray-200;
border-radius: $r-sm;
background: $gray-50;
font-size: 13px;
color: $gray-700;
}
.name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.size {
color: $gray-400;
font-size: 11.5px;
flex-shrink: 0;
}
.remove {
display: inline-flex;
padding: 2px;
border: 0;
background: transparent;
color: $gray-400;
cursor: pointer;
flex-shrink: 0;
&:hover {
color: $red-500;
}
}
@@ -0,0 +1,87 @@
import * as React from 'react';
import { useState, useRef } from 'react';
import styles from './FileDropzone.module.scss';
import { Icon } from '../Icon/Icon';
interface IFileDropzoneProps {
files: File[];
onChange: (files: File[]) => void;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} КБ`;
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
}
/** Зона выбора файлов (перетащить/кликнуть) со списком выбранных и удалением. */
export const FileDropzone: React.FC<IFileDropzoneProps> = ({ files, onChange }) => {
const inputRef = useRef<HTMLInputElement>(null);
const [dragOver, setDragOver] = useState<boolean>(false);
const addFiles = (incoming: FileList | null): void => {
if (!incoming || incoming.length === 0) {
return;
}
onChange([...files, ...Array.from(incoming)]);
};
const removeAt = (index: number): void => {
onChange(files.filter((_, i) => i !== index));
};
const zoneClasses = [styles.zone, dragOver ? styles.over : ''].filter(Boolean).join(' ');
return (
<div>
<div
className={zoneClasses}
onClick={() => inputRef.current && inputRef.current.click()}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
addFiles(e.dataTransfer.files);
}}
>
<Icon name="upload" size="sm" />
<span>Нажмите или перетащите файлы сюда</span>
</div>
<input
ref={inputRef}
type="file"
multiple
className={styles.hidden}
onChange={(e) => {
addFiles(e.target.files);
e.target.value = ''; // чтобы можно было выбрать тот же файл повторно
}}
/>
{files.length > 0 && (
<ul className={styles.list}>
{files.map((file, index) => (
<li key={`${file.name}-${index}`} className={styles.item}>
<Icon name="document" size="sm" />
<span className={styles.name}>{file.name}</span>
<span className={styles.size}>{formatSize(file.size)}</span>
<button
type="button"
className={styles.remove}
aria-label="Удалить файл"
onClick={() => removeAt(index)}
>
<Icon name="close" size="sm" />
</button>
</li>
))}
</ul>
)}
</div>
);
};
@@ -24,6 +24,16 @@ export const IconSprite: React.FC = () => (
<rect x="2" y="4" width="20" height="13" rx="2" />
<path d="M8 21h8M12 17v4" />
</symbol>
<symbol id="i-helmet" viewBox="0 0 24 24">
<path d="M3 16a9 9 0 0 1 18 0" />
<path d="M2 16h20" />
<path d="M9 8V6a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</symbol>
<symbol id="i-user-check" viewBox="0 0 24 24">
<circle cx="9" cy="7" r="4" />
<path d="M3 21v-1a5 5 0 0 1 5-5h3" />
<path d="M15 12l2.5 2.5L22 10" />
</symbol>
<symbol id="i-calculator" viewBox="0 0 24 24">
<rect x="4" y="2" width="16" height="20" rx="2" />
<path d="M8 6h8M8 10h.01M12 10h.01M16 10h.01M8 14h.01M12 14h.01M16 14h.01M8 18h4" />
@@ -5,6 +5,8 @@ export const ICON_NAMES = [
'chart',
'monitor',
'calculator',
'helmet',
'user-check',
'send',
'download',
'upload',
@@ -0,0 +1,31 @@
import * as React from 'react';
import { TextInput } from '../TextInput/TextInput';
// Показ с разделителем тысяч пробелом: 350000 → «350 000».
function formatThousands(input: string): string {
const digits = input.replace(/\D/g, '');
if (!digits) {
return '';
}
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
}
interface IMoneyInputProps {
id?: string;
value: string;
onChange: (value: string) => void;
readOnly?: boolean;
}
/**
* Ввод суммы с разделителем тысяч. В модель формы кладём «сырые» цифры
* (без пробелов), поэтому запись в числовую колонку SP работает как есть.
*/
export const MoneyInput: React.FC<IMoneyInputProps> = ({ id, value, onChange, readOnly }) => (
<TextInput
id={id}
value={formatThousands(value)}
onChange={(v) => onChange(v.replace(/\D/g, ''))}
readOnly={readOnly}
/>
);
@@ -0,0 +1,45 @@
import * as React from 'react';
import { TextInput } from '../TextInput/TextInput';
// Маска номера Казахстана: +7 (XXX) XXX-XX-XX. Идемпотентна — можно применять
// и к уже отформатированному значению (напр. загруженному из SharePoint).
function formatKzPhone(input: string): string {
let digits = input.replace(/\D/g, '');
if (digits.length === 0) {
return '';
}
if (digits[0] === '8') {
digits = '7' + digits.slice(1);
}
if (digits[0] !== '7') {
digits = '7' + digits;
}
digits = digits.slice(0, 11); // код страны + 10 цифр
const rest = digits.slice(1);
let out = '+7';
if (rest.length > 0) out += ' (' + rest.slice(0, 3);
if (rest.length >= 3) out += ')';
if (rest.length > 3) out += ' ' + rest.slice(3, 6);
if (rest.length > 6) out += '-' + rest.slice(6, 8);
if (rest.length > 8) out += '-' + rest.slice(8, 10);
return out;
}
interface IPhoneInputProps {
id?: string;
value: string;
onChange: (value: string) => void;
readOnly?: boolean;
}
/** Ввод телефона с маской +7 (XXX) XXX-XX-XX. */
export const PhoneInput: React.FC<IPhoneInputProps> = ({ id, value, onChange, readOnly }) => (
<TextInput
id={id}
value={formatKzPhone(value)}
onChange={(v) => onChange(formatKzPhone(v))}
placeholder="+7 (___) ___-__-__"
readOnly={readOnly}
/>
);
@@ -5,12 +5,16 @@ export { FormCard } from './FormCard/FormCard';
export { FormGrid } from './FormGrid/FormGrid';
export { FormField } from './FormField/FormField';
export { TextInput } from './TextInput/TextInput';
export { PhoneInput } from './PhoneInput/PhoneInput';
export { MoneyInput } from './MoneyInput/MoneyInput';
export { TextArea } from './TextArea/TextArea';
export { Select } from './Select/Select';
export type { ISelectOption } from './Select/Select';
export { Toggle } from './Toggle/Toggle';
export { CollapseSection } from './CollapseSection/CollapseSection';
export { FormActions } from './FormActions/FormActions';
export { FileDropzone } from './FileDropzone/FileDropzone';
export { AttachmentList } from './AttachmentList/AttachmentList';
export { PeopleField } from './PeopleField/PeopleField';
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
export { FormModeProvider, useFormReadOnly } from './FormMode';