feat: подсправочники — отображение в форме задачи, фильтрация и отображение значений
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
142
client/src/components/TableFieldWithSubdirectory.tsx
Normal file
142
client/src/components/TableFieldWithSubdirectory.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user