From 9c6eaa5dcfc00adbb69cdf7867ed3a75acedbc41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Wed, 15 Jul 2026 10:56:25 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=BE=D0=B4=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=B8=20=E2=80=94=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B2=20=D1=84=D0=BE=D1=80=D0=BC=D0=B5=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D1=87=D0=B8,=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B8=20=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=BD?= =?UTF-8?q?=D0=B0=D1=87=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/CreateTaskFormContent.tsx | 19 ++- .../components/SubdirectoryFieldConfig.tsx | 4 +- .../src/components/TableFieldMultiSelect.tsx | 9 +- client/src/components/TableFieldSelect.tsx | 9 +- .../components/TableFieldWithSubdirectory.tsx | 142 ++++++++++++++++++ client/src/lib/fieldDisplay.ts | 37 ++++- client/src/pages/TaskDetail.tsx | 15 +- server/routes/data-tables.routes.ts | 16 ++ 8 files changed, 238 insertions(+), 13 deletions(-) create mode 100644 client/src/components/TableFieldWithSubdirectory.tsx diff --git a/client/src/components/CreateTaskFormContent.tsx b/client/src/components/CreateTaskFormContent.tsx index 891e716..aeb3064 100644 --- a/client/src/components/CreateTaskFormContent.tsx +++ b/client/src/components/CreateTaskFormContent.tsx @@ -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({ ); } - case 'table': + case 'table': { + const opts = formField.options && !Array.isArray(formField.options) ? (formField.options as Record) : {}; + const showSubdirectory = !!opts.showSubdirectory; + if (showSubdirectory) { + return ( + + ); + } if (formField.allowMultiple) { return ( ); + } case 'task': return ( { + 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, }, }); }; diff --git a/client/src/components/TableFieldMultiSelect.tsx b/client/src/components/TableFieldMultiSelect.tsx index 4469e57..cc9a6b8 100644 --- a/client/src/components/TableFieldMultiSelect.tsx +++ b/client/src/components/TableFieldMultiSelect.tsx @@ -29,6 +29,7 @@ export default function TableFieldMultiSelect({ const opts = options && !Array.isArray(options) ? (options as Record) : {}; const tableId = opts.tableId as number | undefined; const displayColumn = (opts.displayColumn as string) || ''; + const rowFilters = opts.filters as Record | 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, diff --git a/client/src/components/TableFieldSelect.tsx b/client/src/components/TableFieldSelect.tsx index 2711c5c..f513291 100644 --- a/client/src/components/TableFieldSelect.tsx +++ b/client/src/components/TableFieldSelect.tsx @@ -27,6 +27,7 @@ export default function TableFieldSelect({ const opts = options && !Array.isArray(options) ? (options as Record) : {}; const tableId = opts.tableId as number | undefined; const displayColumn = (opts.displayColumn as string) || ''; + const rowFilters = opts.filters as Record | 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, diff --git a/client/src/components/TableFieldWithSubdirectory.tsx b/client/src/components/TableFieldWithSubdirectory.tsx new file mode 100644 index 0000000..0804fb6 --- /dev/null +++ b/client/src/components/TableFieldWithSubdirectory.tsx @@ -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) : {}; + 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 ( + + ); + } + + const parentOptions = opts; + const childOptions = { + ...opts, + tableId: subdirectory.childTableId, + displayColumn: subdirectory.childTable?.columns?.[subdirectory.childColumnIndex ?? 0]?.name || undefined, + }; + + return ( +
+ + +
+ ); +} diff --git a/client/src/lib/fieldDisplay.ts b/client/src/lib/fieldDisplay.ts index a92a5d0..e2e32ee 100644 --- a/client/src/lib/fieldDisplay.ts +++ b/client/src/lib/fieldDisplay.ts @@ -131,15 +131,32 @@ export function resolveFieldDisplayValue( } case 'table': { - const tableId = (field.options as Record | 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 | 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>(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': diff --git a/client/src/pages/TaskDetail.tsx b/client/src/pages/TaskDetail.tsx index 641af5b..6f84f9b 100644 --- a/client/src/pages/TaskDetail.tsx +++ b/client/src/pages/TaskDetail.tsx @@ -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) : {}; + const showSubdirectory = !!opts.showSubdirectory; return (
e.stopPropagation()}> - {field.allowMultiple ? ( + {showSubdirectory ? ( + onDraftChange?.(val)} + allowMultiple={field.allowMultiple || false} + /> + ) : field.allowMultiple ? ( = 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 => {