feat: подсправочники — отображение в форме задачи, фильтрация и отображение значений

This commit is contained in:
2026-07-15 10:56:25 +03:00
parent f560facabd
commit 9c6eaa5dcf
8 changed files with 238 additions and 13 deletions

View File

@@ -17,6 +17,7 @@ import GeoField from '@/components/GeoField';
import ContractNumberField from '@/components/ContractNumberField';
import TableFieldSelect from '@/components/TableFieldSelect';
import TableFieldMultiSelect from '@/components/TableFieldMultiSelect';
import TableFieldWithSubdirectory from '@/components/TableFieldWithSubdirectory';
import ContactField from '@/components/ContactField';
import { SearchableSelect } from '@/components/fields/SearchableSelect';
import { SearchableMultiSelect } from '@/components/fields/SearchableMultiSelect';
@@ -592,7 +593,22 @@ export function CreateTaskFormContent({
</div>
);
}
case 'table':
case 'table': {
const opts = formField.options && !Array.isArray(formField.options) ? (formField.options as Record<string, any>) : {};
const showSubdirectory = !!opts.showSubdirectory;
if (showSubdirectory) {
return (
<TableFieldWithSubdirectory
fieldId={formField.id}
fieldCode={formField.code}
placeholder={formField.placeholder}
options={formField.options}
value={field.value}
onChange={field.onChange}
allowMultiple={formField.allowMultiple || false}
/>
);
}
if (formField.allowMultiple) {
return (
<TableFieldMultiSelect
@@ -615,6 +631,7 @@ export function CreateTaskFormContent({
onChange={field.onChange}
/>
);
}
case 'task':
return (
<TaskFieldSelect

View File

@@ -94,10 +94,12 @@ export default function SubdirectoryFieldConfig({
};
const handleSubdirectoryChange = (value: string) => {
const newSubdirectory = value === 'none' ? undefined : subdirectories.find((s) => s.id?.toString() === value || s.childTableId?.toString() === value);
updateField(index, {
options: {
...field.options,
subdirectoryId: value === 'none' ? undefined : parseInt(value),
subdirectoryId: newSubdirectory?.id,
childTableId: newSubdirectory?.childTableId,
},
});
};

View File

@@ -29,6 +29,7 @@ export default function TableFieldMultiSelect({
const opts = options && !Array.isArray(options) ? (options as Record<string, any>) : {};
const tableId = opts.tableId as number | undefined;
const displayColumn = (opts.displayColumn as string) || '';
const rowFilters = opts.filters as Record<string, string> | undefined;
const { data: tableData, isLoading, error } = useQuery<{
table: {
@@ -45,13 +46,17 @@ export default function TableFieldMultiSelect({
retry: false,
});
const filtersStr = useMemo(() => (rowFilters ? JSON.stringify(rowFilters) : ''), [rowFilters]);
const { data: treeData } = useQuery<{
columnIndex: number | null;
nodes: DirectoryTreeNode[];
}>({
queryKey: ['/api/directories', tableId, 'rows', 'tree'],
queryKey: ['/api/directories', tableId, 'rows', 'tree', filtersStr],
queryFn: async () => {
const res = await apiRequest('GET', `/api/directories/${tableId}/rows/tree`);
const params = new URLSearchParams();
if (filtersStr) params.set('filters', filtersStr);
const res = await apiRequest('GET', `/api/directories/${tableId}/rows/tree?${params.toString()}`);
return res.json();
},
enabled: !!tableId && !!tableData,

View File

@@ -27,6 +27,7 @@ export default function TableFieldSelect({
const opts = options && !Array.isArray(options) ? (options as Record<string, any>) : {};
const tableId = opts.tableId as number | undefined;
const displayColumn = (opts.displayColumn as string) || '';
const rowFilters = opts.filters as Record<string, string> | undefined;
const { data: tableData, isLoading, error } = useQuery<{
table: {
@@ -43,13 +44,17 @@ export default function TableFieldSelect({
retry: false,
});
const filtersStr = useMemo(() => (rowFilters ? JSON.stringify(rowFilters) : ''), [rowFilters]);
const { data: treeData } = useQuery<{
columnIndex: number | null;
nodes: DirectoryTreeNode[];
}>({
queryKey: ['/api/directories', tableId, 'rows', 'tree'],
queryKey: ['/api/directories', tableId, 'rows', 'tree', filtersStr],
queryFn: async () => {
const res = await apiRequest('GET', `/api/directories/${tableId}/rows/tree`);
const params = new URLSearchParams();
if (filtersStr) params.set('filters', filtersStr);
const res = await apiRequest('GET', `/api/directories/${tableId}/rows/tree?${params.toString()}`);
return res.json();
},
enabled: !!tableId && !!tableData,

View File

@@ -0,0 +1,142 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiRequest } from '@/lib/queryClient';
import TableFieldSelect from '@/components/TableFieldSelect';
import TableFieldMultiSelect from '@/components/TableFieldMultiSelect';
import type { DataTableSubdirectory } from '@shared/schema';
interface TableFieldValue {
parentId?: string | null;
childId?: string | null;
}
interface TableFieldWithSubdirectoryProps {
fieldId: number;
fieldCode: string;
placeholder?: string;
options?: unknown;
value: any;
onChange: (value: string) => void;
allowMultiple?: boolean;
}
function parseTableValue(value: any): TableFieldValue {
if (value == null || value === '') return { parentId: null, childId: null };
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
if (parsed && typeof parsed === 'object') {
return {
parentId: parsed.parentId != null ? String(parsed.parentId) : null,
childId: parsed.childId != null ? String(parsed.childId) : null,
};
}
} catch {
// fallback: plain string is parentId
}
return { parentId: value, childId: null };
}
if (typeof value === 'object') {
return {
parentId: value.parentId != null ? String(value.parentId) : null,
childId: value.childId != null ? String(value.childId) : null,
};
}
return { parentId: String(value), childId: null };
}
function serializeTableValue(parentId: string | null | undefined, childId: string | null | undefined): string {
const p = parentId || null;
const c = childId || null;
if (!p) return '';
if (!c) return p;
return JSON.stringify({ parentId: p, childId: c });
}
export default function TableFieldWithSubdirectory({
fieldId,
fieldCode,
placeholder,
options,
value,
onChange,
allowMultiple,
}: TableFieldWithSubdirectoryProps) {
const opts = options && !Array.isArray(options) ? (options as Record<string, any>) : {};
const tableId = opts.tableId as number | undefined;
const showSubdirectory = !!opts.showSubdirectory;
const subdirectoryId = opts.subdirectoryId as number | undefined;
const { data: subdirectoriesData } = useQuery<{ subdirectories: DataTableSubdirectory[] }>({
queryKey: ['/api/directories', tableId, 'subdirectories'],
queryFn: async () => {
const res = await apiRequest('GET', `/api/directories/${tableId}/subdirectories`);
return res.json();
},
enabled: !!tableId && showSubdirectory,
staleTime: 60 * 1000,
});
const subdirectory = useMemo(() => {
return subdirectoriesData?.subdirectories?.find((s) => s.id === subdirectoryId);
}, [subdirectoriesData, subdirectoryId]);
const { parentId, childId } = parseTableValue(value);
const handleParentChange = (newParentId: string) => {
// When parent changes, keep child only if it still belongs to new parent (simplified: reset child)
onChange(serializeTableValue(newParentId || null, null));
};
const handleChildChange = (newChildId: string) => {
onChange(serializeTableValue(parentId, newChildId || null));
};
const childFilter = useMemo(() => {
if (!subdirectory || !parentId) return undefined;
const col = String(subdirectory.childColumnIndex ?? 0);
return { [col]: parentId };
}, [subdirectory, parentId]);
if (!showSubdirectory || !subdirectory || allowMultiple) {
const SelectComponent = allowMultiple ? TableFieldMultiSelect : TableFieldSelect;
return (
<SelectComponent
fieldId={fieldId}
fieldCode={fieldCode}
placeholder={placeholder}
options={options}
value={parentId || ''}
onChange={onChange as any}
/>
);
}
const parentOptions = opts;
const childOptions = {
...opts,
tableId: subdirectory.childTableId,
displayColumn: subdirectory.childTable?.columns?.[subdirectory.childColumnIndex ?? 0]?.name || undefined,
};
return (
<div className="space-y-2">
<TableFieldSelect
fieldId={fieldId}
fieldCode={fieldCode}
placeholder={placeholder || 'Выберите значение...'}
options={parentOptions}
value={parentId || ''}
onChange={handleParentChange}
/>
<TableFieldSelect
fieldId={fieldId}
fieldCode={`${fieldCode}_sub`}
placeholder={subdirectory.childTable?.name || 'Выберите подсправочник...'}
options={childOptions}
value={childId || ''}
onChange={handleChildChange}
/>
</div>
);
}

View File

@@ -131,15 +131,32 @@ export function resolveFieldDisplayValue(
}
case 'table': {
const tableId = (field.options as Record<string, number> | undefined)?.tableId;
const resolveRow = (rowId: number) => {
if (tableId && !isNaN(rowId) && context.tableMap?.has(tableId)) {
const tableRows = context.tableMap.get(tableId)!;
const opts = (field.options as Record<string, any> | undefined) || {};
const tableId = opts.tableId as number | undefined;
const showSubdirectory = !!opts.showSubdirectory;
const subdirectoryId = opts.subdirectoryId as number | undefined;
// Поддержка JSON-значения { parentId, childId } для подсправочников
let parentValue: string | null = null;
let childValue: string | null = null;
const parsedObj = parseJson<Record<string, unknown>>(value);
if (parsedObj && (parsedObj.parentId !== undefined || parsedObj.childId !== undefined)) {
parentValue = safeString(parsedObj.parentId || '');
childValue = safeString(parsedObj.childId || '');
} else {
parentValue = rawStr;
}
const resolveRow = (rowId: number, targetTableId?: number) => {
const tid = targetTableId ?? tableId;
if (tid && !isNaN(rowId) && context.tableMap?.has(tid)) {
const tableRows = context.tableMap.get(tid)!;
const displayValue = tableRows.get(rowId);
if (displayValue !== undefined) return displayValue;
}
return String(rowId);
};
if (Array.isArray(value)) {
return value
.map((v) => {
@@ -149,7 +166,15 @@ export function resolveFieldDisplayValue(
.filter(Boolean)
.join(', ');
}
const rowId = Number(rawStr);
if (showSubdirectory && childValue) {
const parentLabel = resolveRow(Number(parentValue));
const childTableId = opts.childTableId as number | undefined;
const childLabel = resolveRow(Number(childValue), childTableId);
return `${parentLabel} → ${childLabel}`;
}
const rowId = Number(parentValue);
if (tableId && !isNaN(rowId) && context.tableMap?.has(tableId)) {
const tableRows = context.tableMap.get(tableId)!;
const displayValue = tableRows.get(rowId);
@@ -165,7 +190,7 @@ export function resolveFieldDisplayValue(
if (displayValue !== undefined) return displayValue;
}
}
return rawStr;
return parentValue || rawStr;
}
case 'select':

View File

@@ -47,6 +47,7 @@ import ContractNumberField from '@/components/ContractNumberField';
import ContractNumberDisplay from '@/components/ContractNumberDisplay';
import TableFieldSelect from '@/components/TableFieldSelect';
import TableFieldMultiSelect from '@/components/TableFieldMultiSelect';
import TableFieldWithSubdirectory from '@/components/TableFieldWithSubdirectory';
import ContactField from '@/components/ContactField';
import { UserRoleRenderer, UserRoleEditor } from '@/components/fields';
import { InlineField } from '@/components/fields/InlineField';
@@ -1164,9 +1165,21 @@ function FieldValueRow({
);
}
if (field.type === 'table') {
const opts = field.options && !Array.isArray(field.options) ? (field.options as Record<string, any>) : {};
const showSubdirectory = !!opts.showSubdirectory;
return (
<div className="w-full max-w-[280px]" onClick={e => e.stopPropagation()}>
{field.allowMultiple ? (
{showSubdirectory ? (
<TableFieldWithSubdirectory
fieldId={field.id}
fieldCode={field.code}
placeholder={field.placeholder}
options={field.options}
value={editorValue}
onChange={(val) => onDraftChange?.(val)}
allowMultiple={field.allowMultiple || false}
/>
) : field.allowMultiple ? (
<TableFieldMultiSelect
fieldId={field.id}
fieldCode={field.code}

View File

@@ -505,6 +505,22 @@ export function registerDataTableRoutes(app: import("express").Express): void {
const treeColumnIndex = table.treeColumnIndex;
const search = String(req.query.search || '').toLowerCase();
const filtersStr = req.query.filters as string;
if (filtersStr) {
try {
const filters: Record<string, string> = JSON.parse(filtersStr);
Object.entries(filters).forEach(([col, val]) => {
const ci = parseInt(col);
if (!isNaN(ci) && val) {
rows = rows.filter((r) => {
const v = Array.isArray(r.values) ? r.values : [];
return String(v[ci] || '').toLowerCase() === String(val).toLowerCase();
});
}
});
} catch {}
}
const displayColumnIndex = table.treeDisplayColumnIndex ?? 0;
const getRowLabel = (row: typeof rows[number]): string => {