feat: added app routing
This commit is contained in:
@@ -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