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
552 lines
22 KiB
TypeScript
552 lines
22 KiB
TypeScript
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>
|
||
);
|
||
}
|