From e65805379e7bad0ce9b92d74a2c558fc093f3bab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Thu, 13 Aug 2026 14:53:11 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=20=D0=B1=D1=8B=D1=81=D1=82=D1=80=D0=BE=D0=B9=20=D0=BF?= =?UTF-8?q?=D0=B0=D1=87=D0=BA=D0=B8=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=200.1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT.md | 44 +++ README.md | 14 +- package.json | 2 +- src/Alert/Alert.module.scss | 30 +++ src/Alert/Alert.tsx | 34 +++ src/AttachmentList/AttachmentList.module.scss | 59 ++++ src/AttachmentList/AttachmentList.tsx | 49 ++++ .../CollapseSection.module.scss | 62 +++++ src/CollapseSection/CollapseSection.tsx | 32 +++ src/ConfirmDialog/ConfirmDialog.module.scss | 98 +++++++ src/ConfirmDialog/ConfirmDialog.tsx | 82 ++++++ src/ConfirmDialog/confirmModal.ts | 44 +++ src/FileDropzone/FileDropzone.module.scss | 75 ++++++ src/FileDropzone/FileDropzone.tsx | 87 ++++++ src/FormActions/FormActions.module.scss | 15 ++ src/FormActions/FormActions.tsx | 11 + src/FormCard/FormCard.module.scss | 42 +++ src/FormCard/FormCard.tsx | 30 +++ src/FormField/FormField.module.scss | 23 ++ src/FormField/FormField.tsx | 26 ++ src/FormGrid/FormGrid.module.scss | 11 + src/FormGrid/FormGrid.tsx | 11 + src/FormMode.tsx | 16 ++ src/Icon/Icon.module.scss | 22 ++ src/Icon/Icon.tsx | 30 +++ src/Icon/IconSprite.tsx | 140 ++++++++++ src/Icon/iconNames.ts | 37 +++ src/MoneyInput/MoneyInput.tsx | 31 +++ src/PhoneInput/PhoneInput.tsx | 45 ++++ src/PrintButton/PrintButton.tsx | 117 ++++++++ src/RequestNumber/RequestNumber.module.scss | 16 ++ src/RequestNumber/RequestNumber.tsx | 7 + src/Select/Select.module.scss | 20 ++ src/Select/Select.tsx | 40 +++ src/TextArea/TextArea.module.scss | 21 ++ src/TextArea/TextArea.tsx | 30 +++ src/TextInput/TextInput.module.scss | 19 ++ src/TextInput/TextInput.tsx | 39 +++ src/Toggle/Toggle.module.scss | 65 +++++ src/Toggle/Toggle.tsx | 35 +++ src/index.ts | 50 +++- src/useFlipRows.ts | 255 ++++++++++++++++++ 42 files changed, 1911 insertions(+), 5 deletions(-) create mode 100644 src/Alert/Alert.module.scss create mode 100644 src/Alert/Alert.tsx create mode 100644 src/AttachmentList/AttachmentList.module.scss create mode 100644 src/AttachmentList/AttachmentList.tsx create mode 100644 src/CollapseSection/CollapseSection.module.scss create mode 100644 src/CollapseSection/CollapseSection.tsx create mode 100644 src/ConfirmDialog/ConfirmDialog.module.scss create mode 100644 src/ConfirmDialog/ConfirmDialog.tsx create mode 100644 src/ConfirmDialog/confirmModal.ts create mode 100644 src/FileDropzone/FileDropzone.module.scss create mode 100644 src/FileDropzone/FileDropzone.tsx create mode 100644 src/FormActions/FormActions.module.scss create mode 100644 src/FormActions/FormActions.tsx create mode 100644 src/FormCard/FormCard.module.scss create mode 100644 src/FormCard/FormCard.tsx create mode 100644 src/FormField/FormField.module.scss create mode 100644 src/FormField/FormField.tsx create mode 100644 src/FormGrid/FormGrid.module.scss create mode 100644 src/FormGrid/FormGrid.tsx create mode 100644 src/FormMode.tsx create mode 100644 src/Icon/Icon.module.scss create mode 100644 src/Icon/Icon.tsx create mode 100644 src/Icon/IconSprite.tsx create mode 100644 src/Icon/iconNames.ts create mode 100644 src/MoneyInput/MoneyInput.tsx create mode 100644 src/PhoneInput/PhoneInput.tsx create mode 100644 src/PrintButton/PrintButton.tsx create mode 100644 src/RequestNumber/RequestNumber.module.scss create mode 100644 src/RequestNumber/RequestNumber.tsx create mode 100644 src/Select/Select.module.scss create mode 100644 src/Select/Select.tsx create mode 100644 src/TextArea/TextArea.module.scss create mode 100644 src/TextArea/TextArea.tsx create mode 100644 src/TextInput/TextInput.module.scss create mode 100644 src/TextInput/TextInput.tsx create mode 100644 src/Toggle/Toggle.module.scss create mode 100644 src/Toggle/Toggle.tsx create mode 100644 src/useFlipRows.ts diff --git a/PROJECT.md b/PROJECT.md index 61a7128..6267a57 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -67,6 +67,48 @@ web part через `npm i`, и держать единый вид через д - **Шаг 2 в работе**: переписан package.json (@tnsu/ui-kit, exports, peerDeps, Vite-скрипты), ставятся devDependencies, добавлен .gitignore. +## План переноса компонентов (инвентаризация + решения, 2026-08-13) + +Источник — request-portal `shared/ui/`, `shared/approval/ui/`, `shared/dictionary/ui/`, +и (фаза 2) `components/`. + +### Группа 1 — чистая презентация, переносим КАК ЕСТЬ +Alert, FormCard, FormGrid, FormField, TextInput, TextArea, PhoneInput, MoneyInput, +Select, Toggle, CollapseSection, FormActions, Icon (+ IconSprite/iconNames), +RequestNumber, StatusBadge, ErrorOverlay, FormMode (FormModeProvider + useFormReadOnly). + +### Группа 2 — презентация с нюансами (решения приняты) +- ConfirmDialog (+ confirmModal), FileDropzone, useFlipRows → переносим КАК ЕСТЬ (проверено: чисты). +- AttachmentList → переносим, но РАЗВЯЗАТЬ тип: вместо `import { IAttachmentInfo }` + из attachments.service объявить локальный `{ url: string; fileName: string }`. +- PrintButton → переносим КАК ЕСТЬ; сохраняем соглашение классов `.rp-print-root` / + `.rp-no-print` — задокументировать в README, чтобы потребители их соблюдали. +- ItemsTable → ОБОБЩАЕМ в generic `DataTable` (колонки через проп: {key,title,width?, + align?,editable?,render?}, value/onChange/readOnly). Excel-вставка — ОПЦИОНАЛЬНАЯ/ + подключаемая (не хардкодить parseExcelRows под ТМЦ; проект даёт свой маппинг/парсер). + ТМЦ-таблица = конфиг колонок поверх DataTable. Это ОТДЕЛЬНАЯ design-задача (не copy-paste), + делать позже, сверяя абстракцию со 2-м реальным примером таблицы. + +### Группа 3 — бизнес-логика / SharePoint, НЕ в ui-kit (как есть) +PeopleField/PeopleFieldMulti, UserPicker, InitiatorLine, SubmitActions, useRequestSubmit, +DecisionPanel, ApprovalMatrix, DepartmentPicker, ProjectPicker. +- SubmitActions — на обсуждении: можно «очистить» от useRequestSubmit → презентационный → в ui-kit. +- Пикеры — возможно позже РАЗДЕЛИТЬ: презентационный UI (ui-kit) + data-обёртка (проект). +- Логику согласования — возможно в отдельный пакет @tnsu/approval (или @tnsu/sp-core). На будущее. + +### Группа 4 — навигация / app shell (ФАЗА 2) +Tabs (из CategoryTabs), Breadcrumbs (новый), Sidebar (из SideNav), AppLayout (из RequestPortal), +Drawer(?). Сначала «расцепить» от роутинга портала → сделать controlled +(items + activeId + onSelect), потом переносить. Даёт новым приложениям готовую оболочку. + +### Порядок работ +1. [СДЕЛАНО в 0.1.0] Быстрая пачка (20 компонентов): вся Группа 1 (кроме ErrorOverlay/ + StatusBadge — оказались связаны с diagnostics/statuses, отложены) + ConfirmDialog/ + FileDropzone/useFlipRows + AttachmentList (тип развязан на локальный {url,fileName}) + + PrintButton. Сборка чистая, типы ок. +2. DataTable — отдельной design-задачей. +3. Группа 3/4 — по мере надобности. + ## Как мигрировать старый проект (памятка на будущее) 1. `npm i @tnsu/ui-kit` — ничего не ломается, пакет просто лежит рядом. @@ -90,6 +132,8 @@ web part через `npm i`, и держать единый вид через д публикацией (npm не даёт перезаписать уже опубликованную версию). - Потребителю (request-portal и др.) нужен такой же `@tnsu:registry` в его `.npmrc` + свой токен на чтение пакетов. +- ПРОВЕРЕНО: коллега (другой пользователь Gitea) успешно установил @tnsu/ui-kit со своим + read-токеном — кросс-доступ к личным пакетам работает, переносить в организацию пока НЕ нужно. ## Окружение diff --git a/README.md b/README.md index eb78b93..f57bbdc 100644 --- a/README.md +++ b/README.md @@ -63,9 +63,17 @@ import '@tnsu/ui-kit/styles.css'; // один раз в корневом ком _Наполняется по мере переноса. Полная витрина со всеми состояниями — в Storybook._ -| Компонент | Описание | Статус | -|-----------|----------|--------| -| Button | Кнопка, варианты primary/accent/secondary/ghost/danger | ✅ готов | +**Кнопки:** `Button`, `PrintButton` +**Сообщения:** `Alert`, `RequestNumber` +**Форма (оболочка/раскладка):** `FormCard`, `FormGrid`, `FormField`, `FormActions`, `CollapseSection` +**Поля ввода:** `TextInput`, `TextArea`, `PhoneInput`, `MoneyInput`, `Select`, `Toggle` +**Режим чтения:** `FormModeProvider`, `useFormReadOnly` +**Иконки:** `Icon`, `IconSprite` +**Файлы:** `FileDropzone`, `AttachmentList` +**Модалка:** `ConfirmHost`, `confirmModal` +**Хуки:** `useFlipRows` + +> ⏳ В планах: `DataTable` (обобщение ItemsTable), `Badge`, `ErrorOverlay`, навигация (Tabs/Sidebar/Breadcrumbs). ## Разработка пакета diff --git a/package.json b/package.json index aa52a5a..7487a0a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tnsu/ui-kit", - "version": "0.0.1", + "version": "0.1.0", "description": "TNSU shared UI components for SPFx web parts", "author": "Timur Gorodetskiy", "license": "ISC", diff --git a/src/Alert/Alert.module.scss b/src/Alert/Alert.module.scss new file mode 100644 index 0000000..4cd4bda --- /dev/null +++ b/src/Alert/Alert.module.scss @@ -0,0 +1,30 @@ +@import '../tokens/tokens'; + +.alert { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + border: 1px solid; + border-radius: $r-md; + font-size: 12.5px; + margin-bottom: 18px; +} + +.warn { + background: $gold-50; + color: $amber-700; + border-color: $gold-300; +} + +.ok { + background: $green-50; + color: $green-600; + border-color: #bbf7d0; +} + +.err { + background: $red-50; + color: $red-500; + border-color: #fecaca; +} diff --git a/src/Alert/Alert.tsx b/src/Alert/Alert.tsx new file mode 100644 index 0000000..9eaccc1 --- /dev/null +++ b/src/Alert/Alert.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import styles from './Alert.module.scss'; +import { Icon } from '../Icon/Icon'; +import { IconName } from '../Icon/iconNames'; + +type AlertVariant = 'warn' | 'ok' | 'err'; + +interface IAlertProps { + variant: AlertVariant; + children: React.ReactNode; +} + +const variantClass: Record = { + warn: styles.warn, + ok: styles.ok, + err: styles.err +}; + +const variantIcon: Record = { + warn: 'warning', + ok: 'check', + err: 'close' +}; + +export const Alert: React.FC = ({ variant, children }) => { + const classes = [styles.alert, variantClass[variant]].join(' '); + + return ( +
+ + {children} +
+ ); +}; diff --git a/src/AttachmentList/AttachmentList.module.scss b/src/AttachmentList/AttachmentList.module.scss new file mode 100644 index 0000000..e6a40b3 --- /dev/null +++ b/src/AttachmentList/AttachmentList.module.scss @@ -0,0 +1,59 @@ +@import '../tokens/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 { + flex: 1; + min-width: 0; + color: $navy-700; + text-decoration: none; + + &:hover { + color: $gold-600; + text-decoration: underline; + } +} + +.removeBtn { + flex-shrink: 0; + 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: $gray-500; + cursor: pointer; + transition: .16s; + + &:hover { + border-color: $red-500; + color: $red-500; + } +} + +.empty { + color: $gray-500; + font-size: $fs-sm; +} diff --git a/src/AttachmentList/AttachmentList.tsx b/src/AttachmentList/AttachmentList.tsx new file mode 100644 index 0000000..6ff66af --- /dev/null +++ b/src/AttachmentList/AttachmentList.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import styles from './AttachmentList.module.scss'; +import { Icon } from '../Icon/Icon'; +/** + * Минимальная форма вложения для отображения. В request-portal этот тип жил в + * SP-сервисе (attachments.service) — здесь развязан, чтобы ui-kit не тянул + * SharePoint. Компоненту нужны только ссылка и имя файла. + */ +export interface IAttachmentInfo { + url: string; + fileName: string; +} + +interface IAttachmentListProps { + items: IAttachmentInfo[]; + /** Если задан — рядом с файлом показывается кнопка удаления (режим правки). */ + onRemove?: (fileName: string) => void; +} + +/** Список вложений заявки как ссылки на скачивание; с onRemove — ещё и удаление. */ +export const AttachmentList: React.FC = ({ items, onRemove }) => { + if (items.length === 0) { + return

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

; + } + + return ( +
    + {items.map((attachment) => ( +
  • + + + {attachment.fileName} + + {onRemove && ( + + )} +
  • + ))} +
+ ); +}; diff --git a/src/CollapseSection/CollapseSection.module.scss b/src/CollapseSection/CollapseSection.module.scss new file mode 100644 index 0000000..54126a6 --- /dev/null +++ b/src/CollapseSection/CollapseSection.module.scss @@ -0,0 +1,62 @@ +@import '../tokens/tokens'; + +.section { + margin-top: 8px; +} + +.header { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 14px 0 10px; + border: 0; + border-top: 1px solid $gray-200; + background: transparent; + cursor: pointer; + + &::before { + content: ''; + position: absolute; + top: -1px; + left: 0; + width: 48px; + height: 1px; + background: $gold-500; + } +} + +.title { + display: flex; + align-items: center; + gap: 10px; + font-family: $font-serif; + font-size: 14.5px; + font-weight: 600; + color: $navy-900; + letter-spacing: .2px; + + svg { color: $gold-600; } +} + +.chevron { + display: flex; + color: $gray-400; + transition: transform .2s; +} + +.open { + transform: rotate(180deg); +} + +.body { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px 22px; + padding-top: 16px; + + @media (max-width: 640px) { + grid-template-columns: 1fr; + } +} diff --git a/src/CollapseSection/CollapseSection.tsx b/src/CollapseSection/CollapseSection.tsx new file mode 100644 index 0000000..898a3f3 --- /dev/null +++ b/src/CollapseSection/CollapseSection.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { useState } from 'react'; +import styles from './CollapseSection.module.scss'; +import { Icon } from '../Icon/Icon'; +import { IconName } from '../Icon/iconNames'; + +interface ICollapseSectionProps { + title: string; + icon: IconName; + defaultOpen?: boolean; + children: React.ReactNode; +} + +/** Сворачиваемая секция формы: заголовок с иконкой и шевроном + тело. */ +export const CollapseSection: React.FC = ({ title, icon, defaultOpen = true, children }) => { + const [open, setOpen] = useState(defaultOpen); + + return ( +
+ + {open &&
{children}
} +
+ ); +}; diff --git a/src/ConfirmDialog/ConfirmDialog.module.scss b/src/ConfirmDialog/ConfirmDialog.module.scss new file mode 100644 index 0000000..41295fd --- /dev/null +++ b/src/ConfirmDialog/ConfirmDialog.module.scss @@ -0,0 +1,98 @@ +@import '../tokens/tokens'; + +// Поверх всего, включая SPFx-хром вебпарта. +.overlay { + position: fixed; + inset: 0; + z-index: 999997; + display: flex; + align-items: center; + justify-content: center; + padding: $sp-6; + background: rgba($navy-950, .55); + font-family: $font-sans; + // Плавное появление подложки. + animation: overlayIn .14s ease-out; +} + +.card { + box-sizing: border-box; + width: 100%; + max-width: 440px; + padding: $sp-5 $sp-5 $sp-4; + border-radius: $r-lg; + background: $white; + box-shadow: $sh-modal; + animation: cardIn .16s ease-out; +} + +.title { + font-family: $font-serif; + font-size: $fs-form-title; + font-weight: 600; + color: $navy-950; +} + +.message { + margin-top: $sp-2; + font-size: $fs-sm; + line-height: 1.5; + color: $gray-700; +} + +.actions { + display: flex; + justify-content: flex-end; + gap: $sp-2; + margin-top: $sp-5; +} + +.cancelBtn { + padding: 9px 18px; + border: 1px solid $gray-300; + border-radius: $r-sm; + background: $white; + color: $navy-800; + font-family: $font-sans; + font-size: $fs-sm; + cursor: pointer; + transition: .15s; + + &:hover { + border-color: $gold-400; + } +} + +.confirmBtn { + @include btn-accent; + padding: 9px 18px; + border-radius: $r-sm; + font-size: $fs-sm; + cursor: pointer; +} + +.confirmDanger { + padding: 9px 18px; + border: 0; + border-radius: $r-sm; + background: $red-500; + color: $white; + font-family: $font-sans; + font-size: $fs-sm; + cursor: pointer; + transition: .15s; + + &:hover { + filter: brightness(.94); + } +} + +@keyframes overlayIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes cardIn { + from { opacity: 0; transform: translateY(8px) scale(.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} diff --git a/src/ConfirmDialog/ConfirmDialog.tsx b/src/ConfirmDialog/ConfirmDialog.tsx new file mode 100644 index 0000000..36ba3ee --- /dev/null +++ b/src/ConfirmDialog/ConfirmDialog.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; +import { createPortal } from 'react-dom'; +import styles from './ConfirmDialog.module.scss'; +import { IConfirmOptions, registerConfirmHost } from './confirmModal'; + +interface IConfirmDialogProps extends IConfirmOptions { + onConfirm: () => void; + onCancel: () => void; +} + +/** Само окно подтверждения. Рендерится в document.body поверх всего (portal). */ +const ConfirmDialog: React.FC = ({ + title, + message, + confirmText = 'Продолжить', + cancelText = 'Отмена', + danger, + onConfirm, + onCancel, +}) => { + // Esc — отмена. Слушаем на всё окно, пока модалка открыта. + React.useEffect(() => { + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') onCancel(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onCancel]); + + return createPortal( + // Клик по фону = отмена; клик внутри карточки не всплывает до фона. +
+
e.stopPropagation()} + > + {title &&
{title}
} +
{message}
+
+ + +
+
+
, + document.body + ); +}; + +/** + * Хост модалки: монтируется ОДИН раз в портале. Слушает императивные вызовы + * confirmModal(...) и показывает окно; ответ пользователя резолвит их промис. + */ +export const ConfirmHost: React.FC = () => { + const [state, setState] = React.useState<{ + opts: IConfirmOptions; + resolve: (ok: boolean) => void; + } | undefined>(undefined); + + React.useEffect(() => registerConfirmHost((opts, resolve) => setState({ opts, resolve })), []); + + if (!state) return null; + + const close = (ok: boolean): void => { + state.resolve(ok); + setState(undefined); + }; + + return ( + close(true)} onCancel={() => close(false)} /> + ); +}; diff --git a/src/ConfirmDialog/confirmModal.ts b/src/ConfirmDialog/confirmModal.ts new file mode 100644 index 0000000..a48354f --- /dev/null +++ b/src/ConfirmDialog/confirmModal.ts @@ -0,0 +1,44 @@ +// Императивный вызов модалки подтверждения из любого места (в т.ч. из обычных +// модулей вроде shared/unsavedChanges.ts, где нет React-контекста). +// +// Модалка живёт в React-дереве (ConfirmHost, монтируется один раз в +// RequestPortal). Здесь — только «мостик»: confirmModal(...) отдаёт промис, +// который резолвится, когда пользователь нажмёт «Продолжить»/«Отмена». + +export interface IConfirmOptions { + /** Заголовок окна. */ + title?: string; + /** Основной текст-вопрос. */ + message: string; + /** Подпись подтверждающей кнопки. По умолчанию — «Продолжить». */ + confirmText?: string; + /** Подпись кнопки отмены. По умолчанию — «Отмена». */ + cancelText?: string; + /** Опасное действие (потеря данных) — подтверждающая кнопка красная. */ + danger?: boolean; +} + +type ConfirmHandler = (opts: IConfirmOptions, resolve: (ok: boolean) => void) => void; + +// Единственный подписчик — смонтированный ConfirmHost. Больше одного окна +// подтверждения одновременно нам не нужно. +let handler: ConfirmHandler | undefined; + +export function registerConfirmHost(fn: ConfirmHandler): () => void { + handler = fn; + return () => { + if (handler === fn) handler = undefined; + }; +} + +/** + * Показать модальное подтверждение. Промис резолвится в true («Продолжить») или + * false («Отмена»/Esc/клик по фону). Если хост почему-то не смонтирован — + * падаем на нативный confirm, чтобы не потерять защиту от потери данных. + */ +export function confirmModal(opts: IConfirmOptions): Promise { + if (!handler) { + return Promise.resolve(window.confirm(opts.message)); + } + return new Promise((resolve) => handler!(opts, resolve)); +} diff --git a/src/FileDropzone/FileDropzone.module.scss b/src/FileDropzone/FileDropzone.module.scss new file mode 100644 index 0000000..3daa747 --- /dev/null +++ b/src/FileDropzone/FileDropzone.module.scss @@ -0,0 +1,75 @@ +@import '../tokens/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/FileDropzone/FileDropzone.tsx b/src/FileDropzone/FileDropzone.tsx new file mode 100644 index 0000000..6d7be79 --- /dev/null +++ b/src/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/FormActions/FormActions.module.scss b/src/FormActions/FormActions.module.scss new file mode 100644 index 0000000..421b269 --- /dev/null +++ b/src/FormActions/FormActions.module.scss @@ -0,0 +1,15 @@ +@import '../tokens/tokens'; + +.actions { + position: sticky; + bottom: 0; + display: flex; + justify-content: flex-end; + gap: 10px; + margin: 26px -32px -32px; + padding: 16px 32px; + background: $white; + border-top: 1px solid $gray-200; + border-radius: 0 0 $r-lg $r-lg; + box-shadow: 0 -4px 16px rgba(15, 36, 64, .04); +} diff --git a/src/FormActions/FormActions.tsx b/src/FormActions/FormActions.tsx new file mode 100644 index 0000000..49d4f83 --- /dev/null +++ b/src/FormActions/FormActions.tsx @@ -0,0 +1,11 @@ +import * as React from 'react'; +import styles from './FormActions.module.scss'; + +interface IFormActionsProps { + children: React.ReactNode; +} + +/** Нижняя липкая панель действий формы («Отмена» / «Сохранить»). */ +export const FormActions: React.FC = ({ children }) => { + return
{children}
; +}; diff --git a/src/FormCard/FormCard.module.scss b/src/FormCard/FormCard.module.scss new file mode 100644 index 0000000..a76e618 --- /dev/null +++ b/src/FormCard/FormCard.module.scss @@ -0,0 +1,42 @@ +@import '../tokens/tokens'; + +.card { + background: $white; + border: 1px solid $gray-200; + border-radius: $r-lg; + box-shadow: $sh-1; + padding: 32px; +} + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.title { + font-family: $font-serif; + font-size: $fs-form-title; + font-weight: 600; + color: $navy-900; +} + +.headerRight { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 8px; +} + +.subtitle { + font-size: 12px; + color: $gray-400; + margin-top: 6px; + letter-spacing: .3px; +} + +.hairline { + @include hairline; + margin: 6px 0 22px; +} diff --git a/src/FormCard/FormCard.tsx b/src/FormCard/FormCard.tsx new file mode 100644 index 0000000..de73ce7 --- /dev/null +++ b/src/FormCard/FormCard.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import styles from './FormCard.module.scss'; + +interface IFormCardProps { + title: string; + subtitle?: string; + /** Контент в правом верхнем углу шапки (например, номер заявки). */ + headerRight?: React.ReactNode; + /** Строка под подзаголовком (например, инициатор заявки). */ + belowSubtitle?: React.ReactNode; + children: React.ReactNode; +} + +/** Белая карточка формы: заголовок (Lora) + золотой hairline + содержимое. */ +export const FormCard: React.FC = ({ title, subtitle, headerRight, belowSubtitle, children }) => { + return ( +
+
+
+
{title}
+ {subtitle &&
{subtitle}
} + {belowSubtitle} +
+ {headerRight &&
{headerRight}
} +
+
+ {children} +
+ ); +}; diff --git a/src/FormField/FormField.module.scss b/src/FormField/FormField.module.scss new file mode 100644 index 0000000..50cc892 --- /dev/null +++ b/src/FormField/FormField.module.scss @@ -0,0 +1,23 @@ +@import '../tokens/tokens'; + +.field { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.full { + grid-column: 1 / -1; +} + +.label { + font-size: $fs-label; + font-weight: 500; + color: $gray-700; + letter-spacing: .2px; +} + +.req { + color: $gold-600; +} diff --git a/src/FormField/FormField.tsx b/src/FormField/FormField.tsx new file mode 100644 index 0000000..cf07f90 --- /dev/null +++ b/src/FormField/FormField.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import styles from './FormField.module.scss'; + +interface IFormFieldProps { + label: string; + htmlFor?: string; + required?: boolean; + /** Занять обе колонки сетки. */ + full?: boolean; + children: React.ReactNode; +} + +/** Ячейка формы: подпись со звёздочкой для обязательных + контрол. */ +export const FormField: React.FC = ({ label, htmlFor, required, full, children }) => { + const classes = [styles.field, full ? styles.full : ''].filter(Boolean).join(' '); + + return ( +
+ + {children} +
+ ); +}; diff --git a/src/FormGrid/FormGrid.module.scss b/src/FormGrid/FormGrid.module.scss new file mode 100644 index 0000000..4cf95b6 --- /dev/null +++ b/src/FormGrid/FormGrid.module.scss @@ -0,0 +1,11 @@ +@import '../tokens/tokens'; + +.grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18px 22px; + + @media (max-width: 640px) { + grid-template-columns: 1fr; + } +} diff --git a/src/FormGrid/FormGrid.tsx b/src/FormGrid/FormGrid.tsx new file mode 100644 index 0000000..8132eb0 --- /dev/null +++ b/src/FormGrid/FormGrid.tsx @@ -0,0 +1,11 @@ +import * as React from 'react'; +import styles from './FormGrid.module.scss'; + +interface IFormGridProps { + children: React.ReactNode; +} + +/** Сетка полей в 2 колонки. Поля-дети — FormField (full — на всю ширину). */ +export const FormGrid: React.FC = ({ children }) => { + return
{children}
; +}; diff --git a/src/FormMode.tsx b/src/FormMode.tsx new file mode 100644 index 0000000..4b2f492 --- /dev/null +++ b/src/FormMode.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; + +// Режим формы «только чтение». Проставляется один раз оборачиванием +// (...), а поля-примитивы сами его подхватывают — +// не нужно тащить readOnly через каждую секцию и каждое поле. +const FormModeContext = React.createContext(false); + +export const FormModeProvider: React.FC<{ readOnly: boolean; children: React.ReactNode }> = ({ + readOnly, + children +}) => {children}; + +/** true, если форма отрисовывается в режиме «только чтение». */ +export function useFormReadOnly(): boolean { + return React.useContext(FormModeContext); +} diff --git a/src/Icon/Icon.module.scss b/src/Icon/Icon.module.scss new file mode 100644 index 0000000..056e75a --- /dev/null +++ b/src/Icon/Icon.module.scss @@ -0,0 +1,22 @@ +@import '../tokens/tokens'; + +.icon { + width: 20px; + height: 20px; + flex-shrink: 0; + fill: none; + stroke: currentColor; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.sm { + width: 16px; + height: 16px; +} + +.lg { + width: 24px; + height: 24px; +} diff --git a/src/Icon/Icon.tsx b/src/Icon/Icon.tsx new file mode 100644 index 0000000..1c835dd --- /dev/null +++ b/src/Icon/Icon.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import styles from './Icon.module.scss'; +import { IconName } from './iconNames'; + +type IconSize = 'sm' | 'md' | 'lg'; + +interface IIconProps { + name: IconName; + size?: IconSize; + className?: string; +} + +const sizeClass: Record = { + sm: styles.sm, + md: '', + lg: styles.lg +}; + +/** + * Иконка из общего спрайта. Спрайт (IconSprite) должен быть отрисован + * один раз в корне портала, иначе ссылки #i- ничего не покажут. + */ +export const Icon: React.FC = ({ name, size = 'md', className }) => { + const classes = [styles.icon, sizeClass[size], className].filter(Boolean).join(' '); + return ( + + ); +}; diff --git a/src/Icon/IconSprite.tsx b/src/Icon/IconSprite.tsx new file mode 100644 index 0000000..247f420 --- /dev/null +++ b/src/Icon/IconSprite.tsx @@ -0,0 +1,140 @@ +import * as React from 'react'; + +/** + * Скрытый SVG-спрайт со всеми иконками портала. + * Рендерится один раз в корне (RequestPortal). Компонент Icon ссылается + * на символы по id `#i-` (см. iconNames.ts). + */ +export const IconSprite: React.FC = () => ( + +); diff --git a/src/Icon/iconNames.ts b/src/Icon/iconNames.ts new file mode 100644 index 0000000..3ae8c48 --- /dev/null +++ b/src/Icon/iconNames.ts @@ -0,0 +1,37 @@ +// Единый набор имён иконок. Значение = id символа в спрайте (см. IconSprite). +// Добавляя иконку: 1) добавь имя сюда, 2) добавь в IconSprite. +export const ICON_NAMES = [ + 'building', + 'chart', + 'monitor', + 'calculator', + 'helmet', + 'user-check', + 'send', + 'download', + 'upload', + 'folder', + 'document', + 'calendar', + 'clipboard', + 'people', + 'vehicle', + 'chat', + 'camera', + 'image', + 'edit', + 'copy', + 'more', + 'trash', + 'close', + 'chevron-right', + 'chevron-down', + 'chevron-left', + 'arrow-left', + 'search', + 'warning', + 'check', + 'plus' +] as const; + +export type IconName = (typeof ICON_NAMES)[number]; diff --git a/src/MoneyInput/MoneyInput.tsx b/src/MoneyInput/MoneyInput.tsx new file mode 100644 index 0000000..2a1ec32 --- /dev/null +++ b/src/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/PhoneInput/PhoneInput.tsx b/src/PhoneInput/PhoneInput.tsx new file mode 100644 index 0000000..55254e3 --- /dev/null +++ b/src/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/PrintButton/PrintButton.tsx b/src/PrintButton/PrintButton.tsx new file mode 100644 index 0000000..8910f02 --- /dev/null +++ b/src/PrintButton/PrintButton.tsx @@ -0,0 +1,117 @@ +import * as React from 'react'; +import { Button } from '../Button/Button'; +import { Icon } from '../Icon/Icon'; + +// Значение поля для печати. Дату (input type=date, ISO yyyy-mm-dd) — в дд.мм.гггг. +function formatValue(el: HTMLInputElement): string { + if (el.type === 'date' && /^\d{4}-\d{2}-\d{2}$/.test(el.value)) { + const [y, m, d] = el.value.split('-'); + return `${d}.${m}.${y}`; + } + return el.value; +} + +// Печатаем как ДАННЫЕ, а не как форму: каждое поле ввода в клоне заменяем на +// обычный текст его значения (значения берём из живого DOM — React держит их как +// свойства, в клонированном HTML их нет). Так textarea/поля не занимают лишнего +// места и лист выглядит как справка, а не как незаполненный бланк. +function renderValuesAsText(source: Element, clone: Element): void { + const put = (cloneEl: Element | undefined, text: string): void => { + if (!cloneEl) return; + const div = document.createElement('div'); + div.className = 'rp-print-value'; + div.textContent = text && text.length > 0 ? text : '—'; + (cloneEl.closest('label') ?? cloneEl).replaceWith(div); + }; + + const cInputs = clone.querySelectorAll('input'); + source.querySelectorAll('input').forEach((el, i) => { + if (el.type === 'checkbox' || el.type === 'radio') { + put(cInputs[i], el.checked ? 'Да' : 'Нет'); + } else { + put(cInputs[i], formatValue(el)); + } + }); + + const cAreas = clone.querySelectorAll('textarea'); + source.querySelectorAll('textarea').forEach((el, i) => put(cAreas[i], el.value)); + + const cSelects = clone.querySelectorAll('select'); + source.querySelectorAll('select').forEach((el, i) => { + const opt = el.options[el.selectedIndex]; + put(cSelects[i], opt && opt.value ? opt.text : ''); + }); +} + +// Стили печатного окна: чистый лист (без «коробки» карточки), без иконок, +// значения — обычным текстом с переносами. +const PRINT_CSS = + // height:auto перебивает глобальный CSS SharePoint (html/body 100vh) — иначе + // body раздувается выше листа A4 и в печать идёт лишняя пустая страница. + 'html,body{margin:0;background:#fff;height:auto!important;min-height:0!important}' + + 'body{padding:24px;font-size:13px}' + + 'svg{display:none!important}' + + // Фото сотрудника (аватар персоны PeoplePicker) в печать не идёт — на бумаге + // нужны только ФИО и e-mail. Прячем весь круг-аватар, чтобы не оставался + // пустой кружок. Текст персоны (имя/почта) остаётся. + '.ms-Persona-imageArea,.ms-Persona-image{display:none!important}' + + '.rp-print-root>*{border:0!important;box-shadow:none!important;padding:0!important}' + + '.rp-print-value{white-space:pre-wrap;color:#000;padding:1px 0 2px}'; + +/** + * Кнопка «Печать»: переносит документ (`.rp-print-root`) в отдельное чистое окно, + * заменяя поля ввода на текст значений, и печатает его. Не зависит от скролл- + * контейнеров/высоты SharePoint, корректно разбивается на страницы. Общая для форм. + */ +export const PrintButton: React.FC<{ label?: string }> = ({ label = 'Печать' }) => { + const onPrint = (): void => { + const source = document.querySelector('.rp-print-root'); + if (!source) { + window.print(); + return; + } + const win = window.open('', '_blank', 'width=980,height=1200'); + if (!win) { + window.print(); // всплывающее окно заблокировано — печатаем как есть + return; + } + + const clone = source.cloneNode(true) as Element; + // Убираем из печати всё помеченное rp-no-print (напр. блок «Вложения»). + clone.querySelectorAll('.rp-no-print').forEach((el) => el.remove()); + renderValuesAsText(source, clone); + + // Переносим стили страницы (CSS-модули инжектятся как ${clone.outerHTML}` + ); + win.document.close(); + win.focus(); + + // Даём стилям/шрифтам примениться, затем печатаем и закрываем окно. + const done = (): void => { + win.print(); + win.close(); + }; + if (win.document.readyState === 'complete') { + window.setTimeout(done, 350); + } else { + win.onload = (): void => { + window.setTimeout(done, 350); + }; + } + }; + + return ( + + ); +}; diff --git a/src/RequestNumber/RequestNumber.module.scss b/src/RequestNumber/RequestNumber.module.scss new file mode 100644 index 0000000..63540d1 --- /dev/null +++ b/src/RequestNumber/RequestNumber.module.scss @@ -0,0 +1,16 @@ +@import '../tokens/tokens'; + +.badge { + display: inline-flex; + align-items: center; + padding: 5px 12px; + border: 1px solid $gray-200; + border-radius: $r-sm; + background: $navy-50; + color: $navy-800; + font-family: $font-sans; + font-size: 13px; + font-weight: 600; + letter-spacing: .3px; + white-space: nowrap; +} diff --git a/src/RequestNumber/RequestNumber.tsx b/src/RequestNumber/RequestNumber.tsx new file mode 100644 index 0000000..1886ff1 --- /dev/null +++ b/src/RequestNumber/RequestNumber.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; +import styles from './RequestNumber.module.scss'; + +/** Уникальный код заявки ({requestTypeId}-{YY}{MM}-{itemId}) — плашка в правом углу шапки формы. */ +export const RequestNumber: React.FC<{ code: string }> = ({ code }) => ( + №{code} +); diff --git a/src/Select/Select.module.scss b/src/Select/Select.module.scss new file mode 100644 index 0000000..703fb6f --- /dev/null +++ b/src/Select/Select.module.scss @@ -0,0 +1,20 @@ +@import '../tokens/tokens'; + +.select { + appearance: none; + padding: 11px 36px 11px 13px; + border: 1px solid $gray-300; + border-radius: $r-sm; + background-color: $white; + background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%236B7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + font-family: $font-sans; + font-size: 13.5px; + color: $gray-900; + cursor: pointer; + transition: .18s; + + @include input-focus-gold; + @include readonly-field(':disabled'); +} diff --git a/src/Select/Select.tsx b/src/Select/Select.tsx new file mode 100644 index 0000000..c77e9fa --- /dev/null +++ b/src/Select/Select.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import styles from './Select.module.scss'; +import { useFormReadOnly } from '../FormMode'; + +export interface ISelectOption { + value: string; + label: string; +} + +interface ISelectProps { + id?: string; + value: string; + onChange: (value: string) => void; + options: ISelectOption[]; + placeholder?: string; + /** Только чтение (режим согласующего). У select нет readonly — используем disabled. */ + readOnly?: boolean; +} + +/** Выпадающий список (SharePoint Choice). */ +export const Select: React.FC = ({ id, value, onChange, options, placeholder, readOnly }) => { + const contextReadOnly = useFormReadOnly(); + const effectiveReadOnly = readOnly ?? contextReadOnly; + return ( + + ); +}; diff --git a/src/TextArea/TextArea.module.scss b/src/TextArea/TextArea.module.scss new file mode 100644 index 0000000..2f40b5a --- /dev/null +++ b/src/TextArea/TextArea.module.scss @@ -0,0 +1,21 @@ +@import '../tokens/tokens'; + +.textarea { + 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; + resize: vertical; + min-height: 80px; + transition: .18s; + + @include input-focus-gold; + @include readonly-field(':read-only'); + + &::placeholder { + color: $gray-400; + } +} diff --git a/src/TextArea/TextArea.tsx b/src/TextArea/TextArea.tsx new file mode 100644 index 0000000..abdb5ee --- /dev/null +++ b/src/TextArea/TextArea.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import styles from './TextArea.module.scss'; +import { useFormReadOnly } from '../FormMode'; + +interface ITextAreaProps { + id?: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + rows?: number; + /** Только чтение (режим согласующего). */ + readOnly?: boolean; +} + +/** Многострочный ввод (SharePoint Note). */ +export const TextArea: React.FC = ({ id, value, onChange, placeholder, rows = 3, readOnly }) => { + const contextReadOnly = useFormReadOnly(); + const effectiveReadOnly = readOnly ?? contextReadOnly; + return ( +