diff --git a/ROADMAP.md b/ROADMAP.md index 1fc6fc3..45917ee 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -58,7 +58,8 @@ SharePoint; у части заявок есть **согласование** (м Согласующий приходит **по ссылке из потока Power Automate** (не ищет заявку сам). Контракт ссылки (портал — одностраничник, поэтому нужен и тип формы): -`…/СтраницаПортала.aspx?mode=approve&type=<форма>&id=` +`…/СтраницаПортала.aspx?rpmode=approve&rptype=<форма>&rpid=` +(префикс `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. Открытые вопросы (решить по ходу) diff --git a/src/webparts/requestPortal/data/requests.ts b/src/webparts/requestPortal/data/requests.ts index a1ce125..94d9acf 100644 --- a/src/webparts/requestPortal/data/requests.ts +++ b/src/webparts/requestPortal/data/requests.ts @@ -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' } ]; /** Заявки, относящиеся к указанной категории. */ diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx index afcf9fd..6f21039 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx @@ -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 = ({ requestType, context, const [actionKind, setActionKind] = useState(undefined); const [status, setStatus] = useState(undefined); const [error, setError] = useState(null); + const [files, setFiles] = useState([]); const set: SetField = (key, value) => { setData((prev) => ({ ...prev, [key]: value })); @@ -60,7 +62,8 @@ const RecruitingHireForm: React.FC = ({ 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 = ({ requestType, context, + + + + + + approval.searchUsers(q)} /> diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx index 539e4b1..a2a939c 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx @@ -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; // через — поля сами становятся неизменяемыми. const RecruitingHireView: React.FC = ({ context, itemId }) => { const [data, setData] = useState(EMPTY_DATA); + const [attachments, setAttachments] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -38,10 +40,11 @@ const RecruitingHireView: React.FC = ({ 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 = ({ context, itemI + + + + + + {/* Панель решения — ВНЕ FormModeProvider, чтобы комментарий был редактируемым. */} diff --git a/src/webparts/requestPortal/forms/RecruitingHire/model.ts b/src/webparts/requestPortal/forms/RecruitingHire/model.ts index a1e16e9..03bdfa1 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/model.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/model.ts @@ -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. */ diff --git a/src/webparts/requestPortal/forms/RecruitingHire/sections/ApplicationSection.tsx b/src/webparts/requestPortal/forms/RecruitingHire/sections/ApplicationSection.tsx index 82d9d53..3faa4a4 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/sections/ApplicationSection.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/sections/ApplicationSection.tsx @@ -1,27 +1,40 @@ 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 = ({ data, set }) => ( - - - set('Phone', v)} /> - - - + + {isReplacement && ( + + set('ReplacedEmployee', v)} + /> + + )} + + ); +}; diff --git a/src/webparts/requestPortal/forms/RecruitingHire/sections/ConditionsSection.tsx b/src/webparts/requestPortal/forms/RecruitingHire/sections/ConditionsSection.tsx index 0043190..3b43741 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/sections/ConditionsSection.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/sections/ConditionsSection.tsx @@ -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 = ({ data, set }) => ( - set('SalaryProbation', v)} /> - set('SalaryAfterProbation', v)} /> diff --git a/src/webparts/requestPortal/forms/RecruitingHire/sections/PpeSection.tsx b/src/webparts/requestPortal/forms/RecruitingHire/sections/PpeSection.tsx index 424562d..a1d04e1 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/sections/PpeSection.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/sections/PpeSection.tsx @@ -3,7 +3,7 @@ import { CollapseSection, FormField, TextInput } from '../../../shared/ui'; import { ISectionProps } from '../model'; export const PpeSection: React.FC = ({ data, set }) => ( - + set('PpeHeight', v)} /> diff --git a/src/webparts/requestPortal/forms/RecruitingHire/sections/ResponsibleSection.tsx b/src/webparts/requestPortal/forms/RecruitingHire/sections/ResponsibleSection.tsx index 3fef272..ae2d063 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/sections/ResponsibleSection.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/sections/ResponsibleSection.tsx @@ -3,7 +3,7 @@ import { CollapseSection, FormField, PeopleField } from '../../../shared/ui'; import { ISectionProps, PERSON_PLACEHOLDER } from '../model'; export const ResponsibleSection: React.FC = ({ data, set, context }) => ( - + = ({ data, set, context }) => ( - set('RateProbation', v)} /> + set('RateProbation', v)} /> - set('RateAfterProbation', v)} /> diff --git a/src/webparts/requestPortal/shared/attachments/attachments.service.ts b/src/webparts/requestPortal/shared/attachments/attachments.service.ts new file mode 100644 index 0000000..044f3fa --- /dev/null +++ b/src/webparts/requestPortal/shared/attachments/attachments.service.ts @@ -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 { + return new Promise((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 { + 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 { + 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); + } +} diff --git a/src/webparts/requestPortal/shared/pnp/pnpjs.ts b/src/webparts/requestPortal/shared/pnp/pnpjs.ts index 9545866..8844e35 100644 --- a/src/webparts/requestPortal/shared/pnp/pnpjs.ts +++ b/src/webparts/requestPortal/shared/pnp/pnpjs.ts @@ -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'; diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index 0ed8be8..1190214 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -2,8 +2,12 @@ // // Обычный режим (нет параметров) — подача заявок (features/submit). // Режим согласующего — переход по ссылке из потока Power Automate: -// …/СтраницаПортала.aspx?mode=approve&type=<форма>&id= -// где type = ключ формы в реестре (forms/registry), id = RowID заявки в её списке данных. +// …/СтраницаПортала.aspx?rpmode=approve&rptype=<форма>&rpid= +// где 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; // Режим согласующего валиден только при полном наборе параметров. diff --git a/src/webparts/requestPortal/shared/ui/AttachmentList/AttachmentList.module.scss b/src/webparts/requestPortal/shared/ui/AttachmentList/AttachmentList.module.scss new file mode 100644 index 0000000..c5adfd8 --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/AttachmentList/AttachmentList.module.scss @@ -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; +} diff --git a/src/webparts/requestPortal/shared/ui/AttachmentList/AttachmentList.tsx b/src/webparts/requestPortal/shared/ui/AttachmentList/AttachmentList.tsx new file mode 100644 index 0000000..95ab842 --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/AttachmentList/AttachmentList.tsx @@ -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 = ({ items }) => { + if (items.length === 0) { + return

Вложений нет.

; + } + + return ( + + ); +}; diff --git a/src/webparts/requestPortal/shared/ui/FileDropzone/FileDropzone.module.scss b/src/webparts/requestPortal/shared/ui/FileDropzone/FileDropzone.module.scss new file mode 100644 index 0000000..a4c8a1b --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/FileDropzone/FileDropzone.module.scss @@ -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; + } +} diff --git a/src/webparts/requestPortal/shared/ui/FileDropzone/FileDropzone.tsx b/src/webparts/requestPortal/shared/ui/FileDropzone/FileDropzone.tsx new file mode 100644 index 0000000..6d7be79 --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/FileDropzone/FileDropzone.tsx @@ -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 = ({ files, onChange }) => { + const inputRef = useRef(null); + const [dragOver, setDragOver] = useState(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 ( +
+
inputRef.current && inputRef.current.click()} + onDragOver={(e) => { + e.preventDefault(); + setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={(e) => { + e.preventDefault(); + setDragOver(false); + addFiles(e.dataTransfer.files); + }} + > + + Нажмите или перетащите файлы сюда +
+ + { + addFiles(e.target.files); + e.target.value = ''; // чтобы можно было выбрать тот же файл повторно + }} + /> + + {files.length > 0 && ( +
    + {files.map((file, index) => ( +
  • + + {file.name} + {formatSize(file.size)} + +
  • + ))} +
+ )} +
+ ); +}; diff --git a/src/webparts/requestPortal/shared/ui/Icon/IconSprite.tsx b/src/webparts/requestPortal/shared/ui/Icon/IconSprite.tsx index b8f500b..247f420 100644 --- a/src/webparts/requestPortal/shared/ui/Icon/IconSprite.tsx +++ b/src/webparts/requestPortal/shared/ui/Icon/IconSprite.tsx @@ -24,6 +24,16 @@ export const IconSprite: React.FC = () => ( + + + + + + + + + + diff --git a/src/webparts/requestPortal/shared/ui/Icon/iconNames.ts b/src/webparts/requestPortal/shared/ui/Icon/iconNames.ts index 49066e7..3ae8c48 100644 --- a/src/webparts/requestPortal/shared/ui/Icon/iconNames.ts +++ b/src/webparts/requestPortal/shared/ui/Icon/iconNames.ts @@ -5,6 +5,8 @@ export const ICON_NAMES = [ 'chart', 'monitor', 'calculator', + 'helmet', + 'user-check', 'send', 'download', 'upload', diff --git a/src/webparts/requestPortal/shared/ui/MoneyInput/MoneyInput.tsx b/src/webparts/requestPortal/shared/ui/MoneyInput/MoneyInput.tsx new file mode 100644 index 0000000..2a1ec32 --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/MoneyInput/MoneyInput.tsx @@ -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 = ({ id, value, onChange, readOnly }) => ( + onChange(v.replace(/\D/g, ''))} + readOnly={readOnly} + /> +); diff --git a/src/webparts/requestPortal/shared/ui/PhoneInput/PhoneInput.tsx b/src/webparts/requestPortal/shared/ui/PhoneInput/PhoneInput.tsx new file mode 100644 index 0000000..55254e3 --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/PhoneInput/PhoneInput.tsx @@ -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 = ({ id, value, onChange, readOnly }) => ( + onChange(formatKzPhone(v))} + placeholder="+7 (___) ___-__-__" + readOnly={readOnly} + /> +); diff --git a/src/webparts/requestPortal/shared/ui/index.ts b/src/webparts/requestPortal/shared/ui/index.ts index b9f5db9..0271ff8 100644 --- a/src/webparts/requestPortal/shared/ui/index.ts +++ b/src/webparts/requestPortal/shared/ui/index.ts @@ -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';