Compare commits

..

7 Commits

Author SHA1 Message Date
17b848bd39 Merge pull request 'form-4-add-row' (#54) from form-4-add-row into test
Reviewed-on: #54
Reviewed-by: Veronika <veronika.amilonn@gmail.com>
2026-07-29 10:56:41 +03:00
PotapovaA
64302ea127 Merge branch 'test' into form-4-add-row 2026-07-28 18:26:59 +03:00
PotapovaA
8c978b4e33 добавлене программ, проектов в 4 форме 2026-07-28 16:03:52 +03:00
PotapovaA
d257c3f1c1 add-row 2026-07-27 17:12:37 +03:00
PotapovaA
653ede59c1 Merge branch 'test' into form-4-add-row 2026-07-27 15:54:44 +03:00
PotapovaA
562707f418 исправила копирование данных 2026-07-22 14:48:34 +03:00
PotapovaA
73083153da добавление строк (не готово) 2026-07-21 17:24:13 +03:00
18 changed files with 636 additions and 157 deletions

View File

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M8 21V7C8 6.07003 8 5.60504 8.10222 5.22354C8.37962 4.18827 9.18827 3.37962 10.2235 3.10222C10.605 3 11.07 3 12 3C12.93 3 13.395 3 13.7765 3.10222C14.8117 3.37962 15.6204 4.18827 15.8978 5.22354C16 5.60504 16 6.07003 16 7V21M5.2 21H18.8C19.9201 21 20.4802 21 20.908 20.782C21.2843 20.5903 21.5903 20.2843 21.782 19.908C22 19.4802 22 18.9201 22 17.8V10.2C22 9.07989 22 8.51984 21.782 8.09202C21.5903 7.71569 21.2843 7.40973 20.908 7.21799C20.4802 7 19.9201 7 18.8 7H5.2C4.07989 7 3.51984 7 3.09202 7.21799C2.71569 7.40973 2.40973 7.71569 2.21799 8.09202C2 8.51984 2 9.07989 2 10.2V17.8C2 18.9201 2 19.4802 2.21799 19.908C2.40973 20.2843 2.71569 20.5903 3.09202 20.782C3.51984 21 4.0799 21 5.2 21Z"
stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 900 B

View File

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M14 11H8M10 15H8M16 7H8M20 6.8V17.2C20 18.8802 20 19.7202 19.673 20.362C19.3854 20.9265 18.9265 21.3854 18.362 21.673C17.7202 22 16.8802 22 15.2 22H8.8C7.11984 22 6.27976 22 5.63803 21.673C5.07354 21.3854 4.6146 20.9265 4.32698 20.362C4 19.7202 4 18.8802 4 17.2V6.8C4 5.11984 4 4.27976 4.32698 3.63803C4.6146 3.07354 5.07354 2.6146 5.63803 2.32698C6.27976 2 7.11984 2 8.8 2H15.2C16.8802 2 17.7202 2 18.362 2.32698C18.9265 2.6146 19.3854 3.07354 19.673 3.63803C20 4.27976 20 5.11984 20 6.8Z"
stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 704 B

View File

@ -0,0 +1,7 @@
import api from "./client";
export const ExpenseItemApi = {
getAll(params) {
return api.get(`/expense-item/`, { params }).then((r) => r.data);
},
};

View File

@ -0,0 +1,110 @@
import { useEffect, useState } from 'react';
import {
Box,
Button,
Divider,
FormControl,
FormLabel,
IconButton,
Stack,
Select,
MenuItem,
Autocomplete,
TextField,
} from '@mui/material';
import {
ModalBackdrop,
ModalContainer,
HeaderContainer,
ModalTitle,
} from '../../common/Modal/ModalStyled';
import { Cancel } from '../../common/icons/icons';
import { DictVspApi } from '../../../api/dict-vsp';
import { toast } from 'react-toastify';
import { ExpenseItemApi } from '../../../api/expense-item';
import { DangerOutlinedButton, PrimaryButton } from '../../common/Buttons/Buttons';
import { StyledTextField } from '../../common/StyledTextField';
export const CreateProgramModal = ({
isOpen,
onClose,
onCreate,
title = '',
isSaving = false,
sheet,
}) => {
const [name, setName] = useState('');
useEffect(() => {
if (!isOpen) {
setName('');
}
}, [isOpen])
const handleCancel = () => {
setName('');
onClose();
};
const handleBackdropClick = (e) => {
if (e.target === e.currentTarget) {
handleCancel();
}
};
const handleSave = () => {
onCreate?.(name);
onClose();
};
const isFormValid = name !== '';
if (!isOpen) return null;
return (
<ModalBackdrop isOpen={isOpen} onClick={handleBackdropClick}>
<ModalContainer height={'max-content'} >
<HeaderContainer>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
<ModalTitle>{title}</ModalTitle>
</Stack>
<IconButton size="small" onClick={handleCancel}>
<Cancel />
</IconButton>
</HeaderContainer>
<Box>
<Divider sx={{ marginBottom: '0.75rem' }} />
{/* <Input */}
<StyledTextField label={'Название программы'} onChange={(e) => setName(e.target.value)}></StyledTextField>
<Stack
direction="row"
spacing={0.75}
sx={{
justifyContent: 'flex-end',
marginTop: '1.5rem',
width: '100%',
}}
>
<DangerOutlinedButton
onClick={handleCancel}
disabled={isSaving}
>
Отмена
</DangerOutlinedButton>
<PrimaryButton
onClick={handleSave}
disabled={!isFormValid || isSaving}
loading={isSaving}>
Создать
</PrimaryButton>
</Stack>
</Box>
</ModalContainer>
</ModalBackdrop>
);
};

View File

@ -0,0 +1,154 @@
import { useEffect, useState } from 'react';
import {
Box,
Button,
Divider,
FormControl,
FormLabel,
IconButton,
Stack,
Select,
MenuItem,
Autocomplete,
TextField,
} from '@mui/material';
import {
ModalBackdrop,
ModalContainer,
HeaderContainer,
ModalTitle,
} from '../../common/Modal/ModalStyled';
import { Cancel } from '../../common/icons/icons';
import { DictVspApi } from '../../../api/dict-vsp';
import { toast } from 'react-toastify';
import { ExpenseItemApi } from '../../../api/expense-item';
import { DangerOutlinedButton, PrimaryButton } from '../../common/Buttons/Buttons';
export const SelectExpenseItemModal = ({
isOpen,
onClose,
onSelect,
formId,
title = '',
isSaving = false,
sheet,
}) => {
const [selectedValue, setSelectedValue] = useState('');
const [expenseItemOptions, setExpenseItemOption] = useState([])
useEffect(() => {
if (formId === null) return;
ExpenseItemApi.getAll({ r_start: true, sheet: sheet }).then(data => {
if (data.success) {
setExpenseItemOption(data.result)
return;
}
toast.error('Ошибка получения статья расходов')
})
}, [formId])
useEffect(() => {
if (!isOpen) {
setSelectedValue('');
}
}, [isOpen])
const handleCancel = () => {
setSelectedValue('');
onClose();
};
const handleBackdropClick = (e) => {
if (e.target === e.currentTarget) {
handleCancel();
}
};
const handleSave = () => {
onSelect?.(selectedValue);
onClose();
};
const isFormValid = selectedValue;
if (!isOpen) return null;
return (
<ModalBackdrop isOpen={isOpen} onClick={handleBackdropClick}>
<ModalContainer height={'max-content'} >
<HeaderContainer>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
<ModalTitle>{title}</ModalTitle>
</Stack>
<IconButton size="small" onClick={handleCancel}>
<Cancel />
</IconButton>
</HeaderContainer>
<Box>
<Divider sx={{ marginBottom: '0.75rem' }} />
{/* Выбор ВСП */}
<Stack sx={{ marginBottom: '0.75rem', width: '100%' }}>
<FormControl fullWidth>
<Autocomplete
size="small"
options={expenseItemOptions}
noOptionsText="Не найдено"
getOptionLabel={(option) => option.name || ''}
getOptionKey={(option) => option.id}
value={selectedValue}
onChange={(event, newValue) => {
setSelectedValue(newValue);
}}
slotProps={{
popper: {
style: { zIndex: 60 },
},
}}
renderInput={(params) => (
<TextField
{...params}
placeholder="Не выбрана"
sx={{
'& .MuiOutlinedInput-root': {
'& fieldset': {
borderColor: 'rgba(0,0,0,0.1)',
},
},
}}
/>
)}
/>
</FormControl>
</Stack>
<Stack
direction="row"
spacing={0.75}
sx={{
justifyContent: 'flex-end',
marginTop: '1.5rem',
width: '100%',
}}
>
<DangerOutlinedButton
onClick={handleCancel}
disabled={isSaving}
>
Отмена
</DangerOutlinedButton>
<PrimaryButton
onClick={handleSave}
disabled={!isFormValid || isSaving}
loading={isSaving}>
Выбрать
</PrimaryButton>
</Stack>
</Box>
</ModalContainer>
</ModalBackdrop>
);
};

View File

@ -19,6 +19,7 @@ import {
import { Cancel } from '../../common/icons/icons'; import { Cancel } from '../../common/icons/icons';
import { DictVspApi } from '../../../api/dict-vsp'; import { DictVspApi } from '../../../api/dict-vsp';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { DangerOutlinedButton, PrimaryButton } from '../../common/Buttons/Buttons';
export const SelectVspModal = ({ export const SelectVspModal = ({
isOpen, isOpen,
@ -113,24 +114,19 @@ export const SelectVspModal = ({
width: '100%', width: '100%',
}} }}
> >
<Button <DangerOutlinedButton
variant="outlined"
color="default"
onClick={handleCancel} onClick={handleCancel}
style={{ height: '2.5rem' }}
disabled={isSaving} disabled={isSaving}
> >
Отмена Отмена
</Button> </DangerOutlinedButton>
<Button
variant="contained" <PrimaryButton
onClick={handleSave} onClick={handleSave}
style={{ height: '2.5rem' }}
disabled={!isFormValid || isSaving} disabled={!isFormValid || isSaving}
loading={isSaving} loading={isSaving}>
>
Выбрать Выбрать
</Button> </PrimaryButton>
</Stack> </Stack>
</Box> </Box>
</ModalContainer> </ModalContainer>

View File

@ -24,10 +24,13 @@ import {
import { useRealtime } from './contexts/RealtimeContext'; import { useRealtime } from './contexts/RealtimeContext';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { CircularProgress } from '@mui/material'; import { CircularProgress } from '@mui/material';
import { additionVspRowTable } from './constants/addingRowConfig'; import { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig';
import { SelectVspModal } from './Modals/SelectVspModal'; import { SelectVspModal } from './Modals/SelectVspModal';
import { SelectExpenseItemModal } from './Modals/SelectExpenseItemModal';
import { getRowId } from './utils/rowUtils'; import { getRowId } from './utils/rowUtils';
import { FORM_TYPE_OPTIONS } from '../../constants/constants';
import { debounce } from '@mui/material'; import { debounce } from '@mui/material';
import { CreateProgramModal } from './Modals/CreateProgramProjectModal';
const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const { data, setData, isLoading: isTableLoading, editingCells: dataEditingCells } = useRealtimeData(formId, sheetName, direction, formType, year); const { data, setData, isLoading: isTableLoading, editingCells: dataEditingCells } = useRealtimeData(formId, sheetName, direction, formType, year);
@ -40,6 +43,10 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const [editingCell, setEditingCell] = useState(null); const [editingCell, setEditingCell] = useState(null);
const [isOpenModalSelectVsp, setIsOpenModalSelectVsp] = useState(false); const [isOpenModalSelectVsp, setIsOpenModalSelectVsp] = useState(false);
const [isOpenModalSelectExpenseItem, setIsOpenModalSelectExpenseItem] = useState(false);
const [isOpenModalCreateProgram, setIsOpenModalCreateProgram] = useState(false);
const [isOpenModalCreateProject, setIsOpenModalCreateProject] = useState(false);
const [isProgramCreate, setIsProgramCreate] = useState(false)
const isVspAdditionRow = useMemo(() => { const isVspAdditionRow = useMemo(() => {
if (!formType || !sheetName) return false; if (!formType || !sheetName) return false;
@ -47,6 +54,12 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
return additionVspRowTable[formType].includes(sheetName); return additionVspRowTable[formType].includes(sheetName);
}, [formType, sheetName]); }, [formType, sheetName]);
const isAdditionExpenseItem = useMemo(() => {
if (!formType || !sheetName) return false;
if (!additionExpenseRowTable[formType]) return false;
return additionExpenseRowTable[formType].includes(sheetName);
}, [formType, sheetName]);
const handleGlobalFilterChange = useCallback( const handleGlobalFilterChange = useCallback(
debounce((value) => { debounce((value) => {
startTransition(() => { startTransition(() => {
@ -64,8 +77,9 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
error: wsError, error: wsError,
updateCell: contextUpdateCell, updateCell: contextUpdateCell,
addRow: contextAddRow, addRow: contextAddRow,
deleteRow: contextDeleteRow,
startEditing: contextStartEditing, startEditing: contextStartEditing,
addProgram: contextAddProgram,
addProject: contextAddProject,
} = useRealtime(); } = useRealtime();
const { const {
@ -113,6 +127,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
setColumnsConfig({ columns: [], colors: {} }); setColumnsConfig({ columns: [], colors: {} });
} }
}; };
loadConfig(); loadConfig();
}, [formType, sheetName]); }, [formType, sheetName]);
@ -363,11 +378,45 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const handleAddVspRow = useCallback((vsp_id) => { const handleAddVspRow = useCallback((vsp_id) => {
contextAddRow({ vsp_id: vsp_id }); contextAddRow({ vsp_id: vsp_id });
}, [rowSelection, table]); }, []);
const handleAddExpenseItemRow = useCallback((expense_item) => {
const allRows = table.getRowModel().rows;
const selectedRows = allRows.filter(row => rowSelection[row.id]);
const row = selectedRows[0];
contextAddRow({ expense_item_id: expense_item.id, project_id: row.original.data.header.project_id });
}, [rowSelection]);
const handleAddProgramRow = useCallback((name) => {
contextAddProgram({ name: name });
}, []);
const handleAddProjectRow = useCallback((name) => {
const allRows = table.getRowModel().rows;
const selectedRows = allRows.filter(row => rowSelection[row.id]);
const row = selectedRows[0];
contextAddProject({ name: name, program_id: row.original.data.header.program_id });
}, [rowSelection]);
const handleOpenModalSelectVsp = useCallback(() => { const handleOpenModalSelectVsp = useCallback(() => {
setIsOpenModalSelectVsp(true); setIsOpenModalSelectVsp(true);
}, [rowSelection, table]) }, [])
const handleOpenModalSelectExpenseItem = useCallback(() => {
const allRows = table.getRowModel().rows;
const selectedRows = allRows.filter(row => rowSelection[row.id]);
if (selectedRows.length === 0) {
toast.error('Выделите строку для вставки');
return;
}
const row = selectedRows[0];
if (row.original.row_type !== 'ITEM') {
toast.error('Выделите строку проекта');
return;
}
setIsOpenModalSelectExpenseItem(true);
}, [rowSelection])
const handleDeleteRow = useCallback(() => { const handleDeleteRow = useCallback(() => {
const allRows = table.getRowModel().rows; const allRows = table.getRowModel().rows;
@ -383,6 +432,39 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
setRowSelection({}); setRowSelection({});
}, [rowSelection, table]) }, [rowSelection, table])
const addRow = useCallback(() => {
if (isVspAdditionRow) {
return handleOpenModalSelectVsp();
}
if (isAdditionExpenseItem) {
return handleOpenModalSelectExpenseItem();
}
return handleAddRow();
}, [isVspAdditionRow, isAdditionExpenseItem, handleOpenModalSelectVsp, handleAddRow]);
const addProgram = useCallback(() => {
setIsProgramCreate(true);
setIsOpenModalCreateProgram(true);
}, [setIsOpenModalCreateProgram])
const addProject = useCallback(() => {
setIsProgramCreate(false);
const allRows = table.getRowModel().rows;
const selectedRows = allRows.filter(row => rowSelection[row.id]);
if (selectedRows.length === 0) {
toast.error('Выделите строку для вставки');
return;
}
const row = selectedRows[0];
if (row.original.row_type !== 'GROUP') {
toast.error('Выделите строку программы');
return;
}
setIsOpenModalCreateProgram(true);
}, [setIsOpenModalCreateProgram, rowSelection])
useEffect(() => { useEffect(() => {
return () => { return () => {
setData([]); setData([]);
@ -409,7 +491,9 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
onGlobalFilterChange={handleGlobalFilterChange} onGlobalFilterChange={handleGlobalFilterChange}
sizeMult={sizeMult} sizeMult={sizeMult}
onChangeShowColumnFilters={setShowColumnFilters} onChangeShowColumnFilters={setShowColumnFilters}
onAddRow={isVspAdditionRow ? handleOpenModalSelectVsp : handleAddRow} onAddRow={addRow}
onAddProgram={addProgram}
onAddProject={addProject}
onDeleteRow={handleDeleteRow} onDeleteRow={handleDeleteRow}
isLoadingData={isLoadingData} isLoadingData={isLoadingData}
formId={formId} formId={formId}
@ -417,6 +501,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
direction={direction} direction={direction}
year={year} year={year}
isProject={formType == 'PROJECT'} isProject={formType == 'PROJECT'}
formType={formType}
/> />
<div style={tableWrapperStyle}> <div style={tableWrapperStyle}>
@ -479,6 +564,20 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
onSelect={handleAddVspRow} onSelect={handleAddVspRow}
title="Выбор ВСП" title="Выбор ВСП"
/> />
<SelectExpenseItemModal
isOpen={isOpenModalSelectExpenseItem}
onClose={() => setIsOpenModalSelectExpenseItem(false)}
formId={formId}
onSelect={handleAddExpenseItemRow}
title="Добавление строки"
sheet={sheetName}
/>
<CreateProgramModal
isOpen={isOpenModalCreateProgram}
onClose={() => setIsOpenModalCreateProgram(false)}
title={isProgramCreate ? "Создание программы" : "Создание проекта"}
onCreate={isProgramCreate ? handleAddProgramRow : handleAddProjectRow}
/>
</> </>
); );
}; };

View File

@ -11,6 +11,8 @@ import {
Minus, Minus,
Pin, Pin,
Plus, Plus,
Program,
Project,
RemoveLine, RemoveLine,
Search, Search,
} from '../../common/icons/icons'; } from '../../common/icons/icons';
@ -19,6 +21,7 @@ import ZoomSlider from './ZoomSlider';
import SearchComponent from '../../common/SearchComponent'; import SearchComponent from '../../common/SearchComponent';
import { ExportDefaultButton } from '../../common/Buttons/ButtonsActions'; import { ExportDefaultButton } from '../../common/Buttons/ButtonsActions';
import { exportSheet, exportSheetProject } from '../../../utils/exportFile'; import { exportSheet, exportSheetProject } from '../../../utils/exportFile';
import { FORM_TYPE_OPTIONS } from '../../../constants/constants';
const SettingPanel = ({ const SettingPanel = ({
selectedColumnId, selectedColumnId,
@ -34,6 +37,8 @@ const SettingPanel = ({
sizeMult, sizeMult,
onChangeShowColumnFilters, onChangeShowColumnFilters,
onAddRow, onAddRow,
onAddProgram,
onAddProject,
onDeleteRow, onDeleteRow,
isLoadingData, isLoadingData,
formId, formId,
@ -41,13 +46,13 @@ const SettingPanel = ({
direction, direction,
year, year,
isProject, isProject,
formType,
}) => { }) => {
const [isPinned, setIsPinned] = useState(false); const [isPinned, setIsPinned] = useState(false);
const [isExporting, setIsExporting] = useState(false); const [isExporting, setIsExporting] = useState(false);
const [selectedColumnIds, setSelectedColumnIds] = useState(); const [selectedColumnIds, setSelectedColumnIds] = useState();
const [showColumnFilters, setShowColumnFilters] = useState(false); const [showColumnFilters, setShowColumnFilters] = useState(false);
// TODO: console.log(!!!) везде посомтреть
useEffect(() => { useEffect(() => {
if (!selectedColumnId) { if (!selectedColumnId) {
setIsPinned(false); setIsPinned(false);
@ -65,7 +70,7 @@ const SettingPanel = ({
}, [columnVisibility]); }, [columnVisibility]);
//TO DO: сделать //TO DO: сделать
const { addColorForCells, addFormulaForCells, deleteFormula } = {}; const { addColorForCells,} = {};
const handleChangeShowColumnFilters = () => { const handleChangeShowColumnFilters = () => {
const show = table.getState().showColumnFilters; const show = table.getState().showColumnFilters;
@ -73,10 +78,6 @@ const SettingPanel = ({
onChangeShowColumnFilters(!show); onChangeShowColumnFilters(!show);
}; };
const handleClickAddFormula = () => { };
const handleClickDeleteFormula = () => { };
const handleExport = async () => { const handleExport = async () => {
if (isExporting) return; if (isExporting) return;
if (isProject) { if (isProject) {
@ -111,11 +112,6 @@ const SettingPanel = ({
console.warn('[ColumnPin] клик Pin: selectedColumnId пустой'); console.warn('[ColumnPin] клик Pin: selectedColumnId пустой');
return; return;
} }
console.log('[ColumnPin] клик Pin:', {
selectedColumnId,
isPinned,
action: isPinned ? 'unpin' : 'pin',
});
isPinned isPinned
? onUnpinColumn?.(selectedColumnId) ? onUnpinColumn?.(selectedColumnId)
: onPinColumn?.(selectedColumnId); : onPinColumn?.(selectedColumnId);
@ -143,6 +139,23 @@ const SettingPanel = ({
<RemoveLine /> <RemoveLine />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
{/* проверяем на 4 форму */}
{formType == FORM_TYPE_OPTIONS[3] && <>
<Tooltip title="Добавить программу">
<IconButton onClick={onAddProgram} variant="outlined">
<Plus />
<Program />
</IconButton>
</Tooltip>
<Tooltip title="Добавить проект">
<IconButton onClick={onAddProject} variant="outlined">
<Plus />
<Project />
</IconButton>
</Tooltip>
</>}
</GroupByObject> </GroupByObject>
</> </>
} }

View File

@ -1,3 +1,7 @@
export const additionVspRowTable = { export const additionVspRowTable = {
FORM_2: ["AHR_SECURITY", "AHR_RENT", "AHR_UTILITY"], FORM_2: ["AHR_SECURITY", "AHR_RENT", "AHR_UTILITY"],
}; };
export const additionExpenseRowTable = {
FORM_4: ["AHR", "CAP", "OPER"],
};

View File

@ -88,19 +88,33 @@ export const RealtimeProvider = ({
} }
break; break;
case "program_added":
console.log("Program added:", data);
if (data.result && onProgramAddRef.current) {
onProgramAddRef.current(data);
}
break;
case "project_added":
console.log("Project added:", data);
if (data.result && onProjectAddRef.current) {
onProjectAddRef.current(data);
}
break;
default: default:
console.log("Unknown event:", data.event); console.log("Unknown event:", data.event);
} }
}, []); }, []);
const { isConnectedRef, isConnected, sendMessage, disconnect, lastError } = useWebSocket( const { isConnectedRef, isConnected, sendMessage, disconnect, lastError } =
wsUrl, useWebSocket(wsUrl, handleMessage);
handleMessage,
);
const onCellUpdateRef = useRef(null); const onCellUpdateRef = useRef(null);
const onRowAddRef = useRef(null); const onRowAddRef = useRef(null);
const onRowDeleteRef = useRef(null); const onRowDeleteRef = useRef(null);
const onProgramAddRef = useRef(null);
const onProjectAddRef = useRef(null);
const onErrorRef = useRef(null); const onErrorRef = useRef(null);
const onAuthSuccessRef = useRef(null); const onAuthSuccessRef = useRef(null);
const onCellEditStartRef = useRef(null); const onCellEditStartRef = useRef(null);
@ -283,6 +297,42 @@ export const RealtimeProvider = ({
[isConnected, sendMessage, sheetName, formId, direction], [isConnected, sendMessage, sheetName, formId, direction],
); );
const addProject = useCallback(
async (rowData) => {
if (!isConnectedRef) {
onErrorRef.current?.("Нет подключения или авторизации");
return false;
}
const message = {
event: "project_added",
data: rowData,
};
addCommonField(message);
return sendMessage(message);
},
[isConnected, sendMessage, sheetName, formId, direction],
);
const addProgram = useCallback(
async (rowData) => {
if (!isConnectedRef) {
onErrorRef.current?.("Нет подключения или авторизации");
return false;
}
const message = {
event: "program_added",
data: rowData,
};
addCommonField(message);
return sendMessage(message);
},
[isConnected, sendMessage, sheetName, formId, direction],
);
const deleteRow = useCallback( const deleteRow = useCallback(
async (rowId) => { async (rowId) => {
if (!isConnectedRef) { if (!isConnectedRef) {
@ -322,6 +372,20 @@ export const RealtimeProvider = ({
}; };
}, []); }, []);
const subscribeToProgramAdds = useCallback((callback) => {
onProgramAddRef.current = callback;
return () => {
onProgramAddRef.current = null;
};
}, []);
const subscribeToProjectAdds = useCallback((callback) => {
onProjectAddRef.current = callback;
return () => {
onProjectAddRef.current = null;
};
}, []);
const subscribeToErrors = useCallback((callback) => { const subscribeToErrors = useCallback((callback) => {
onErrorRef.current = callback; onErrorRef.current = callback;
return () => { return () => {
@ -366,9 +430,13 @@ export const RealtimeProvider = ({
endEditing, endEditing,
updateCell, updateCell,
addRow, addRow,
addProgram,
addProject,
deleteRow, deleteRow,
subscribeToRowAdds, subscribeToRowAdds,
subscribeToRowDeletes, subscribeToRowDeletes,
subscribeToProgramAdds,
subscribeToProjectAdds,
subscribeToErrors, subscribeToErrors,
subscribeToAuthSuccess, subscribeToAuthSuccess,
subscribeToCellEditStart, subscribeToCellEditStart,

View File

@ -1,9 +1,14 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { FormsSheetApi } from "../../../api/form_sheet"; import { FormsSheetApi } from "../../../api/form_sheet";
import { useRealtime } from "../contexts/RealtimeContext"; import { useRealtime } from "../contexts/RealtimeContext";
import { deleteRow, insertCell, updateCells } from "../utils/cellUtils"; import {
deleteRow,
insertCell,
insertProjectRow,
updateCells,
} from "../utils/cellUtils";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { isVspNewRow, isVspRow } from "../utils/rowUtils"; import { getParentId, isVspNewRow, isVspRow } from "../utils/rowUtils";
import { ProjectsApi } from "../../../api/projects"; import { ProjectsApi } from "../../../api/projects";
const useRealtimeData = (formId, sheetName, direction, formType, year) => { const useRealtimeData = (formId, sheetName, direction, formType, year) => {
@ -19,6 +24,8 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
error: wsError, error: wsError,
subscribeToCellUpdates, subscribeToCellUpdates,
subscribeToRowAdds, subscribeToRowAdds,
subscribeToProgramAdds,
subscribeToProjectAdds,
subscribeToRowDeletes, subscribeToRowDeletes,
subscribeToErrors, subscribeToErrors,
subscribeToCellEditStart, subscribeToCellEditStart,
@ -182,7 +189,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
const unsubscribeRowAdds = subscribeToRowAdds((data) => { const unsubscribeRowAdds = subscribeToRowAdds((data) => {
const updates = data.result.data; const updates = data.result.data;
const expense_item_id = data.data.expense_item_id || null; const parent_id = getParentId(data.data);
const newLineId = data.result.new_line_id; const newLineId = data.result.new_line_id;
const [updatedCells, newRow] = updates.reduce( const [updatedCells, newRow] = updates.reduce(
(res, row) => { (res, row) => {
@ -201,7 +208,49 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
} }
setData((prevData) => { setData((prevData) => {
const updateData = updateCells(prevData, updatedCells); const updateData = updateCells(prevData, updatedCells);
const newData = insertCell(updateData, newRow, expense_item_id); const newData = insertCell(updateData, newRow, parent_id);
return newData;
});
});
const unsubscribeProjectAdds = subscribeToProjectAdds((data) => {
const updates = data.result[1];
const program_id = data.data.program_id;
const [updatedCells, newRow] = updates.reduce(
(res, row) => {
if (row[0] === "ITEM") {
res[1] = row;
} else {
res[0].push(row);
}
return res;
},
[[], null],
);
if (newRow === null) {
toast.error("Ошибка получения данных");
return;
}
setData((prevData) => {
const updateData = updateCells(prevData, updatedCells);
const newData = insertProjectRow(updateData, newRow, program_id);
return newData;
});
});
const unsubscribeProgramAdds = subscribeToProgramAdds((data) => {
const newRow = data.result[1][0];
setData((prevData) => {
const updateData = structuredClone(prevData);
const [row_type, depth, sort_order, dataRow] = newRow;
const newRowObject = {
row_type: row_type,
depth: depth,
sort_order: sort_order,
subRows: [],
data: dataRow,
};
const newData = [...updateData, newRowObject];
return newData; return newData;
}); });
}); });
@ -255,6 +304,8 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
return () => { return () => {
unsubscribeCellUpdates(); unsubscribeCellUpdates();
unsubscribeRowAdds(); unsubscribeRowAdds();
unsubscribeProgramAdds();
unsubscribeProjectAdds();
unsubscribeRowDeletes(); unsubscribeRowDeletes();
unsubscribeErrors(); unsubscribeErrors();
unsubscribeCellEditStart(); unsubscribeCellEditStart();
@ -268,6 +319,8 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
subscribeToErrors, subscribeToErrors,
subscribeToCellEditStart, subscribeToCellEditStart,
subscribeToCellEditEnd, subscribeToCellEditEnd,
subscribeToProgramAdds,
subscribeToProjectAdds,
formatedToSubRows, formatedToSubRows,
]); ]);

View File

@ -1,4 +1,4 @@
import { getDataRowId } from "./rowUtils"; import { getDataRowId, getParentId } from "./rowUtils";
export function setNewValueToData(data, colId, rowId, value) { export function setNewValueToData(data, colId, rowId, value) {
let targetObject = data; let targetObject = data;
@ -44,7 +44,7 @@ export function setNewValueToData(data, colId, rowId, value) {
} }
export function updateCells(data, newRows) { export function updateCells(data, newRows) {
const updatedRows = JSON.parse(JSON.stringify(data)); const updatedRows = structuredClone(data);
function deepMerge(target, source) { function deepMerge(target, source) {
if (!source || typeof source !== "object") { if (!source || typeof source !== "object") {
@ -116,7 +116,7 @@ export function updateCells(data, newRows) {
return updatedRows; return updatedRows;
} }
export function insertCell(data, newRow, expense_item_id) { export function insertCell(data, newRow, parent_id) {
const updatedRows = JSON.parse(JSON.stringify(data)); const updatedRows = JSON.parse(JSON.stringify(data));
const [row_type, depth, sort_order, dataRow] = newRow; const [row_type, depth, sort_order, dataRow] = newRow;
const newRowObject = { const newRowObject = {
@ -128,7 +128,7 @@ export function insertCell(data, newRow, expense_item_id) {
}; };
// Если expense_item_id === null, добавляем в конец // Если expense_item_id === null, добавляем в конец
if (expense_item_id === null) { if (parent_id === null) {
updatedRows.push(newRowObject); updatedRows.push(newRowObject);
return updatedRows; return updatedRows;
} }
@ -145,7 +145,47 @@ export function insertCell(data, newRow, expense_item_id) {
row.sort_order = row.sort_order + 1; row.sort_order = row.sort_order + 1;
} }
if (!adding & (row.data.header.expense_item_id === expense_item_id)) { if (!adding & (getParentId(row.data.header) === parent_id)) {
if (!("subRows" in row)) {
row.subRows = [];
}
row.subRows.push(newRowObject);
adding = true;
}
if (row.subRows && Array.isArray(row.subRows) && row.subRows.length > 0) {
const found = findAndUpdate(row.subRows);
}
}
}
findAndUpdate(updatedRows);
return updatedRows;
}
export function insertProjectRow(data, newRow, program_id) {
const updatedRows = structuredClone(data);
const [row_type, depth, sort_order, dataRow] = newRow;
const newRowObject = {
row_type: row_type,
depth: depth,
sort_order: sort_order,
subRows: [],
data: dataRow,
};
let adding = false;
function findAndUpdate(rows) {
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (
(row.sort_order >= newRowObject.sort_order) &
(row.data.line_id != newRowObject.data.line_id)
) {
row.sort_order = row.sort_order + 1;
}
if (!adding & (row.data.header.program_id === program_id)) {
if (!("subRows" in row)) { if (!("subRows" in row)) {
row.subRows = []; row.subRows = [];
} }

View File

@ -17,3 +17,7 @@ export function isVspNewRow(rowData) {
const newRow = rowData[3]; const newRow = rowData[3];
return !!newRow.id; return !!newRow.id;
} }
export function getParentId(data){
return data.project_id || data.expense_item_id || null;
}

View File

@ -0,0 +1,20 @@
import {
TextField,
} from '@mui/material';
export const StyledTextField = ({ error, helperText, disabled, ...props }) => (
<TextField
className="fullWidth"
size="small"
error={!!error}
helperText={helperText || error}
disabled={disabled}
sx={{
width: '100%',
'& .MuiInputBase-root': {
height: '2.69rem',
},
}}
{...props}
/>
);

View File

@ -1,117 +0,0 @@
import { useState } from 'react';
import styled from '@emotion/styled';
import Tooltip from './Tooltip';
const TextInput = ({
label,
name,
type = 'text',
onChange,
onChangeWithName,
tooltipText = '',
value = '',
placeholder = '',
autocomplete = 'new-password',
}) => {
const [cur_value, setCurValue] = useState(value);
const [isFocused, setIsFocused] = useState(false);
const handleChange = (inputValue) => {
if (onChangeWithName) {
onChangeWithName(name, inputValue);
}
if (onChange) {
onChange(inputValue);
}
};
return (
<TextInputDiv>
<label>
{label}
<input
type={type}
name={name}
onChange={(e) => {
setCurValue(e.target.value);
if (!isFocused) {
handleChange(e.target.value);
}
}}
onBlur={() => {
handleChange(cur_value);
setIsFocused(false);
}}
onFocus={() => setIsFocused(true)}
value={cur_value}
autoComplete={autocomplete}
placeholder={placeholder}
/>
</label>
{tooltipText !== '' && <Tooltip tooltipText={tooltipText} />}
</TextInputDiv>
);
};
const TextInputDiv = styled.div`
position: relative;
width: 100%;
label {
color: inherit;
font-size: inherit;
display: flex;
flex-direction: column;
gap: 0.2rem; /* Уменьшено еще на 10% (0.225rem * 0.9) */
}
input {
max-width: 100%;
width: 100%;
flex-grow: 1;
flex-shrink: 1;
border: 1px solid #ddd;
border-radius: 4px;
height: 1.6rem; /* Уменьшено еще на 10% (1.8rem * 0.9) */
min-height: 36px; /* Уменьшено еще на 10% (40px * 0.9) */
background-color: rgb(240, 242, 246);
padding: 0 0.4rem; /* Уменьшено еще на 10% (0.45rem * 0.9) */
font-size: inherit;
transition: border-color 0.2s ease;
&:focus {
outline: none;
border-color: #258141;
box-shadow: 0 0 0 2px rgba(37, 129, 65, 0.1);
}
&:hover {
border-color: #999;
}
}
/* Медиа-запросы для адаптивности */
@media (max-width: 768px) {
input {
height: 2rem; /* Уменьшено еще на 10% (2.25rem * 0.9) */
min-height: 39px; /* Уменьшено еще на 10% (43px * 0.9) */
font-size: 13px; /* Уменьшено еще на 10% (14.4px * 0.9) */
}
}
@media (max-width: 480px) {
label {
font-size: 0.73rem; /* Уменьшено еще на 10% (0.81rem * 0.9) */
}
input {
height: 2.3rem; /* Уменьшено еще на 10% (2.52rem * 0.9) */
min-height: 42px; /* Уменьшено еще на 10% (47px * 0.9) */
padding: 0 0.6rem; /* Уменьшено еще на 10% (0.675rem * 0.9) */
}
}
`;
export default TextInput;

View File

@ -108,6 +108,23 @@ export const AddColumn = styled.div`
background-position: center; background-position: center;
`; `;
export const Project = styled.div`
background-image: url('images/Project.svg');
width: 1.25rem;
height: 1.25rem;
background-repeat: no-repeat;
background-size: contain;
background-position: center;
`;
export const Program = styled.div`
background-image: url('images/Program.svg');
width: 1.25rem;
height: 1.25rem;
background-repeat: no-repeat;
background-size: contain;
background-position: center;
`;
export const DeleteColumn = styled.div` export const DeleteColumn = styled.div`
background-image: url('images/RemoveColumn.svg'); background-image: url('images/RemoveColumn.svg');
width: 1.25rem; width: 1.25rem;

View File

@ -11,6 +11,7 @@ body {
margin: 0; margin: 0;
background: #fcfcfd; background: #fcfcfd;
color: #171a1c; color: #171a1c;
/* color: green; */
font-family: font-family:
-apple-system, -apple-system,
Segoe UI, Segoe UI,