Files
iistwin/client/src/components/TaskRegularTable.tsx
Ильяс Султанов 7d975b0b00 fix(ui): пакет фиксов по ревью better-interface (HIGH+MEDIUM)
HIGH:
- mobile: поля задачи доступны для форм без вкладок (fallback-аккордеон)
- удаление вложений доступно на тач-устройствах (кнопка всегда видна)
- бейдж статуса: контрастный текст по яркости (statusColor.ts), также в реестре и канбане
- кнопки согласования: контрастные дефолты #15803d/#b91c1c + текст по яркости
- клавиатура: focus-visible у карандаша, focus-ring у поиска, InlineField с клавиатуры

MEDIUM:
- iOS: инпуты text-base на мобильном (убран зум при фокусе)
- системные поля унифицированы с кастомными (inline label+value)
- тач-таргеты ≥32px, кнопка удаления задачи отделена ms-auto
- токены --warning/--success/--info во всех скинах, хардкод-цвета → семантика
- заголовок задачи крупнее + title с полным текстом; мелкий текст ≥11px
- UX-тексты: «Не удалось …» вместо «Ошибка», имена вместо ID
- button.tsx: без transition-all, глобальный active:scale-[0.96]
- ARIA: aria-label инпутам, role=alert ошибкам, Escape в dropdown
2026-08-04 09:36:13 +03:00

552 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useRef, useCallback, useEffect } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import ContractNumberDisplay from '@/components/ContractNumberDisplay';
import { Plus, Trash2, Loader2, Clock } from 'lucide-react';
import { queryClient, apiRequest, OfflineQueuedError } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import type { TableColumnDef, RegularTableRow } from '@shared/schema';
import { evaluateAllFormulas, evaluateAggregateFormula, FORMULA_ERROR, FORMULA_CIRCULAR_ERROR } from '@/lib/formulaEngine';
import { normalizeSelectOptions, getSelectOptionLabel } from '@/lib/fieldOptions';
import { InlineField } from '@/components/fields/InlineField';
interface DataTableCellProps {
tableId: number;
displayColumn?: string;
value: string;
onChange: (value: string) => void;
readOnly?: boolean;
}
function DataTableCell({ tableId, displayColumn, value, onChange, readOnly }: DataTableCellProps) {
const { data: rowsData, isLoading, isError } = useQuery<{ rows: any[] }>({
queryKey: ['/api/directories', tableId, 'rows'],
});
const { data: tableInfo } = useQuery<{ success: boolean; table: any }>({
queryKey: ['/api/directories', tableId],
});
const rows = rowsData?.rows || [];
const columns = tableInfo?.table?.columns || [];
const displayCol = displayColumn || columns[0]?.name || 'value';
if (isLoading) {
return <div className="min-h-[28px] flex items-center px-1"><Loader2 className="w-3 h-3 animate-spin" /></div>;
}
if (isError) {
return <div className="min-h-[28px] flex items-center px-1 text-muted-foreground text-xs">Ошибка</div>;
}
const getDisplayValue = (row: any) => {
if (!row) return '';
if (row.data && row.data[displayCol]) return row.data[displayCol];
if (Array.isArray(row.values)) {
const colIndex = columns.findIndex((c: any) => c.name === displayCol);
return colIndex >= 0 ? row.values[colIndex] : row.values[0] || `#${row.id}`;
}
return `#${row.id}`;
};
if (readOnly) {
const selectedRow = rows.find(r => String(r.id) === String(value));
return <div className="min-h-[24px] flex items-center px-0.5 truncate">{getDisplayValue(selectedRow) || value}</div>;
}
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="h-6 border-0 shadow-none focus:ring-1 px-1">
<SelectValue placeholder="Выбрать..." />
</SelectTrigger>
<SelectContent>
{rows.map((row) => (
<SelectItem key={row.id} value={String(row.id)}>
{getDisplayValue(row)}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
interface TaskRegularTableProps {
taskId: number;
tabId: number;
columns: TableColumnDef[];
readOnly?: boolean;
}
export default function TaskRegularTable({ taskId, tabId, columns, readOnly = false }: TaskRegularTableProps) {
const { toast } = useToast();
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => {
const widths: Record<string, number> = {};
columns.forEach(col => {
widths[col.id] = col.width || 150;
});
return widths;
});
const [editingCell, setEditingCell] = useState<{ rowId: number; colId: string } | null>(null);
const [editValue, setEditValue] = useState('');
const resizingRef = useRef<{ colId: string; startX: number; startWidth: number } | null>(null);
const [localPendingRows, setLocalPendingRows] = useState<Array<RegularTableRow & { _pending?: boolean }>>([]);
useEffect(() => {
const rowUrl = `/api/tasks/${taskId}/regular-table/${tabId}/rows`;
const syncFromQueue = (items: Array<{ url: string; method: string; conflictStatus?: string | null }>) => {
const pendingAdds = items.filter(
i => i.url === rowUrl && i.method === 'POST' && !i.conflictStatus
).length;
setLocalPendingRows(prev => {
if (pendingAdds === 0) return [];
if (prev.length === pendingAdds) return prev;
if (prev.length > pendingAdds) return prev.slice(0, pendingAdds);
const extra = Array.from({ length: pendingAdds - prev.length }, (_, idx) => ({
id: -(Date.now() + idx),
taskId,
tabId,
data: {},
position: 0,
_pending: true,
} as RegularTableRow & { _pending: boolean }));
return [...prev, ...extra];
});
};
import('@/lib/offlineQueue').then(({ getAll }) => {
getAll().then(syncFromQueue).catch(() => {});
});
const checkQueue = () => {
import('@/lib/offlineQueue').then(({ getAll }) => {
getAll().then(syncFromQueue).catch(() => {});
});
};
window.addEventListener('offline-queue-updated', checkQueue);
return () => window.removeEventListener('offline-queue-updated', checkQueue);
}, [taskId, tabId]);
const { data: rowsData, isLoading } = useQuery<{ success: boolean; rows: RegularTableRow[] }>({
queryKey: ['/api/tasks', taskId, 'regular-table', tabId, 'rows'],
});
const rows = rowsData?.rows || [];
const createRowMutation = useMutation({
mutationFn: async () => {
return apiRequest('POST', `/api/tasks/${taskId}/regular-table/${tabId}/rows`, {
data: {},
});
},
onMutate: () => {
const tempId = -(Date.now());
const newRow = { id: tempId, taskId, tabId, data: {}, position: 0, _pending: true } as RegularTableRow & { _pending: boolean };
setLocalPendingRows(prev => [...prev, newRow]);
return { tempId };
},
onSuccess: (_data, _vars, context) => {
if (context?.tempId) {
setLocalPendingRows(prev => prev.filter(r => r.id !== context.tempId));
}
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'regular-table', tabId, 'rows'] });
},
onError: (error, _vars, context) => {
if (error instanceof OfflineQueuedError) {
return;
}
if (context?.tempId) {
setLocalPendingRows(prev => prev.filter(r => r.id !== context.tempId));
}
toast({
variant: 'destructive',
title: 'Ошибка',
description: error instanceof Error ? error.message : 'Не удалось создать строку',
});
},
});
const updateRowMutation = useMutation({
mutationFn: async ({ rowId, data }: { rowId: number; data: Record<string, any> }) => {
return apiRequest('PATCH', `/api/tasks/${taskId}/regular-table/${tabId}/rows/${rowId}`, { data });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'regular-table', tabId, 'rows'] });
},
onError: (error) => {
toast({
variant: 'destructive',
title: 'Ошибка',
description: error instanceof Error ? error.message : 'Не удалось обновить строку',
});
},
});
const deleteRowMutation = useMutation({
mutationFn: async (rowId: number) => {
return apiRequest('DELETE', `/api/tasks/${taskId}/regular-table/${tabId}/rows/${rowId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'regular-table', tabId, 'rows'] });
},
onError: (error) => {
toast({
variant: 'destructive',
title: 'Ошибка',
description: error instanceof Error ? error.message : 'Не удалось удалить строку',
});
},
});
const handleCellClick = (rowId: number, colId: string, currentValue: string) => {
if (readOnly) return;
setEditingCell({ rowId, colId });
setEditValue(currentValue || '');
};
const handleCellBlur = () => {
if (!editingCell) return;
const row = rows.find(r => r.id === editingCell.rowId);
if (row) {
const currentData = (row.data as Record<string, any>) || {};
const oldValue = String(currentData[editingCell.colId] ?? '');
if (editValue !== oldValue) {
const newData = { ...currentData, [editingCell.colId]: editValue };
updateRowMutation.mutate({ rowId: editingCell.rowId, data: newData });
}
}
setEditingCell(null);
setEditValue('');
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleCellBlur();
} else if (e.key === 'Escape') {
setEditingCell(null);
setEditValue('');
}
};
const handleResizeStart = useCallback((e: React.MouseEvent, colId: string) => {
e.preventDefault();
resizingRef.current = {
colId,
startX: e.clientX,
startWidth: columnWidths[colId] || 150,
};
const handleMouseMove = (e: MouseEvent) => {
if (!resizingRef.current) return;
const delta = e.clientX - resizingRef.current.startX;
const newWidth = Math.max(60, resizingRef.current.startWidth + delta);
setColumnWidths(prev => ({
...prev,
[resizingRef.current!.colId]: newWidth,
}));
};
const handleMouseUp = () => {
resizingRef.current = null;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}, [columnWidths]);
if (isLoading) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
if (columns.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
Нет настроенных колонок для этой таблицы.
</div>
);
}
const columnNames = columns.map(c => c.name);
const fieldDefs = columns.map(c => ({
code: c.name,
type: c.type,
options: c.formula ? { formula: c.formula } : undefined,
}));
// Compute resolved values for every row (needed for aggregation)
const allResolvedRowValues: Array<Record<string, number | string | null | undefined>> = rows.map(row => {
const rowData = (row.data as Record<string, any>) || {};
const rawVals: Record<string, unknown> = {};
columns.forEach(c => {
if (c.type !== 'formula') {
rawVals[c.name] = rowData[c.id] ?? null;
}
});
return evaluateAllFormulas(fieldDefs, rawVals);
});
const hasAggregateRow = columns.some(c => c.aggregateFormula);
return (
<div className="space-y-2">
<div className="overflow-x-auto border rounded">
<table className="w-full border-collapse text-sm" style={{ tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '24px' }} />
{columns.map(col => (
<col key={col.id} style={{ width: `${columnWidths[col.id] || col.width || 150}px` }} />
))}
{!readOnly && <col style={{ width: '32px' }} />}
</colgroup>
<thead>
<tr className="bg-muted/50">
<th className="border px-0.5 py-0.5 text-center text-muted-foreground font-medium text-xs">#</th>
{columns.map(col => (
<th
key={col.id}
className="border px-1 py-0.5 text-left font-medium relative group"
>
<span className="truncate block">{col.name}</span>
<div
className="absolute right-0 top-0 h-full w-1 cursor-col-resize bg-transparent hover:bg-primary/30 group-hover:bg-border"
onMouseDown={(e) => handleResizeStart(e, col.id)}
/>
</th>
))}
{!readOnly && (
<th className="border px-0.5 py-0.5 text-center"></th>
)}
</tr>
</thead>
<tbody>
{rows.length === 0 && localPendingRows.length === 0 ? (
<tr>
<td colSpan={columns.length + (readOnly ? 1 : 2)} className="border px-2 py-3 text-center text-muted-foreground">
Нет данных. Нажмите "Добавить строку" для добавления.
</td>
</tr>
) : (
<>
{rows.map((row, rowIndex) => {
const resolvedFormulaValues = allResolvedRowValues[rowIndex];
return (
<tr key={row.id} className="hover:bg-muted/30">
<td className="border px-0.5 py-0 text-center text-muted-foreground text-xs">
{rowIndex + 1}
</td>
{columns.map(col => {
const cellData = (row.data as Record<string, any>) || {};
const cellValue = cellData[col.id] || '';
const isEditing = editingCell?.rowId === row.id && editingCell?.colId === col.id;
const handleSelectChange = (value: string) => {
const currentData = (row.data as Record<string, any>) || {};
const newData = { ...currentData, [col.id]: value };
updateRowMutation.mutate({ rowId: row.id, data: newData });
};
if (col.type === 'contract-number') {
return (
<td
key={col.id}
className="border px-0.5 py-0"
data-testid={`cell-${row.id}-${col.id}`}
>
<div className="min-h-[24px] flex items-center px-0.5 truncate">
<ContractNumberDisplay value={cellValue} />
</div>
</td>
);
}
if (col.type === 'formula') {
const result: string | number = resolvedFormulaValues[col.name] ?? '';
const isCircular = result === FORMULA_CIRCULAR_ERROR;
const isErr = result === FORMULA_ERROR || isCircular;
return (
<td
key={col.id}
className="border px-0.5 py-0"
data-testid={`cell-${row.id}-${col.id}`}
>
<div className="min-h-[24px] flex items-center px-0.5 truncate bg-yellow-50 dark:bg-yellow-950/30 font-mono text-xs">
{isCircular ? (
<span
className="text-orange-600 dark:text-orange-400"
title="Циклическая зависимость: формула ссылается сама на себя через цепочку других формул"
>
{String(result)}
</span>
) : isErr ? (
<span className="text-red-600 dark:text-red-400">{String(result)}</span>
) : (
<span>{String(result)}</span>
)}
</div>
</td>
);
}
const isEditableCell = !readOnly && col.type !== 'select' && col.type !== 'table';
return (
<td
key={col.id}
className="border px-0.5 py-0 hover:bg-muted/50"
data-testid={`cell-${row.id}-${col.id}`}
>
{col.type === 'select' && !readOnly ? (
<Select value={cellValue} onValueChange={handleSelectChange}>
<SelectTrigger className="h-6 border-0 shadow-none focus:ring-1 px-1">
<SelectValue placeholder="Выбрать..." />
</SelectTrigger>
<SelectContent>
{normalizeSelectOptions(col.options).map((opt, idx) => (
<SelectItem key={idx} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
) : col.type === 'table' && col.dataTableId ? (
<DataTableCell
tableId={col.dataTableId}
displayColumn={col.displayColumn}
value={cellValue}
onChange={handleSelectChange}
readOnly={readOnly}
/>
) : (
<InlineField
ariaLabel={col.name}
readView={
<div className="min-h-[24px] flex items-center px-0.5 truncate cursor-pointer">
{cellValue === '__auto_prolongation__'
? 'Автопролонгация'
: col.type === 'select'
? getSelectOptionLabel(col.options, cellValue)
: cellValue}
</div>
}
editor={
<Input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleCellBlur}
onKeyDown={handleKeyDown}
autoFocus
className="h-6 px-1 py-0 border-0 focus-visible:ring-1 bg-background w-full"
type={col.type === 'number' ? 'number' : col.type === 'date' ? 'date' : 'text'}
/>
}
isEditing={isEditing}
editable={isEditableCell}
onEnterEdit={() => handleCellClick(row.id, col.id, cellValue)}
onExitEdit={() => { setEditingCell(null); setEditValue(''); }}
activateOnClick={isEditableCell}
fitOverlay={true}
closeOnClickOutside={false}
className="w-full min-h-[24px] items-center"
/>
)}
</td>
);
})}
{!readOnly && (
<td className="border px-0 py-0 text-center">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
onClick={() => deleteRowMutation.mutate(row.id)}
disabled={deleteRowMutation.isPending}
data-testid={`button-delete-row-${row.id}`}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</td>
)}
</tr>
);
})}
{localPendingRows.map((row, idx) => (
<tr key={`pending-${row.id}`} className="bg-amber-50/50 dark:bg-amber-950/20 opacity-70">
<td className="border px-0.5 py-0 text-center text-muted-foreground text-xs">
{rows.length + idx + 1}
</td>
{columns.map(col => (
<td key={col.id} className="border px-0.5 py-0">
<div className="min-h-[24px] flex items-center px-0.5 text-muted-foreground text-xs italic">—</div>
</td>
))}
{!readOnly && (
<td className="border px-0 py-0 text-center">
<Clock className="w-3 h-3 text-amber-500 mx-auto animate-pulse" aria-label="Ожидает синхронизации" />
</td>
)}
</tr>
))}
</>
)}
</tbody>
{hasAggregateRow && (
<tfoot>
<tr className="bg-blue-50 dark:bg-blue-950/30 font-medium">
<td className="border px-0.5 py-0.5 text-center text-muted-foreground text-xs">∑</td>
{columns.map(col => {
if (!col.aggregateFormula) {
return <td key={col.id} className="border px-0.5 py-0.5" />;
}
const result = evaluateAggregateFormula(col.aggregateFormula, allResolvedRowValues, columnNames);
const isErr = result === FORMULA_ERROR;
return (
<td
key={col.id}
className="border px-0.5 py-0.5"
data-testid={`aggregate-${col.id}`}
>
<div className="min-h-[24px] flex items-center px-0.5 truncate font-mono text-xs">
{isErr ? (
<span className="text-red-600 dark:text-red-400">{String(result)}</span>
) : (
<span className="text-blue-700 dark:text-blue-300">{String(result)}</span>
)}
</div>
</td>
);
})}
{!readOnly && <td className="border px-0.5 py-0.5" />}
</tr>
</tfoot>
)}
</table>
</div>
{!readOnly && (
<Button
variant="outline"
size="sm"
onClick={() => createRowMutation.mutate()}
disabled={createRowMutation.isPending}
className="gap-1"
data-testid="button-add-row"
>
{createRowMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Plus className="w-4 h-4" />
)}
Добавить строку
</Button>
)}
</div>
);
}