- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
428 lines
15 KiB
TypeScript
428 lines
15 KiB
TypeScript
import { useState, useMemo, useRef } from 'react';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
|
||
import { Link2, Plus, Upload, FileText, X as XIcon } from 'lucide-react';
|
||
import { useToast } from '@/hooks/use-toast';
|
||
import { apiRequest, queryClient } from '@/lib/queryClient';
|
||
import { normalizeSelectOptions } from '@/lib/fieldOptions';
|
||
import { CreateTaskDialog } from '@/components/CreateTaskDialog';
|
||
|
||
export interface FormFieldData {
|
||
id: number;
|
||
name: string;
|
||
code: string;
|
||
type: string;
|
||
isRequired: boolean;
|
||
placeholder?: string;
|
||
options?: any[] | { tableId?: number; displayColumn?: string; [key: string]: any };
|
||
defaultValue?: string;
|
||
validationRules?: any;
|
||
requiredRules?: {
|
||
enabled?: boolean;
|
||
statusId?: number | "__creation__" | null;
|
||
logic?: "and" | "or";
|
||
conditions?: Array<{ fieldCode: string; operator?: "contains" | "empty" | "filled"; value?: string }>;
|
||
} | null;
|
||
immutableRules?: {
|
||
enabled?: boolean;
|
||
statusId?: number | "__creation__" | null;
|
||
logic?: "and" | "or";
|
||
conditions?: Array<{ fieldCode: string; operator?: "contains" | "empty" | "filled"; value?: string }>;
|
||
} | null;
|
||
visibilityRules?: {
|
||
enabled?: boolean;
|
||
statusId?: number | "__creation__" | null;
|
||
logic?: "and" | "or";
|
||
conditions?: Array<{ fieldCode: string; operator?: "contains" | "empty" | "filled"; value?: string }>;
|
||
} | null;
|
||
linkedFormId?: number | null;
|
||
companyAutofill?: Record<string, string>;
|
||
maxFileCount?: number | null;
|
||
maxFileSizeMB?: number | null;
|
||
autoProlongation?: boolean;
|
||
autoProlongationLabel?: string;
|
||
allowMultiple?: boolean;
|
||
ganttRole?: 'start' | 'end' | 'milestone' | null;
|
||
displayOrder?: number;
|
||
}
|
||
|
||
export interface UserData {
|
||
id: number;
|
||
firstName: string;
|
||
lastName: string;
|
||
middleName?: string;
|
||
email: string;
|
||
}
|
||
|
||
export type FileFieldValue = { url: string; name: string; size: number };
|
||
|
||
// Компонент выбора связанной задачи из другой формы
|
||
export function TaskFieldSelect({ formField, value, onChange, showCreateButton = true }: {
|
||
formField: FormFieldData;
|
||
value: any;
|
||
onChange: (value: string) => void;
|
||
showCreateButton?: boolean;
|
||
}) {
|
||
const linkedFormId = formField.linkedFormId;
|
||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||
|
||
// Загружаем задачи связанной формы
|
||
const { data: tasksData, isLoading, error } = useQuery<{ tasks: Array<{ id: number; title: string }> }>({
|
||
queryKey: ['/api/forms', linkedFormId, 'tasks'],
|
||
enabled: !!linkedFormId,
|
||
staleTime: 5 * 60 * 1000,
|
||
gcTime: 10 * 60 * 1000,
|
||
retry: false,
|
||
});
|
||
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
||
const tasks = tasksData?.tasks || [];
|
||
const selectedTask = tasks.find(t => t.id.toString() === value?.toString());
|
||
|
||
const sortedTasks = useMemo(() => {
|
||
return [...tasks].sort((a, b) => (a.title || '').localeCompare(b.title || '', 'ru'));
|
||
}, [tasks]);
|
||
|
||
const filtered = useMemo(() => {
|
||
if (!searchTerm.trim()) return sortedTasks;
|
||
const term = searchTerm.toLowerCase();
|
||
return sortedTasks.filter(t =>
|
||
t.title.toLowerCase().includes(term) ||
|
||
t.id.toString().includes(term)
|
||
);
|
||
}, [sortedTasks, searchTerm]);
|
||
|
||
const handleSelect = (taskId: string) => {
|
||
onChange(taskId);
|
||
setSearchTerm('');
|
||
setIsOpen(false);
|
||
};
|
||
|
||
if (!linkedFormId) {
|
||
return <div className="text-sm text-muted-foreground">Связанная форма не настроена</div>;
|
||
}
|
||
|
||
const inputPlaceholder = isLoading
|
||
? 'Загрузка задач...'
|
||
: error
|
||
? 'Нет доступа к связанной форме'
|
||
: !tasks.length
|
||
? 'Нет задач в связанной форме'
|
||
: (formField.placeholder || 'Выберите задачу...');
|
||
|
||
return (
|
||
<>
|
||
<Popover open={isOpen} onOpenChange={setIsOpen} modal={false}>
|
||
<PopoverAnchor asChild>
|
||
<div className="relative w-full flex items-center gap-2">
|
||
<Input
|
||
ref={inputRef}
|
||
className="h-8 flex-1"
|
||
placeholder={inputPlaceholder}
|
||
disabled={isLoading || !!error}
|
||
value={isOpen ? searchTerm : (selectedTask ? selectedTask.title : '')}
|
||
onChange={(e) => {
|
||
setSearchTerm(e.target.value);
|
||
setIsOpen(true);
|
||
}}
|
||
onClick={() => {
|
||
if (isLoading || error || !tasks.length) return;
|
||
setSearchTerm('');
|
||
setIsOpen(true);
|
||
}}
|
||
data-testid={`select-task-${formField.code}`}
|
||
/>
|
||
{showCreateButton && (
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="icon"
|
||
className="h-8 w-8 shrink-0"
|
||
title="Создать связанную запись"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setCreateDialogOpen(true);
|
||
}}
|
||
data-testid={`button-create-linked-task-${formField.code}`}
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</PopoverAnchor>
|
||
{!isLoading && !error && (
|
||
<PopoverContent
|
||
className="p-0"
|
||
style={{ width: 'var(--radix-popper-anchor-width)' }}
|
||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||
onInteractOutside={(e) => {
|
||
if (inputRef.current && inputRef.current.contains(e.target as Node)) {
|
||
e.preventDefault();
|
||
}
|
||
}}
|
||
>
|
||
<ul className="max-h-60 overflow-auto py-1">
|
||
<li
|
||
className="cursor-pointer px-3 py-2 hover:bg-accent text-sm text-muted-foreground"
|
||
onMouseDown={(e) => { e.preventDefault(); handleSelect(''); }}
|
||
>
|
||
—
|
||
</li>
|
||
{filtered.map(task => (
|
||
<li
|
||
key={task.id}
|
||
className="cursor-pointer px-3 py-2 hover:bg-accent"
|
||
onMouseDown={(e) => { e.preventDefault(); handleSelect(task.id.toString()); }}
|
||
>
|
||
<div className="flex items-center gap-2 text-sm">
|
||
<Link2 className="w-3 h-3 text-muted-foreground" />
|
||
{task.title}
|
||
</div>
|
||
</li>
|
||
))}
|
||
{filtered.length === 0 && (
|
||
<li className="px-3 py-2 text-sm text-muted-foreground">Нет совпадений</li>
|
||
)}
|
||
</ul>
|
||
</PopoverContent>
|
||
)}
|
||
</Popover>
|
||
|
||
<CreateTaskDialog
|
||
formId={linkedFormId}
|
||
open={createDialogOpen}
|
||
onOpenChange={setCreateDialogOpen}
|
||
onCreated={(task) => {
|
||
onChange(task.id.toString());
|
||
queryClient.invalidateQueries({ queryKey: ['/api/forms', linkedFormId, 'tasks'] });
|
||
setCreateDialogOpen(false);
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// Компонент выбора пользователя с учетом фильтра из настроек формы
|
||
export function UserFieldSelect({
|
||
formField,
|
||
users,
|
||
value,
|
||
onChange
|
||
}: {
|
||
formField: FormFieldData;
|
||
users: UserData[];
|
||
value: any;
|
||
onChange: (value: string) => void;
|
||
}) {
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
||
// Применяем первичный фильтр из настроек формы (userFilter)
|
||
const userFilter = (formField.options as any)?.userFilter as { userIds?: number[]; roleIds?: number[] } | undefined;
|
||
|
||
const allowedUsers = useMemo(() => {
|
||
if (!userFilter?.userIds || userFilter.userIds.length === 0) return users;
|
||
return users.filter((u) => userFilter.userIds!.includes(u.id));
|
||
}, [users, userFilter]);
|
||
|
||
const selectedUser = allowedUsers.find(u => u.id.toString() === value?.toString());
|
||
|
||
const sortedUsers = useMemo(() => {
|
||
return [...allowedUsers].sort((a, b) =>
|
||
`${a.firstName} ${a.middleName || ''} ${a.lastName}`.localeCompare(`${b.firstName} ${b.middleName || ''} ${b.lastName}`, 'ru')
|
||
);
|
||
}, [allowedUsers]);
|
||
|
||
const filtered = useMemo(() => {
|
||
if (!searchTerm.trim()) return sortedUsers;
|
||
const term = searchTerm.toLowerCase();
|
||
return sortedUsers.filter(u =>
|
||
`${u.firstName} ${u.middleName || ''} ${u.lastName}`.toLowerCase().includes(term) ||
|
||
u.email?.toLowerCase().includes(term)
|
||
);
|
||
}, [sortedUsers, searchTerm]);
|
||
|
||
const handleSelect = (userId: string) => {
|
||
onChange(userId);
|
||
setSearchTerm('');
|
||
setIsOpen(false);
|
||
};
|
||
|
||
return (
|
||
<Popover open={isOpen} onOpenChange={setIsOpen} modal={false}>
|
||
<PopoverAnchor asChild>
|
||
<div className="relative w-full">
|
||
<Input
|
||
ref={inputRef}
|
||
className="h-8"
|
||
placeholder={formField.placeholder || 'Выберите пользователя...'}
|
||
value={isOpen ? searchTerm : (selectedUser ? `${selectedUser.firstName} ${selectedUser.middleName || ''} ${selectedUser.lastName}` : '')}
|
||
onChange={(e) => {
|
||
setSearchTerm(e.target.value);
|
||
setIsOpen(true);
|
||
}}
|
||
onClick={() => {
|
||
setSearchTerm('');
|
||
setIsOpen(true);
|
||
}}
|
||
data-testid={`select-user-${formField.code}`}
|
||
/>
|
||
</div>
|
||
</PopoverAnchor>
|
||
<PopoverContent
|
||
className="p-0"
|
||
style={{ width: 'var(--radix-popper-anchor-width)' }}
|
||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||
onInteractOutside={(e) => {
|
||
if (inputRef.current && inputRef.current.contains(e.target as Node)) {
|
||
e.preventDefault();
|
||
}
|
||
}}
|
||
>
|
||
<ul className="max-h-60 overflow-auto py-1">
|
||
<li
|
||
className="cursor-pointer px-3 py-2 hover:bg-accent text-sm text-muted-foreground"
|
||
onMouseDown={(e) => { e.preventDefault(); handleSelect(''); }}
|
||
>
|
||
—
|
||
</li>
|
||
{filtered.map(user => (
|
||
<li
|
||
key={user.id}
|
||
className="cursor-pointer px-3 py-2 hover:bg-accent"
|
||
onMouseDown={(e) => { e.preventDefault(); handleSelect(user.id.toString()); }}
|
||
>
|
||
<div className="text-sm">{user.firstName} {user.middleName} {user.lastName}</div>
|
||
{user.email && <div className="text-xs text-muted-foreground">{user.email}</div>}
|
||
</li>
|
||
))}
|
||
{filtered.length === 0 && (
|
||
<li className="px-3 py-2 text-sm text-muted-foreground">Нет совпадений</li>
|
||
)}
|
||
</ul>
|
||
</PopoverContent>
|
||
</Popover>
|
||
);
|
||
}
|
||
|
||
// Компонент загрузки файлов для формы создания задачи (поддержка нескольких файлов)
|
||
export function FileUploadField({ value, onChange, field }: {
|
||
value: FileFieldValue[] | null;
|
||
onChange: (v: FileFieldValue[] | null) => void;
|
||
field?: FormFieldData;
|
||
}) {
|
||
const { toast } = useToast();
|
||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||
const [isUploading, setIsUploading] = useState(false);
|
||
|
||
const files: FileFieldValue[] = Array.isArray(value) ? value : [];
|
||
const countLimitReached = field?.maxFileCount != null && files.length >= field.maxFileCount;
|
||
|
||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
|
||
if (field?.maxFileCount != null && files.length >= field.maxFileCount) {
|
||
toast({
|
||
title: 'Превышен лимит файлов',
|
||
description: `Максимальное количество файлов для этого поля: ${field.maxFileCount}`,
|
||
variant: 'destructive',
|
||
});
|
||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||
return;
|
||
}
|
||
|
||
if (field?.maxFileSizeMB != null) {
|
||
const currentTotalBytes = files.reduce((sum, f) => sum + (f.size || 0), 0);
|
||
const limitBytes = field.maxFileSizeMB * 1024 * 1024;
|
||
if (currentTotalBytes + file.size > limitBytes) {
|
||
toast({
|
||
title: 'Превышен лимит размера',
|
||
description: `Суммарный размер файлов не должен превышать ${field.maxFileSizeMB} МБ`,
|
||
variant: 'destructive',
|
||
});
|
||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||
return;
|
||
}
|
||
}
|
||
|
||
setIsUploading(true);
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
const uploadUrl = field?.id ? `/api/upload?fieldId=${field.id}` : '/api/upload';
|
||
const res = await fetch(uploadUrl, {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
body: formData,
|
||
});
|
||
if (!res.ok) {
|
||
const errData = await res.json().catch(() => ({}));
|
||
throw new Error(errData.error || 'Ошибка загрузки файла');
|
||
}
|
||
const uploaded = await res.json();
|
||
const newFiles = [...files, { url: String(uploaded.url), name: String(uploaded.name), size: Number(uploaded.size) }];
|
||
onChange(newFiles);
|
||
toast({ title: 'Файл добавлен', description: String(uploaded.name) });
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : 'Ошибка';
|
||
toast({ title: 'Ошибка загрузки', description: msg, variant: 'destructive' });
|
||
} finally {
|
||
setIsUploading(false);
|
||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||
}
|
||
};
|
||
|
||
const handleRemove = (index: number) => {
|
||
const newFiles = files.filter((_, i) => i !== index);
|
||
onChange(newFiles.length > 0 ? newFiles : null);
|
||
};
|
||
|
||
return (
|
||
<div className="flex flex-col gap-1">
|
||
{field?.maxFileCount != null && (
|
||
<span className="text-xs text-muted-foreground">{files.length}/{field.maxFileCount} файлов</span>
|
||
)}
|
||
{files.map((fileInfo, idx) => (
|
||
<span key={idx} className="inline-flex items-center gap-1 text-sm text-primary">
|
||
<FileText className="w-3 h-3" />
|
||
{fileInfo.name}
|
||
<button
|
||
type="button"
|
||
onClick={() => handleRemove(idx)}
|
||
className="ml-1 text-muted-foreground hover:text-destructive"
|
||
title="Удалить файл"
|
||
>
|
||
<XIcon className="w-3 h-3" />
|
||
</button>
|
||
</span>
|
||
))}
|
||
{!countLimitReached && (
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8 w-fit"
|
||
disabled={isUploading}
|
||
onClick={() => fileInputRef.current?.click()}
|
||
>
|
||
<Upload className="w-3.5 h-3.5 mr-1.5" />
|
||
{isUploading ? 'Загрузка...' : files.length > 0 ? 'Добавить файл' : 'Выбрать файл'}
|
||
</Button>
|
||
)}
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
className="hidden"
|
||
onChange={handleFileChange}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|