feat: added app routing
This commit is contained in:
+8
-1
@@ -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. Открытые вопросы (решить по ходу)
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user