Compare commits

...

3 Commits

Author SHA1 Message Date
43acce3b51 Merge pull request 'fix' (#98) from fix-logic-limitation into test
Reviewed-on: #98
2026-08-17 13:30:18 +03:00
PotapovaA
ecbd89cc71 fix 2026-08-17 13:30:02 +03:00
PotapovaA
63c3cb26c7 fix 2026-08-17 13:21:08 +03:00
5 changed files with 141 additions and 71 deletions

View File

@ -10,6 +10,24 @@ const remainingBookingKeys = [
"data.q4.residual_after_booking", "data.q4.residual_after_booking",
]; ];
// Требование №1 работает для всех форм и листов, кроме CAP и OPER формы 4.
const remainingBookingRuleScope = {
excludedScopes: [
{ formType: "FORM_4", sheetName: "CAP" },
{ formType: "FORM_4", sheetName: "OPER" },
],
};
// Для листов CAP и OPER формы 4 требование №1 проверяет только остаток за 4 квартал.
const remainingBooking4FormKeys = ["data.q4.residual_after_booking"];
const remainingBooking4FormRuleScope = {
includedScopes: [
{ formType: "FORM_4", sheetName: "CAP" },
{ formType: "FORM_4", sheetName: "OPER" },
],
};
// Требование №2. Контроль столбца «Текущие корректировки = 0». // Требование №2. Контроль столбца «Текущие корректировки = 0».
const zeroValueRequirementKeys = [ const zeroValueRequirementKeys = [
"data.q1.adj_current", "data.q1.adj_current",
@ -18,7 +36,7 @@ const zeroValueRequirementKeys = [
"data.q4.adj_current", "data.q4.adj_current",
]; ];
//Бронь <= Остаток после брони // Бронь <= Остаток после брони для формы 2.
const bookingLessBalance = [ const bookingLessBalance = [
["data.q1.booking", "data.q1.residual_after_booking"], ["data.q1.booking", "data.q1.residual_after_booking"],
["data.q2.booking", "data.q2.residual_after_booking"], ["data.q2.booking", "data.q2.residual_after_booking"],
@ -33,15 +51,18 @@ const bookingLessBalanceByColumn = new Map(
]), ]),
); );
//Бронь <= Остаток после брони для листов кв.П и Опер 4 формы // Экономия <= Всего для форм 1 и 4.
const bookingLessBalance4From = [ const economyLessTotal = [
["data.q4.booking", "data.q4.residual_after_booking"], ["data.q1.economy", "data.q1.total"],
["data.q2.economy", "data.q2.total"],
["data.q3.economy", "data.q3.total"],
["data.q4.economy", "data.q4.total"],
]; ];
const bookingLessBalance4FormByColumn = new Map( const economyLessTotalByColumn = new Map(
bookingLessBalance.flatMap(([bookingKey, balanceKey]) => [ economyLessTotal.flatMap(([economyKey, totalKey]) => [
[bookingKey, { bookingKey, balanceKey }], [economyKey, { economyKey, totalKey }],
[balanceKey, { bookingKey, balanceKey }], [totalKey, { economyKey, totalKey }],
]), ]),
); );
@ -59,17 +80,13 @@ const transferLessTotalByColumn = new Map(
]), ]),
); );
// Область действия всех правил. Пары можно добавлять, удалять и изменять.
const bookingLessBalanceRuleScope = { const bookingLessBalanceRuleScope = {
includedScopes: [], includedScopes: [{ formType: "FORM_2" }],
excludedScopes: [
{ formType: "FORM_4", sheetName: "CAP" },
{ formType: "FORM_4", sheetName: "OPER" },
],
}; };
export const validationRules = [ export const validationRules = [
{ {
...remainingBookingRuleScope,
id: "remaining-booking", id: "remaining-booking",
columnKeys: remainingBookingKeys, columnKeys: remainingBookingKeys,
message: "Сумма бронирования превышает сумму, предусмотренную сметой", message: "Сумма бронирования превышает сумму, предусмотренную сметой",
@ -80,6 +97,18 @@ export const validationRules = [
Number.isFinite(Number(value)) && Number.isFinite(Number(value)) &&
Number(value) < 0, Number(value) < 0,
}, },
{
...remainingBooking4FormRuleScope,
id: "remaining-booking-4-form",
columnKeys: remainingBooking4FormKeys,
message: "Сумма бронирования превышает сумму, предусмотренную сметой",
isInvalid: (value) =>
value !== null &&
value !== undefined &&
value !== "" &&
Number.isFinite(Number(value)) &&
Number(value) < 0,
},
{ {
id: "current-adjustments-zero", id: "current-adjustments-zero",
columnKeys: zeroValueRequirementKeys, columnKeys: zeroValueRequirementKeys,
@ -123,31 +152,32 @@ export const validationRules = [
}, },
}, },
{ {
id: "booking-less-balance-4-form", includedScopes: [{ formType: "FORM_1" }, { formType: "FORM_4" }],
columnKeys: bookingLessBalance4From.flat(), id: "economy-less-total",
errorColumnKeys: bookingLessBalance4From.map(([bookingKey]) => bookingKey), columnKeys: economyLessTotal.flat(),
message: "Сумма бронирования превышает остаток после бронирования", errorColumnKeys: economyLessTotal.map(([economyKey]) => economyKey),
message: "Экономия должна быть меньше значения «Всего»",
isInvalid: (_value, row, columnKey) => { isInvalid: (_value, row, columnKey) => {
const pair = bookingLessBalance4FormByColumn.get(columnKey); const pair = economyLessTotalByColumn.get(columnKey);
if (!pair) return false; if (!pair) return false;
const booking = pair.bookingKey const economy = pair.economyKey
.split(".") .split(".")
.reduce((value, key) => value?.[key], row); .reduce((value, key) => value?.[key], row);
const balance = pair.balanceKey const total = pair.totalKey
.split(".") .split(".")
.reduce((value, key) => value?.[key], row); .reduce((value, key) => value?.[key], row);
return ( return (
booking !== null && economy !== null &&
booking !== undefined && economy !== undefined &&
booking !== "" && economy !== "" &&
balance !== null && total !== null &&
balance !== undefined && total !== undefined &&
balance !== "" && total !== "" &&
Number.isFinite(Number(booking)) && Number.isFinite(Number(economy)) &&
Number.isFinite(Number(balance)) && Number.isFinite(Number(total)) &&
Number(booking) > Number(balance) Number(economy) > Number(total)
); );
}, },
}, },

View File

@ -555,14 +555,14 @@ export const TransitionSvg = ({ size = 16 }) => (
export const ProjectSvg = ({ size = 16 }) => ( export const ProjectSvg = ({ size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true"> <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M2.7 3.2h4.1l1 1.3h5.5c.7 0 1.2.5 1.2 1.2v6.6c0 .7-.5 1.2-1.2 1.2H2.7c-.7 0-1.2-.5-1.2-1.2V4.4c0-.7.5-1.2 1.2-1.2Z" stroke="#258141" stroke-width="1.15" strokeLinejoin="round" /><path d="M4.2 7.2h7.6M4.2 9.5h5.4" stroke="#258141" stroke-width="1.15" stroke-linecap="round"> <path d="M2.7 3.2h4.1l1 1.3h5.5c.7 0 1.2.5 1.2 1.2v6.6c0 .7-.5 1.2-1.2 1.2H2.7c-.7 0-1.2-.5-1.2-1.2V4.4c0-.7.5-1.2 1.2-1.2Z" stroke="#258141" strokeWidth="1.15" strokeLinejoin="round" /><path d="M4.2 7.2h7.6M4.2 9.5h5.4" stroke="#258141" strokeWidth="1.15" strokeLinecap="round">
</path> </path>
</svg> </svg>
); );
export const SummarySvg = ({ size = 16 }) => ( export const SummarySvg = ({ size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true"> <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M2.5 13.3V2.7M2.5 13.3h11" stroke="#99A1AF" stroke-width="1.15" stroke-linecap="round" /><path d="M4.6 10.6V7.9M7.9 10.6V4.8M11.2 10.6V6.4" stroke="#99A1AF" stroke-width="1.45" stroke-linecap="round"> <path d="M2.5 13.3V2.7M2.5 13.3h11" stroke="#99A1AF" strokeWidth="1.15" strokeLinecap="round" /><path d="M4.6 10.6V7.9M7.9 10.6V4.8M11.2 10.6V6.4" stroke="#99A1AF" strokeWidth="1.45" strokeLinecap="round">
</path> </path>
</svg> </svg>
); );

View File

@ -39,7 +39,7 @@ function DictVspPage() {
if (!user) return; if (!user) return;
setIsLoading(true); setIsLoading(true);
try { try {
const isAdmin = user.role_id == ROLES_NAME_ID.admin; const isAdmin = user.role_id === ROLES_NAME_ID.admin;
const [vspResponse, sspResponse] = await Promise.all([ const [vspResponse, sspResponse] = await Promise.all([
DictVspApi.get(), DictVspApi.get(),
isAdmin ? SspApi.getAll({ is_active: true }) : Promise.resolve(null), isAdmin ? SspApi.getAll({ is_active: true }) : Promise.resolve(null),

View File

@ -1,6 +1,6 @@
import { Box, CircularProgress, Paper, Typography } from '@mui/material'; import { Box, CircularProgress, Paper, Typography } from '@mui/material';
import { MaterialReactTable, useMaterialReactTable } from 'material-react-table'; import { MaterialReactTable, useMaterialReactTable } from 'material-react-table';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { ProjectsApi } from '../../api/projects'; import { ProjectsApi } from '../../api/projects';
@ -15,6 +15,8 @@ import { createFirstColumns, secondColumns } from './columns';
import EditModal from './components/EditModal/EditModal'; import EditModal from './components/EditModal/EditModal';
import TableFilters from './components/TableFilters/TableFilters'; // Импортируем компонент фильтров import TableFilters from './components/TableFilters/TableFilters'; // Импортируем компонент фильтров
import { handleNavigateClick } from './utils/tableHandlers'; import { handleNavigateClick } from './utils/tableHandlers';
import { useAuth } from '../../app/context/AuthProvider';
import { ROLES_NAME_ID } from '../../constants/constants';
const SummaryPage = () => { const SummaryPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -33,32 +35,49 @@ const SummaryPage = () => {
const [createModalOpen, setCreateModalOpen] = useState(false); const [createModalOpen, setCreateModalOpen] = useState(false);
const [projectToDelete, setProjectToDelete] = useState(null); const [projectToDelete, setProjectToDelete] = useState(null);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const { user } = useAuth();
// Состояния для фильтров // Состояния для фильтров
const [selectedBranch, setSelectedBranch] = useState(''); const [selectedBranch, setSelectedBranch] = useState('');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
useEffect(() => { const loadData = useCallback(async () => {
let active = true; if (!user) return;
const loadOrgUnits = async () => { setIsLoading(true);
try { try {
const response = await SspApi.getAll({ is_active: true }); const isAdmin = user.role_id === ROLES_NAME_ID.admin;
if (active) {
setOrgUnits(response.result || []); if (isAdmin) {
const sspResponse = await (
SspApi.getAll({ is_active: true })
);
if (!sspResponse.success) {
toast.error('Ошибка загрузки списка ССП');
return;
}
setOrgUnits(sspResponse.result);
setOrgUnitNames( setOrgUnitNames(
Object.fromEntries((response.result || []).map((orgUnit) => [orgUnit.id, orgUnit.title])), Object.fromEntries((sspResponse.result || []).map((orgUnit) => [orgUnit.id, orgUnit.title])),
);
} else {
setOrgUnits(user.org_units ?? []);
setOrgUnitNames(
Object.fromEntries((user.org_units || []).map((orgUnit) => [orgUnit.id, orgUnit.title])),
); );
} }
} catch (requestError) { } catch (error) {
console.error('Error loading SSP/RF names:', requestError); console.error('Error loading data:', error);
if (active) toast.error('Не удалось загрузить список ССП/РФ'); toast.error(error.response?.data?.message || 'Ошибка загрузки данных');
} finally {
setIsLoading(false);
} }
}; }, [user]);
loadOrgUnits();
return () => { useEffect(() => {
active = false; loadData();
}; }, [loadData]);
}, []);
const createProjectConfig = useMemo(() => ({ const createProjectConfig = useMemo(() => ({
...DEFAULT_PROJECT_CONFIG, ...DEFAULT_PROJECT_CONFIG,

View File

@ -1,27 +1,48 @@
import { Search } from '@mui/icons-material'; import { Search } from '@mui/icons-material';
import { Autocomplete, Box, Chip, CircularProgress, Paper, TextField } from '@mui/material'; import { Autocomplete, Box, Chip, CircularProgress, Paper, TextField } from '@mui/material';
import { useEffect, useState } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { SspApi } from '../../../../api/ssp'; import { SspApi } from '../../../../api/ssp';
import { useAuth } from '../../../../app/context/AuthProvider';
import { toast } from 'react-toastify';
import { ROLES_NAME_ID } from '../../../../constants/constants';
const TableFilters = ({ onBranchChange, onSearchChange, selectedBranch, searchQuery }) => { const TableFilters = ({ onBranchChange, onSearchChange, selectedBranch, searchQuery }) => {
const [branches, setBranches] = useState([]); const [branches, setBranches] = useState([]);
const [loading, setLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { user } = useAuth();
// Загружаем филиалы из API const loadData = useCallback(async () => {
useEffect(() => { if (!user) return;
const loadBranches = async () => { setIsLoading(true);
setLoading(true);
try { try {
const data = await SspApi.getAll({ is_active: true }); const isAdmin = user.role_id === ROLES_NAME_ID.admin;
setBranches(data.result);
} catch (error) { if (isAdmin) {
console.error('Error loading branches:', error); const sspResponse = await (
} finally { SspApi.getAll({ is_active: true })
setLoading(false); );
if (!sspResponse.success) {
toast.error('Ошибка загрузки списка ССП');
return;
} }
}; setBranches(sspResponse.result);
loadBranches();
}, []); } else {
setBranches(user.org_units ?? []);
}
} catch (error) {
console.error('Error loading data:', error);
toast.error(error.response?.data?.message || 'Ошибка загрузки данных');
} finally {
setIsLoading(false);
}
}, [user]);
useEffect(() => {
loadData();
}, [loadData]);
return ( return (
<Paper <Paper
@ -46,7 +67,7 @@ const TableFilters = ({ onBranchChange, onSearchChange, selectedBranch, searchQu
flex: '1 1 auto', flex: '1 1 auto',
}} }}
options={branches} options={branches}
loading={loading} loading={isLoading}
getOptionLabel={(option) => option.title || ''} getOptionLabel={(option) => option.title || ''}
getOptionKey={(option) => option.id} getOptionKey={(option) => option.id}
value={selectedBranch || null} value={selectedBranch || null}
@ -65,7 +86,7 @@ const TableFilters = ({ onBranchChange, onSearchChange, selectedBranch, searchQu
...InputProps, ...InputProps,
endAdornment: ( endAdornment: (
<> <>
{loading && <CircularProgress color='inherit' size={20} />} {isLoading && <CircularProgress color='inherit' size={20} />}
{InputProps?.endAdornment} {InputProps?.endAdornment}
</> </>
), ),