form3-expense: добавил новые статьи в форму 3, возможность удаления задач #178

Merged
tsygankoviva merged 1 commits from form3-expense into test 2026-09-25 14:22:27 +03:00
15 changed files with 540 additions and 38 deletions

View File

@ -0,0 +1,15 @@
from sql_migration import run_sql_file_migration
revision = "0038"
down_revision = "0037"
branch_labels = None
depends_on = None
def upgrade() -> None:
run_sql_file_migration("0038_form3_expense_item_links.sql")
def downgrade() -> None:
pass

View File

@ -0,0 +1,15 @@
from sql_migration import run_sql_file_migration
revision = "0039"
down_revision = "0038"
branch_labels = None
depends_on = None
def upgrade() -> None:
run_sql_file_migration("0039_budget_form_delete_workflow.sql")
def downgrade() -> None:
pass

View File

@ -0,0 +1,22 @@
insert into v3.expense_item_form_type (expense_item_id, form_type_code)
values
(84, 'FORM_3'),
(85, 'FORM_3'),
(86, 'FORM_3'),
(87, 'FORM_3'),
(88, 'FORM_3'),
(89, 'FORM_3'),
(90, 'FORM_3'),
(91, 'FORM_3'),
(92, 'FORM_3'),
(93, 'FORM_3'),
(94, 'FORM_3'),
(95, 'FORM_3'),
(97, 'FORM_3'),
(98, 'FORM_3'),
(99, 'FORM_3'),
(100, 'FORM_3'),
(101, 'FORM_3'),
(83, 'FORM_3'),
(96, 'FORM_3')
on conflict (expense_item_id, form_type_code) do nothing;

View File

@ -0,0 +1,75 @@
ALTER TABLE v3.budget_form
ADD COLUMN IF NOT EXISTS to_delete boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS deleted_by integer NULL REFERENCES v3.app_user(id),
ADD COLUMN IF NOT EXISTS deleted_at timestamp with time zone NULL;
CREATE INDEX IF NOT EXISTS ix_v3_budget_form_to_delete
ON v3.budget_form (to_delete);
CREATE OR REPLACE FUNCTION v3.hard_delete_budget_form(p_form_id integer)
RETURNS void
LANGUAGE plpgsql
AS $function$
BEGIN
IF NOT EXISTS (SELECT 1 FROM v3.budget_form WHERE id = p_form_id) THEN
RAISE EXCEPTION 'budget_form id=% не существует', p_form_id;
END IF;
DELETE FROM v3.booking
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
DELETE FROM v3.sequestration
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
DELETE FROM v3.budget_line_quarter
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
DELETE FROM v3.rent_detail
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
DELETE FROM v3.utility_detail
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
DELETE FROM v3.security_detail
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
DELETE FROM v3.contract_detail
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
DELETE FROM v3.contract_summary
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
DELETE FROM v3.plan
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
DELETE FROM v3.reserve
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
DELETE FROM v3.allocation
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
DELETE FROM v3.ckk
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
DELETE FROM v3.collegial_approval
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
DELETE FROM v3.fixed_asset_report
WHERE budget_form_id = p_form_id;
DELETE FROM v3.form_limit
WHERE budget_form_id = p_form_id;
DELETE FROM v3.form_phase
WHERE budget_form_id = p_form_id;
DELETE FROM v3.budget_line
WHERE budget_form_id = p_form_id;
DELETE FROM v3.form4_project
WHERE form_id = p_form_id;
DELETE FROM v3.budget_form
WHERE id = p_form_id;
END;
$function$;

View File

@ -8,8 +8,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.services.org_unit_service import OrgUnitService
from src.db.models.form_type import FormTypeEnum
from src.db.models.role import UserRoleEnum
from src.services.sheet_service import SheetService
from src.domain.schemas import AddLineSchema, BaseListResponse, BaseSingleResponse, BudgetFormResponse, CellPatch, CellsPatch, DirectionSchemaEnum, FormCreateSchema, SheetFormTypeResponse, SheetResponse
from src.domain.schemas import AddLineSchema, BaseListResponse, BaseSingleResponse, ResponseBase, BudgetFormResponse, CellPatch, CellsPatch, DirectionSchemaEnum, FormCreateSchema, SheetFormTypeResponse, SheetResponse
from src.db.models.app_user import AppUser
from src.services.budget_form_service import BudgetFormService
from src.api.v1.deps import get_current_active_user_with_set_db, require_admin
@ -80,6 +81,7 @@ async def get_forms(
org_units: str | None = None,
form_type: Literal["FORM_1", "FORM_2", "FORM_4"] | None = None,
year: int | None = None,
delete_status: Literal["active", "marked", "all"] | None = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[BudgetFormResponse]:
@ -95,6 +97,7 @@ async def get_forms(
form_type=form_type,
year=year,
load_org=True,
delete_status=delete_status,
)
return BaseListResponse(
result = [
@ -112,12 +115,15 @@ async def get_form(
) -> BaseSingleResponse[BudgetFormResponse]:
bf_service = BudgetFormService(db)
form = await bf_service.get(
user=current_user,
user=current_user,
budget_form_id=form_id,
load_org=True,
include_to_delete=current_user.role_id == UserRoleEnum.ADMIN,
)
if not form:
raise HTTPException(404, f"Форма {form_id} не найдена")
return BaseSingleResponse(
result = BudgetFormResponse.model_validate(form),
result=BudgetFormResponse.model_validate(form),
)
@ -464,6 +470,7 @@ async def add_form(
form_type_code=form.form_type_code,
year=form.year,
org_unit_ids=org_unit_ids,
user=current_user,
)
db_ms = (time.perf_counter() - t0) * 1000
@ -485,3 +492,36 @@ async def add_form(
result=[BudgetFormResponse.model_validate(new_form) for new_form in new_forms],
count=len(new_forms),
)
@router.post("/{form_id}/mark-delete")
async def mark_form_for_deletion(
form_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> BaseSingleResponse[BudgetFormResponse]:
bf_service = BudgetFormService(db)
form = await bf_service.mark_for_deletion(budget_form_id=form_id, user=current_user)
return BaseSingleResponse(result=BudgetFormResponse.model_validate(form))
@router.post("/{form_id}/restore")
async def restore_form(
form_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> BaseSingleResponse[BudgetFormResponse]:
bf_service = BudgetFormService(db)
form = await bf_service.restore(budget_form_id=form_id, user=current_user)
return BaseSingleResponse(result=BudgetFormResponse.model_validate(form))
@router.delete("/{form_id}/hard")
async def hard_delete_form(
form_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> ResponseBase:
bf_service = BudgetFormService(db)
await bf_service.hard_delete(budget_form_id=form_id, user=current_user)
return ResponseBase(success=True, message="Задача полностью удалена")

View File

@ -1,7 +1,7 @@
from datetime import datetime
import typing
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.models.form_type import FormTypeEnum
@ -34,6 +34,9 @@ class BudgetForm(Base):
org_unit_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("v3.org_unit.id", name="fk_v3_budget_form_org_unit")
)
to_delete: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
deleted_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
form_type: Mapped["FormType"] = relationship(
"FormType", #back_populates="budget_forms"

View File

@ -330,6 +330,9 @@ class BudgetFormResponse(BaseModel):
form_type_code: FormTypeSchemaEnum
year: int
org_unit_id: int
to_delete: bool = False
deleted_by: int | None = None
deleted_at: datetime | None = None
org_unit: OrgUnitSchema | None = None
model_config = ConfigDict(from_attributes=True)

View File

@ -1,4 +1,4 @@
from sqlalchemy import func, select, text
from sqlalchemy import func, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.form_type import FormTypeEnum
@ -6,20 +6,20 @@ from src.db.models.budget_form import BudgetForm
from sqlalchemy.orm import joinedload
class BudgetFormRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_list(
self,
offset: int | None = None,
self,
offset: int | None = None,
limit: int | None = None,
org_unit: int | list[int] | None = None,
with_count: bool = False,
load_org: bool = False,
form_type: FormTypeEnum | str | None = None,
year: int | None = None,
delete_status: str = "active",
) -> list[BudgetForm] | tuple[int, list[BudgetForm]]:
if with_count:
query = select(func.count().over().label("total_count"), BudgetForm)
@ -38,6 +38,11 @@ class BudgetFormRepository:
if year is not None:
where.append(BudgetForm.year == year)
if delete_status == "marked":
where.append(BudgetForm.to_delete.is_(True))
elif delete_status != "all":
where.append(BudgetForm.to_delete.is_(False))
query = query.where(*where)
if load_org:
query = query.options(
@ -63,19 +68,20 @@ class BudgetFormRepository:
return (await self.db.execute(count_query)).scalar(), []
else:
return (await self.db.execute(query)).scalars().all()
async def get(
self,
self,
budget_form_id: int | None = None,
org_unit: int | list[int] | None = None,
form_type_code: FormTypeEnum | str | None = None,
year: int | None = None,
load_form_type: bool = False,
load_org: bool = False,
include_to_delete: bool = False,
) -> BudgetForm | None:
assert budget_form_id or all((form_type_code, year, org_unit))
query = select(BudgetForm)
where = []
if budget_form_id is not None:
where.append(BudgetForm.id == budget_form_id)
@ -85,16 +91,19 @@ class BudgetFormRepository:
where.append(BudgetForm.org_unit_id == org_unit)
else:
where.append(BudgetForm.org_unit_id.in_(org_unit))
if form_type_code is not None:
if isinstance(form_type_code, FormTypeEnum):
where.append(BudgetForm.form_type_code == form_type_code.value)
else:
where.append(BudgetForm.form_type_code == form_type_code)
if year is not None:
where.append(BudgetForm.year == year)
if not include_to_delete:
where.append(BudgetForm.to_delete.is_(False))
query = query.where(*where)
if load_org:
query = query.options(
@ -106,12 +115,13 @@ class BudgetFormRepository:
joinedload(BudgetForm.form_type)
)
return (await self.db.execute(query)).scalars().first()
async def create(
self,
self,
form_type_code: str,
year: int,
year: int,
org_unit_id: int,
user_id: int | None = None,
) -> BudgetForm | None:
form_id = (
await self.db.execute(
@ -134,6 +144,42 @@ class BudgetFormRepository:
).scalar_one_or_none()
if form_id is None:
return None
if user_id is not None:
await self.db.execute(
update(BudgetForm)
.where(BudgetForm.id == form_id)
.values(created_by=user_id, updated_by=user_id)
)
return await self.get(
budget_form_id=form_id,
)
async def mark_for_deletion(self, budget_form_id: int, user_id: int) -> None:
await self.db.execute(
text(
"""
UPDATE v3.budget_form
SET to_delete = true, deleted_by = :user_id, deleted_at = now(), updated_by = :user_id, updated_at = now()
WHERE id = :form_id
"""
),
{"form_id": budget_form_id, "user_id": user_id},
)
async def restore(self, budget_form_id: int, user_id: int) -> None:
await self.db.execute(
text(
"""
UPDATE v3.budget_form
SET to_delete = false, deleted_by = NULL, deleted_at = NULL, updated_by = :user_id, updated_at = now()
WHERE id = :form_id
"""
),
{"form_id": budget_form_id, "user_id": user_id},
)
async def hard_delete(self, budget_form_id: int) -> None:
await self.db.execute(
text("SELECT v3.hard_delete_budget_form(:form_id)"),
{"form_id": budget_form_id},
)

View File

@ -4,12 +4,15 @@ from typing import Literal
from sqlalchemy.ext.asyncio import AsyncSession
from src.repository.user_repository import UserRepository
from src.core.errors import AccessDeniedException, ValidationException
from src.db.models.app_user import AppUser
from src.db.models.audit_log import AuditLog
from src.db.models.budget_form import BudgetForm
from src.db.models.role import UserRoleEnum
from src.repository.budget_form_repository import BudgetFormRepository
from src.repository.user_repository import UserRepository
DeleteStatus = Literal["active", "marked", "all"]
class BudgetFormService:
@ -28,8 +31,15 @@ class BudgetFormService:
org_unit: list[int] | None = None,
form_type: Literal["FORM_1", "FORM_2", "FORM_4"] | None = None,
year: int | None = None,
delete_status: DeleteStatus | None = None,
) -> list[BudgetForm] | tuple[int, list[BudgetForm]]:
if user.role_id == UserRoleEnum.ADMIN:
if delete_status is not None and delete_status not in {"active", "marked", "all"}:
raise ValidationException("delete_status должен быть active, marked или all")
is_admin = user.role_id == UserRoleEnum.ADMIN
effective_delete_status = (delete_status or "all") if is_admin else "active"
if is_admin:
return await self.bf_repo.get_list(
offset=offset,
limit=limit,
@ -38,6 +48,7 @@ class BudgetFormService:
org_unit=org_unit,
form_type=form_type,
year=year,
delete_status=effective_delete_status,
)
user = await self.user_repo.get(
user_id=user.id,
@ -58,6 +69,7 @@ class BudgetFormService:
load_org=load_org,
form_type=form_type,
year=year,
delete_status=effective_delete_status,
)
async def get(
@ -66,12 +78,15 @@ class BudgetFormService:
user: AppUser,
load_form_type: bool = False,
load_org: bool = False,
include_to_delete: bool = False,
) -> BudgetForm | None:
if user.role_id == UserRoleEnum.ADMIN:
is_admin = user.role_id == UserRoleEnum.ADMIN
if is_admin:
return await self.bf_repo.get(
budget_form_id=budget_form_id,
load_form_type=load_form_type,
load_org=load_org,
include_to_delete=True,
)
user = await self.user_repo.get(
user_id=user.id,
@ -82,30 +97,35 @@ class BudgetFormService:
org_unit=[ou.id for ou in user.org_units],
load_form_type=load_form_type,
load_org=load_org,
include_to_delete=False,
)
async def create(
self,
self,
form_type_code: str,
year: int,
year: int,
org_unit_id: int,
user: AppUser | None = None,
) -> BudgetForm | None:
return await self.bf_repo.create(
form_type_code=form_type_code,
year=year,
org_unit_id=org_unit_id,
user_id=user.id if user else None,
)
async def create_if_not_exists(
self,
self,
form_type_code: str,
year: int,
year: int,
org_unit_id: int,
user: AppUser | None = None,
) -> BudgetForm | None:
existing_bf = await self.bf_repo.get(
form_type_code=form_type_code,
year=year,
org_unit=org_unit_id,
include_to_delete=True,
)
if existing_bf:
return existing_bf
@ -113,13 +133,15 @@ class BudgetFormService:
form_type_code=form_type_code,
year=year,
org_unit_id=org_unit_id,
user_id=user.id if user else None,
)
async def bulk_create(
self,
self,
form_type_code: str,
year: int,
year: int,
org_unit_ids: list[int],
user: AppUser | None = None,
) -> BudgetForm | None:
return await asyncio.gather(
*[
@ -127,16 +149,18 @@ class BudgetFormService:
form_type_code=form_type_code,
year=year,
org_unit_id=org_unit_id,
user=user,
)
for org_unit_id in org_unit_ids
]
]
)
async def bulk_create_if_not_exists(
self,
self,
form_type_code: str,
year: int,
year: int,
org_unit_ids: list[int],
user: AppUser | None = None,
) -> BudgetForm | None:
return await asyncio.gather(
*[
@ -144,7 +168,118 @@ class BudgetFormService:
form_type_code=form_type_code,
year=year,
org_unit_id=org_unit_id,
user=user,
)
for org_unit_id in org_unit_ids
]
]
)
async def mark_for_deletion(self, budget_form_id: int, user: AppUser) -> BudgetForm:
self._ensure_admin(user)
form = await self._get_admin_form(budget_form_id)
if form.to_delete:
return form
await self.bf_repo.mark_for_deletion(budget_form_id=budget_form_id, user_id=user.id)
await self.db.refresh(form)
self._add_audit_log(
event="FORM_MARKED_FOR_DELETION",
user=user,
form=form,
event_data={
"entity_type": "form",
"entity_id": form.id,
"form_id": form.id,
"form_type_code": self._form_type_value(form.form_type_code),
"year": form.year,
"org_unit_id": form.org_unit_id,
},
)
return form
async def restore(self, budget_form_id: int, user: AppUser) -> BudgetForm:
self._ensure_admin(user)
form = await self._get_admin_form(budget_form_id)
if not form.to_delete:
return form
snapshot = self._form_snapshot(form)
await self.bf_repo.restore(budget_form_id=budget_form_id, user_id=user.id)
await self.db.refresh(form)
self._add_audit_log(
event="FORM_RESTORED",
user=user,
form=form,
event_data={
"entity_type": "form",
"entity_id": form.id,
"before": snapshot,
},
)
return form
async def hard_delete(self, budget_form_id: int, user: AppUser) -> None:
self._ensure_admin(user)
form = await self._get_admin_form(budget_form_id)
if not form.to_delete:
raise ValidationException("Задача не помечена на удаление")
if form.deleted_by == user.id:
raise AccessDeniedException("Полностью удалить задачу должен другой администратор")
snapshot = self._form_snapshot(form)
self._add_audit_log(
event="FORM_HARD_DELETED",
user=user,
form=form,
event_data={
"entity_type": "form",
"entity_id": form.id,
"before": snapshot,
},
)
await self.db.flush()
await self.bf_repo.hard_delete(budget_form_id=budget_form_id)
def _ensure_admin(self, user: AppUser) -> None:
if user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException("Недостаточно прав")
async def _get_admin_form(self, budget_form_id: int) -> BudgetForm:
form = await self.bf_repo.get(budget_form_id=budget_form_id, include_to_delete=True, load_org=True)
if not form:
raise ValidationException("Форма не найдена")
return form
def _add_audit_log(
self,
event: str,
user: AppUser,
form: BudgetForm,
event_data: dict,
) -> None:
self.db.add(
AuditLog(
user_id=user.id,
org_unit_id=form.org_unit_id,
form_id=form.id,
task_id=form.id,
event=event,
event_type="FORM",
event_data=event_data,
)
)
def _form_type_value(self, form_type_code) -> str:
return getattr(form_type_code, "value", str(form_type_code))
def _form_snapshot(self, form: BudgetForm) -> dict:
return {
"id": form.id,
"form_type_code": self._form_type_value(form.form_type_code),
"year": form.year,
"org_unit_id": form.org_unit_id,
"created_by": form.created_by,
"created_at": form.created_at.isoformat() if form.created_at else None,
"updated_by": form.updated_by,
"updated_at": form.updated_at.isoformat() if form.updated_at else None,
"to_delete": form.to_delete,
"deleted_by": form.deleted_by,
"deleted_at": form.deleted_at.isoformat() if form.deleted_at else None,
}

View File

@ -31,6 +31,15 @@ export const TasksApi = {
responseType: 'blob',
});
},
markForDeletion(taskId) {
return api.post(`/form/${taskId}/mark-delete`).then((r) => r.data);
},
restore(taskId) {
return api.post(`/form/${taskId}/restore`).then((r) => r.data);
},
hardDelete(taskId) {
return api.delete(`/form/${taskId}/hard`).then((r) => r.data);
},
exportTasks(payload, params = {}) {
return api.post(`export/bulk`, payload, {
params,

View File

@ -59,6 +59,9 @@ export const TaskCardComponent = ({
onAddForExport,
onExportClick,
isLastOpened = false,
isAdmin = false,
onChanged,
currentUserId = null,
}) => {
const navigate = useNavigate();
const location = useLocation();
@ -67,11 +70,14 @@ export const TaskCardComponent = ({
if (isLastOpened) cardRef.current?.scrollIntoView({ block: 'nearest' });
}, [isLastOpened]);
const { id, title, org_unit, form_type_code, year } = taskForm;
const canHardDelete = taskForm.to_delete && taskForm.deleted_by !== currentUserId;
const [isEditModalOpen, setEditModalOpen] = useState(false);
const [editedTitle, setEditedTitle] = useState('');
const [currentTypeCode, setCurrentTypeCode] = useState(FORM_TYPE_TRANSLATE[form_type_code] || '');
const [isChecked, setIsChecked] = useState(false);
const [confirmAction, setConfirmAction] = useState(null);
const [actionLoading, setActionLoading] = useState(false);
useEffect(() => {
setIsChecked(false);
@ -102,6 +108,52 @@ export const TaskCardComponent = ({
onExportClick?.(id, title);
};
const openConfirmAction = (event, action) => {
event.stopPropagation();
setConfirmAction(action);
};
const closeConfirmAction = () => {
if (!actionLoading) setConfirmAction(null);
};
const runConfirmedAction = async () => {
if (!confirmAction) return;
try {
setActionLoading(true);
if (confirmAction === 'mark') {
await TasksApi.markForDeletion(id);
toast.success('Задача помечена на удаление');
}
if (confirmAction === 'restore') {
await TasksApi.restore(id);
toast.success('Задача возвращена');
}
if (confirmAction === 'hardDelete') {
await TasksApi.hardDelete(id);
toast.success('Задача полностью удалена');
}
setConfirmAction(null);
onChanged?.();
} catch (error) {
toast.error(error?.response?.data?.detail || 'Не удалось выполнить действие');
} finally {
setActionLoading(false);
}
};
const actionTitle = {
mark: 'Удалить задачу',
restore: 'Вернуть задачу',
hardDelete: 'Полностью удалить задачу',
}[confirmAction];
const actionText = {
mark: 'Задача будет скрыта от пользователей. Админы смогут вернуть её или удалить окончательно.',
restore: 'Задача снова станет доступна пользователям.',
hardDelete: 'Будут удалены задача и все связанные данные. Это действие нельзя отменить.',
}[confirmAction];
const updateTask = async (nextTitle) => {
try {
await TasksApi.editTask(id, { title: nextTitle });
@ -147,6 +199,7 @@ export const TaskCardComponent = ({
<TaskCard
ref={cardRef}
$fluidWidth={fluidWidth}
$marked={Boolean(taskForm.to_delete)}
onClick={(event) => {
if (exportMode) {
handleCheckboxClick(event);
@ -159,7 +212,10 @@ export const TaskCardComponent = ({
<Box sx={{ width: '100%' }}>
<CardHeader>
<TaskId>ID: {id}</TaskId>
{isLastOpened && <LastOpenedBadge sx={{ mb: 0 }}>Последняя открытая</LastOpenedBadge>}
<Stack direction='row' gap={0.75} alignItems='center'>
{taskForm.to_delete && <Chip sx={{ background: '#fde8e8', color: '#b42318' }}>На удаление</Chip>}
{isLastOpened && <LastOpenedBadge sx={{ mb: 0 }}>Последняя открытая</LastOpenedBadge>}
</Stack>
</CardHeader>
<TaskTitle>{org_unit?.title || 'Подразделение не указано'}</TaskTitle>
@ -182,10 +238,47 @@ export const TaskCardComponent = ({
</CardBody>
{!isForm && !exportMode && <ExportButton onClick={handleExportTask} />}
{exportMode && <div>{isChecked ? <CheckBoxCheckSvg size='1.625rem' /> : <CheckBoxUncheckSvg size='1.625rem' />}</div>}
<Stack direction='row' gap={0.75} alignItems='center' onClick={(event) => event.stopPropagation()}>
{isAdmin && !isForm && !exportMode && !taskForm.to_delete && (
<Button size='small' variant='text' color='error' onClick={(event) => openConfirmAction(event, 'mark')}>
Удалить
</Button>
)}
{isAdmin && !isForm && !exportMode && taskForm.to_delete && (
<>
<Button size='small' variant='text' onClick={(event) => openConfirmAction(event, 'restore')}>
Вернуть
</Button>
{canHardDelete && (
<Button size='small' variant='text' color='error' onClick={(event) => openConfirmAction(event, 'hardDelete')}>
Удалить
</Button>
)}
</>
)}
{!isForm && !exportMode && !taskForm.to_delete && <ExportButton onClick={handleExportTask} />}
{exportMode && <div>{isChecked ? <CheckBoxCheckSvg size='1.625rem' /> : <CheckBoxUncheckSvg size='1.625rem' />}</div>}
</Stack>
</Stack>
</TaskCard>
<Modal
open={Boolean(confirmAction)}
onClose={closeConfirmAction}
title={actionTitle}
size='small'
actions={
<>
<Button variant='grey' onClick={closeConfirmAction} disabled={actionLoading}>
Отменить
</Button>
<Button variant='contained' color={confirmAction === 'hardDelete' || confirmAction === 'mark' ? 'error' : 'primary'} onClick={runConfirmedAction} disabled={actionLoading}>
Подтвердить
</Button>
</>
}>
{actionText}
</Modal>
</>
);
};

View File

@ -10,12 +10,13 @@ export const TaskCard = styled.div`
justify-content: flex-start;
align-items: flex-start;
box-sizing: border-box;
border: 0.06rem solid var(--Stroke, rgba(0, 0, 0, 0.1));
border: 0.06rem solid ${({ $marked }) => ($marked ? 'rgba(211, 47, 47, 0.35)' : 'var(--Stroke, rgba(0, 0, 0, 0.1))')};
border-radius: 1rem;
box-shadow:
0rem 2rem 4rem -2rem rgba(0, 0, 0, 0.02),
0rem 4rem 6rem -1rem rgba(0, 0, 0, 0.02);
background: var(--White, rgba(255, 255, 255, 1));
background: ${({ $marked }) => ($marked ? '#fff7f7' : 'var(--White, rgba(255, 255, 255, 1))')};
opacity: ${({ $marked }) => ($marked ? 0.72 : 1)};
overflow: hidden;
cursor: pointer;
position: relative;

View File

@ -5,6 +5,9 @@ export const DEFAULT_ACTIONS = {
ACCESS_WINDOW_CHANGE: 'Изменение этапа',
USER_ROLE_CHANGE: 'Изменение роли пользователя',
CREATE_TASK: 'Создание задачи',
FORM_MARKED_FOR_DELETION: 'Задача отмечена на удаление',
FORM_RESTORED: 'Задача возвращена',
FORM_HARD_DELETED: 'Задача полностью удалена',
// UPDATE_TASK: 'Обновление задачи', //сейчас нет события
// DELETE_TASK: 'Удаление задачи',
USER_CREATE: 'Создание пользователя',

View File

@ -58,6 +58,21 @@ export const EVENT_CONFIGS = {
form_type: 'Форма',
ssp_list: 'Создана для СПП/РФ',
},
FORM_MARKED_FOR_DELETION: {
entity_id: 'Задача',
form_id: 'Задача',
form_type_code: 'Форма',
year: 'Год',
org_unit_id: 'ССП/РФ',
},
FORM_RESTORED: {
entity_id: 'Задача',
before: 'Данные до возврата',
},
FORM_HARD_DELETED: {
entity_id: 'Задача',
before: 'Удалённые данные',
},
ROW_CREATE: {
sheet: 'Лист',
expense_item_id: 'Статья расходов',

View File

@ -40,6 +40,7 @@ export default function TasksPage() {
const yearFilter = searchParams.get('year') || '';
const formTypeFilter = searchParams.get('form_type') || '';
const orgUnitsFilter = searchParams.get('org_units') || '';
const deleteStatusFilter = searchParams.get('delete_status') || 'all';
const orgUnitIdsFilter = useMemo(
() =>
orgUnitsFilter
@ -185,6 +186,7 @@ export default function TasksPage() {
...(yearFilter && { year: Number(yearFilter) }),
...(formTypeFilter && { form_type: formTypeFilter }),
...(orgUnitIdsFilter.length > 0 && { org_units: orgUnitIdsFilter.join(',') }),
...(user?.role_id === ROLES_NAME_ID.admin && deleteStatusFilter && { delete_status: deleteStatusFilter }),
};
const res = await TasksApi.list(params);
@ -216,7 +218,7 @@ export default function TasksPage() {
if (currentRequest === requestId.current) setLoading(false);
}
},
[yearFilter, formTypeFilter, orgUnitIdsFilter, setSearchParams],
[yearFilter, formTypeFilter, orgUnitIdsFilter, deleteStatusFilter, user?.role_id, setSearchParams],
);
const handlePageChange = (_event, value) => {
@ -263,6 +265,7 @@ export default function TasksPage() {
nextParams.delete('year');
nextParams.delete('form_type');
nextParams.delete('org_units');
nextParams.delete('delete_status');
return nextParams;
});
};
@ -286,7 +289,18 @@ export default function TasksPage() {
onLimitChange={handleLimitChange}
entityName='задач'
emptyMessage='Задачи не найдены'
renderItem={(task, exportProps) => <TaskCardComponent key={task.id} taskForm={task} isLastOpened={String(task.id) === lastVisit?.id} fluidWidth {...exportProps} />}
renderItem={(task, exportProps) => (
<TaskCardComponent
key={task.id}
taskForm={task}
isLastOpened={String(task.id) === lastVisit?.id}
fluidWidth
isAdmin={user.role_id === ROLES_NAME_ID.admin}
currentUserId={user.id}
onChanged={() => loadTasks(page, limit)}
{...exportProps}
/>
)}
getItemId={(task) => task.id}
getItemTitle={(task) =>
task.title ||
@ -345,6 +359,19 @@ export default function TasksPage() {
sx={{ width: 480, maxWidth: '100%' }}
/>
{user.role_id === ROLES_NAME_ID.admin && (
<Select
size='small'
displayEmpty
value={deleteStatusFilter}
onChange={(event) => handleFilterChange('delete_status', event.target.value)}
sx={{ width: 230 }}>
<MenuItem value='active'>Непомеченные</MenuItem>
<MenuItem value='marked'>Помеченные</MenuItem>
<MenuItem value='all'>Все задачи</MenuItem>
</Select>
)}
<ResetFiltersButton onClick={handleClearFilters} />
</Box>
}