From 4b9b2df2e39d38314d71f5bf3625fee7f40bd392 Mon Sep 17 00:00:00 2001
From: TronoSfera <119615520+TronoSfera@users.noreply.github.com>
Date: Thu, 26 Feb 2026 18:55:02 +0300
Subject: [PATCH] Task P052-P053
---
.../0019_add_request_cost_to_requests.py | 27 +
.../versions/0020_add_phone_to_admin_users.py | 28 +
.../versions/0021_request_data_chat_fields.py | 50 +
.../0022_request_data_templates_tables.py | 76 +
.../versions/0023_status_important_date.py | 35 +
.../versions/0024_featured_staff_carousel.py | 72 +
app/api/admin/chat.py | 592 ++-
app/api/admin/crud.py | 188 +-
app/api/admin/metrics.py | 151 +-
app/api/admin/requests.py | 536 ++-
app/api/admin/router.py | 3 +-
app/api/admin/test_utils.py | 52 +
app/api/public/chat.py | 157 +-
app/api/public/featured_staff.py | 64 +
app/api/public/router.py | 3 +-
app/core/http_hardening.py | 23 +-
app/data/cleanup_test_artifacts.py | 38 +
app/data/manual_test_seed.py | 694 ++++
app/models/admin_user.py | 1 +
app/models/landing_featured_staff.py | 18 +
app/models/request.py | 2 +
app/models/request_data_requirement.py | 7 +-
app/models/request_data_template.py | 24 +
app/models/request_data_template_item.py | 24 +
app/models/status_history.py | 4 +-
app/models/topic_data_template.py | 2 +
app/schemas/admin.py | 12 +
app/services/chat_service.py | 109 +
app/services/request_status.py | 5 +
app/services/test_data_cleanup.py | 249 ++
app/services/universal_query.py | 121 +-
app/web/admin.css | 1093 +++++-
app/web/admin.jsx | 3496 ++++-------------
.../admin/features/config/ConfigSection.jsx | 569 +++
.../features/dashboard/DashboardSection.jsx | 253 ++
.../features/invoices/InvoicesSection.jsx | 121 +
app/web/admin/features/kanban/KanbanBoard.jsx | 246 ++
app/web/admin/features/meta/MetaSection.jsx | 29 +
.../admin/features/quotes/QuotesSection.jsx | 96 +
.../features/requests/RequestWorkspace.jsx | 1907 +++++++++
.../features/requests/RequestsSection.jsx | 163 +
.../tables/AvailableTablesSection.jsx | 67 +
app/web/admin/hooks/useAdminApi.js | 42 +
app/web/admin/hooks/useAdminCatalogLoaders.js | 82 +
app/web/admin/hooks/useKanban.js | 120 +
app/web/admin/hooks/useRequestWorkspace.js | 436 ++
app/web/admin/hooks/useTableActions.js | 199 +
app/web/admin/hooks/useTableFilterActions.js | 120 +
app/web/admin/hooks/useTablesState.js | 56 +
app/web/admin/index.jsx | 1 +
app/web/admin/shared/constants.js | 155 +
app/web/admin/shared/state.js | 30 +
app/web/admin/shared/utils.js | 348 ++
app/web/client.css | 176 +
app/web/client.html | 18 +
app/web/client.js | 452 ++-
app/web/landing.css | 170 +
app/web/landing.html | 16 +
app/web/landing.js | 120 +
celerybeat-schedule | Bin 16384 -> 16384 bytes
context/10_development_execution_plan.md | 110 +
context/11_test_runbook.md | 47 +-
context/13_role_flows_test_matrix.md | 470 +++
context/14_e2e_backlog_prioritized.md | 92 +
context/15_manual_test_access.md | 54 +
e2e/tests/admin_entry_flow.spec.js | 5 +
e2e/tests/admin_role_flow.spec.js | 14 +-
e2e/tests/admin_status_designer_flow.spec.js | 6 +-
e2e/tests/helpers.js | 144 +-
e2e/tests/kanban_role_flow.spec.js | 11 +-
e2e/tests/lawyer_role_flow.spec.js | 19 +-
e2e/tests/public_client_flow.spec.js | 11 +-
e2e/tests/request_data_file_flow.spec.js | 109 +
frontend/Dockerfile | 3 +-
frontend/nginx.conf | 33 +-
tests/test_dashboard_finance.py | 122 +
tests/test_http_hardening.py | 36 +
tests/test_migrations.py | 51 +-
tests/test_rates.py | 63 +
tests/test_universal_query.py | 151 +
80 files changed, 12433 insertions(+), 3066 deletions(-)
create mode 100644 alembic/versions/0019_add_request_cost_to_requests.py
create mode 100644 alembic/versions/0020_add_phone_to_admin_users.py
create mode 100644 alembic/versions/0021_request_data_chat_fields.py
create mode 100644 alembic/versions/0022_request_data_templates_tables.py
create mode 100644 alembic/versions/0023_status_important_date.py
create mode 100644 alembic/versions/0024_featured_staff_carousel.py
create mode 100644 app/api/admin/test_utils.py
create mode 100644 app/api/public/featured_staff.py
create mode 100644 app/data/cleanup_test_artifacts.py
create mode 100644 app/data/manual_test_seed.py
create mode 100644 app/models/landing_featured_staff.py
create mode 100644 app/models/request_data_template.py
create mode 100644 app/models/request_data_template_item.py
create mode 100644 app/services/test_data_cleanup.py
create mode 100644 app/web/admin/features/config/ConfigSection.jsx
create mode 100644 app/web/admin/features/dashboard/DashboardSection.jsx
create mode 100644 app/web/admin/features/invoices/InvoicesSection.jsx
create mode 100644 app/web/admin/features/kanban/KanbanBoard.jsx
create mode 100644 app/web/admin/features/meta/MetaSection.jsx
create mode 100644 app/web/admin/features/quotes/QuotesSection.jsx
create mode 100644 app/web/admin/features/requests/RequestWorkspace.jsx
create mode 100644 app/web/admin/features/requests/RequestsSection.jsx
create mode 100644 app/web/admin/features/tables/AvailableTablesSection.jsx
create mode 100644 app/web/admin/hooks/useAdminApi.js
create mode 100644 app/web/admin/hooks/useAdminCatalogLoaders.js
create mode 100644 app/web/admin/hooks/useKanban.js
create mode 100644 app/web/admin/hooks/useRequestWorkspace.js
create mode 100644 app/web/admin/hooks/useTableActions.js
create mode 100644 app/web/admin/hooks/useTableFilterActions.js
create mode 100644 app/web/admin/hooks/useTablesState.js
create mode 100644 app/web/admin/index.jsx
create mode 100644 app/web/admin/shared/constants.js
create mode 100644 app/web/admin/shared/state.js
create mode 100644 app/web/admin/shared/utils.js
create mode 100644 context/13_role_flows_test_matrix.md
create mode 100644 context/14_e2e_backlog_prioritized.md
create mode 100644 context/15_manual_test_access.md
create mode 100644 e2e/tests/request_data_file_flow.spec.js
create mode 100644 tests/test_universal_query.py
diff --git a/alembic/versions/0019_add_request_cost_to_requests.py b/alembic/versions/0019_add_request_cost_to_requests.py
new file mode 100644
index 0000000..d58dd77
--- /dev/null
+++ b/alembic/versions/0019_add_request_cost_to_requests.py
@@ -0,0 +1,27 @@
+"""add request_cost field to requests
+
+Revision ID: 0019_request_cost_on_requests
+Revises: 0018_status_groups
+Create Date: 2026-02-26 00:15:00.000000
+"""
+
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "0019_request_cost_on_requests"
+down_revision = "0018_status_groups"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column("requests", sa.Column("request_cost", sa.Numeric(14, 2), nullable=True))
+
+
+def downgrade() -> None:
+ op.drop_column("requests", "request_cost")
+
diff --git a/alembic/versions/0020_add_phone_to_admin_users.py b/alembic/versions/0020_add_phone_to_admin_users.py
new file mode 100644
index 0000000..6ec0c9f
--- /dev/null
+++ b/alembic/versions/0020_add_phone_to_admin_users.py
@@ -0,0 +1,28 @@
+"""add phone to admin_users
+
+Revision ID: 0020_admin_users_phone
+Revises: 0019_request_cost_on_requests
+Create Date: 2026-02-26 22:10:00.000000
+"""
+
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "0020_admin_users_phone"
+down_revision = "0019_request_cost_on_requests"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column("admin_users", sa.Column("phone", sa.String(length=30), nullable=True))
+ op.create_index(op.f("ix_admin_users_phone"), "admin_users", ["phone"], unique=False)
+
+
+def downgrade() -> None:
+ op.drop_index(op.f("ix_admin_users_phone"), table_name="admin_users")
+ op.drop_column("admin_users", "phone")
diff --git a/alembic/versions/0021_request_data_chat_fields.py b/alembic/versions/0021_request_data_chat_fields.py
new file mode 100644
index 0000000..59d0fe5
--- /dev/null
+++ b/alembic/versions/0021_request_data_chat_fields.py
@@ -0,0 +1,50 @@
+"""extend request data templates and request data requirements for chat requests
+
+Revision ID: 0021_request_data_chat_fields
+Revises: 0020_admin_users_phone
+Create Date: 2026-02-26 12:10:00.000000
+"""
+
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "0021_request_data_chat_fields"
+down_revision = "0020_admin_users_phone"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column("topic_data_templates", sa.Column("value_type", sa.String(length=20), nullable=False, server_default="text"))
+ op.add_column("topic_data_templates", sa.Column("document_name", sa.String(length=200), nullable=True))
+ op.create_index(op.f("ix_topic_data_templates_document_name"), "topic_data_templates", ["document_name"], unique=False)
+
+ op.add_column("request_data_requirements", sa.Column("request_message_id", sa.UUID(), nullable=True))
+ op.add_column("request_data_requirements", sa.Column("field_type", sa.String(length=20), nullable=False, server_default="text"))
+ op.add_column("request_data_requirements", sa.Column("document_name", sa.String(length=200), nullable=True))
+ op.add_column("request_data_requirements", sa.Column("value_text", sa.String(length=500), nullable=True))
+ op.add_column("request_data_requirements", sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"))
+ op.create_index(op.f("ix_request_data_requirements_request_message_id"), "request_data_requirements", ["request_message_id"], unique=False)
+ op.create_index(op.f("ix_request_data_requirements_document_name"), "request_data_requirements", ["document_name"], unique=False)
+
+ op.alter_column("topic_data_templates", "value_type", server_default=None)
+ op.alter_column("request_data_requirements", "field_type", server_default=None)
+ op.alter_column("request_data_requirements", "sort_order", server_default=None)
+
+
+def downgrade() -> None:
+ op.drop_index(op.f("ix_request_data_requirements_document_name"), table_name="request_data_requirements")
+ op.drop_index(op.f("ix_request_data_requirements_request_message_id"), table_name="request_data_requirements")
+ op.drop_column("request_data_requirements", "sort_order")
+ op.drop_column("request_data_requirements", "value_text")
+ op.drop_column("request_data_requirements", "document_name")
+ op.drop_column("request_data_requirements", "field_type")
+ op.drop_column("request_data_requirements", "request_message_id")
+
+ op.drop_index(op.f("ix_topic_data_templates_document_name"), table_name="topic_data_templates")
+ op.drop_column("topic_data_templates", "document_name")
+ op.drop_column("topic_data_templates", "value_type")
diff --git a/alembic/versions/0022_request_data_templates_tables.py b/alembic/versions/0022_request_data_templates_tables.py
new file mode 100644
index 0000000..74378fb
--- /dev/null
+++ b/alembic/versions/0022_request_data_templates_tables.py
@@ -0,0 +1,76 @@
+"""add request data templates and template items tables
+
+Revision ID: 0022_req_data_templates
+Revises: 0021_request_data_chat_fields
+Create Date: 2026-02-26 13:00:00.000000
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import postgresql
+
+
+# revision identifiers, used by Alembic.
+revision = "0022_req_data_templates"
+down_revision = "0021_request_data_chat_fields"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "request_data_templates",
+ sa.Column("topic_code", sa.String(length=50), nullable=False),
+ sa.Column("name", sa.String(length=200), nullable=False),
+ sa.Column("description", sa.Text(), nullable=True),
+ sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
+ sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
+ sa.Column("created_by_admin_id", postgresql.UUID(as_uuid=True), nullable=True),
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"),
+ sa.UniqueConstraint("topic_code", "name", name="uq_request_data_templates_topic_name"),
+ )
+ op.create_index(op.f("ix_request_data_templates_topic_code"), "request_data_templates", ["topic_code"], unique=False)
+ op.create_index(op.f("ix_request_data_templates_name"), "request_data_templates", ["name"], unique=False)
+ op.create_index(op.f("ix_request_data_templates_created_by_admin_id"), "request_data_templates", ["created_by_admin_id"], unique=False)
+
+ op.create_table(
+ "request_data_template_items",
+ sa.Column("request_data_template_id", postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column("topic_data_template_id", postgresql.UUID(as_uuid=True), nullable=True),
+ sa.Column("key", sa.String(length=80), nullable=False),
+ sa.Column("label", sa.String(length=200), nullable=False),
+ sa.Column("value_type", sa.String(length=20), nullable=False, server_default="string"),
+ sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"),
+ sa.UniqueConstraint("request_data_template_id", "key", name="uq_request_data_template_items_template_key"),
+ )
+ op.create_index(op.f("ix_request_data_template_items_request_data_template_id"), "request_data_template_items", ["request_data_template_id"], unique=False)
+ op.create_index(op.f("ix_request_data_template_items_topic_data_template_id"), "request_data_template_items", ["topic_data_template_id"], unique=False)
+ op.create_index(op.f("ix_request_data_template_items_key"), "request_data_template_items", ["key"], unique=False)
+
+ op.alter_column("request_data_templates", "enabled", server_default=None)
+ op.alter_column("request_data_templates", "sort_order", server_default=None)
+ op.alter_column("request_data_templates", "responsible", server_default=None)
+ op.alter_column("request_data_template_items", "value_type", server_default=None)
+ op.alter_column("request_data_template_items", "sort_order", server_default=None)
+ op.alter_column("request_data_template_items", "responsible", server_default=None)
+
+
+def downgrade() -> None:
+ op.drop_index(op.f("ix_request_data_template_items_key"), table_name="request_data_template_items")
+ op.drop_index(op.f("ix_request_data_template_items_topic_data_template_id"), table_name="request_data_template_items")
+ op.drop_index(op.f("ix_request_data_template_items_request_data_template_id"), table_name="request_data_template_items")
+ op.drop_table("request_data_template_items")
+
+ op.drop_index(op.f("ix_request_data_templates_created_by_admin_id"), table_name="request_data_templates")
+ op.drop_index(op.f("ix_request_data_templates_name"), table_name="request_data_templates")
+ op.drop_index(op.f("ix_request_data_templates_topic_code"), table_name="request_data_templates")
+ op.drop_table("request_data_templates")
diff --git a/alembic/versions/0023_status_important_date.py b/alembic/versions/0023_status_important_date.py
new file mode 100644
index 0000000..58b68c3
--- /dev/null
+++ b/alembic/versions/0023_status_important_date.py
@@ -0,0 +1,35 @@
+"""add important date to requests and status history
+
+Revision ID: 0023_status_important_date
+Revises: 0022_req_data_templates
+Create Date: 2026-02-26 15:20:00.000000
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+
+
+# revision identifiers, used by Alembic.
+revision = "0023_status_important_date"
+down_revision = "0022_req_data_templates"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column("requests", sa.Column("important_date_at", sa.DateTime(timezone=True), nullable=True))
+ op.create_index(op.f("ix_requests_important_date_at"), "requests", ["important_date_at"], unique=False)
+
+ op.add_column("status_history", sa.Column("important_date_at", sa.DateTime(timezone=True), nullable=True))
+ op.create_index(op.f("ix_status_history_important_date_at"), "status_history", ["important_date_at"], unique=False)
+
+
+def downgrade() -> None:
+ op.drop_index(op.f("ix_status_history_important_date_at"), table_name="status_history")
+ op.drop_column("status_history", "important_date_at")
+
+ op.drop_index(op.f("ix_requests_important_date_at"), table_name="requests")
+ op.drop_column("requests", "important_date_at")
+
diff --git a/alembic/versions/0024_featured_staff_carousel.py b/alembic/versions/0024_featured_staff_carousel.py
new file mode 100644
index 0000000..46ea166
--- /dev/null
+++ b/alembic/versions/0024_featured_staff_carousel.py
@@ -0,0 +1,72 @@
+"""add landing featured staff carousel table
+
+Revision ID: 0024_featured_staff_carousel
+Revises: 0023_status_important_date
+Create Date: 2026-02-26 23:45:00.000000
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import postgresql
+
+
+# revision identifiers, used by Alembic.
+revision = "0024_featured_staff_carousel"
+down_revision = "0023_status_important_date"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "landing_featured_staff",
+ sa.Column("admin_user_id", postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column("caption", sa.String(length=300), nullable=True),
+ sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
+ sa.Column("pinned", sa.Boolean(), nullable=False, server_default=sa.text("false")),
+ sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"),
+ sa.UniqueConstraint("admin_user_id", name="uq_landing_featured_staff_admin_user"),
+ )
+ op.create_index(
+ op.f("ix_landing_featured_staff_admin_user_id"),
+ "landing_featured_staff",
+ ["admin_user_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_landing_featured_staff_sort_order"),
+ "landing_featured_staff",
+ ["sort_order"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_landing_featured_staff_pinned"),
+ "landing_featured_staff",
+ ["pinned"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_landing_featured_staff_enabled"),
+ "landing_featured_staff",
+ ["enabled"],
+ unique=False,
+ )
+
+ op.alter_column("landing_featured_staff", "sort_order", server_default=None)
+ op.alter_column("landing_featured_staff", "pinned", server_default=None)
+ op.alter_column("landing_featured_staff", "enabled", server_default=None)
+ op.alter_column("landing_featured_staff", "responsible", server_default=None)
+
+
+def downgrade() -> None:
+ op.drop_index(op.f("ix_landing_featured_staff_enabled"), table_name="landing_featured_staff")
+ op.drop_index(op.f("ix_landing_featured_staff_pinned"), table_name="landing_featured_staff")
+ op.drop_index(op.f("ix_landing_featured_staff_sort_order"), table_name="landing_featured_staff")
+ op.drop_index(op.f("ix_landing_featured_staff_admin_user_id"), table_name="landing_featured_staff")
+ op.drop_table("landing_featured_staff")
diff --git a/app/api/admin/chat.py b/app/api/admin/chat.py
index c60c32d..d0124ee 100644
--- a/app/api/admin/chat.py
+++ b/app/api/admin/chat.py
@@ -8,10 +8,22 @@ from sqlalchemy.orm import Session
from app.core.deps import require_role
from app.db.session import get_db
from app.models.admin_user import AdminUser
+from app.models.attachment import Attachment
+from app.models.message import Message
from app.models.request import Request
-from app.services.chat_service import create_admin_or_lawyer_message, list_messages_for_request, serialize_message
+from app.models.request_data_requirement import RequestDataRequirement
+from app.models.request_data_template import RequestDataTemplate
+from app.models.request_data_template_item import RequestDataTemplateItem
+from app.models.topic_data_template import TopicDataTemplate
+from app.services.chat_service import (
+ create_admin_or_lawyer_message,
+ list_messages_for_request,
+ serialize_message,
+ serialize_messages_for_request,
+)
router = APIRouter()
+ALLOWED_VALUE_TYPES = {"string", "text", "date", "number", "file"}
def _request_uuid_or_400(request_id: str) -> UUID:
@@ -52,6 +64,121 @@ def _ensure_lawyer_can_manage_request_or_403(admin: dict, req: Request) -> None:
raise HTTPException(status_code=403, detail="Юрист может работать только со своими назначенными заявками")
+def _parse_uuid_or_400(raw: str, field_name: str) -> UUID:
+ try:
+ return UUID(str(raw))
+ except ValueError:
+ raise HTTPException(status_code=400, detail=f'Некорректное поле "{field_name}"')
+
+
+def _slugify_key(raw: str) -> str:
+ text = str(raw or "").strip().lower()
+ out = []
+ dash = False
+ for ch in text:
+ if ch.isalnum():
+ out.append(ch)
+ dash = False
+ continue
+ if not dash:
+ out.append("-")
+ dash = True
+ slug = "".join(out).strip("-")
+ return (slug or "data-field")[:80]
+
+
+def _normalize_value_type(raw: str | None) -> str:
+ value = str(raw or "text").strip().lower()
+ if value not in ALLOWED_VALUE_TYPES:
+ raise HTTPException(status_code=400, detail='Тип поля должен быть одним из: string, text, date, number, file')
+ return value
+
+
+def _serialize_template(row: TopicDataTemplate) -> dict:
+ return {
+ "id": str(row.id),
+ "topic_code": row.topic_code,
+ "key": row.key,
+ "label": row.label,
+ "value_type": str(row.value_type or "text"),
+ "document_name": row.document_name,
+ "description": row.description,
+ "sort_order": int(row.sort_order or 0),
+ "enabled": bool(row.enabled),
+ }
+
+
+def _serialize_request_data_template(row: RequestDataTemplate) -> dict:
+ return {
+ "id": str(row.id),
+ "topic_code": row.topic_code,
+ "name": row.name,
+ "description": row.description,
+ "enabled": bool(row.enabled),
+ "sort_order": int(row.sort_order or 0),
+ "created_by_admin_id": str(row.created_by_admin_id) if row.created_by_admin_id else None,
+ }
+
+
+def _serialize_request_data_template_item(row: RequestDataTemplateItem) -> dict:
+ return {
+ "id": str(row.id),
+ "request_data_template_id": str(row.request_data_template_id),
+ "topic_data_template_id": str(row.topic_data_template_id) if row.topic_data_template_id else None,
+ "key": row.key,
+ "label": row.label,
+ "value_type": str(row.value_type or "string"),
+ "sort_order": int(row.sort_order or 0),
+ }
+
+
+def _serialize_data_request_items(db: Session, rows: list[RequestDataRequirement]) -> list[dict]:
+ attachment_ids: list[UUID] = []
+ for row in rows:
+ if str(row.field_type or "").lower() != "file":
+ continue
+ try:
+ attachment_ids.append(UUID(str(row.value_text or "").strip()))
+ except Exception:
+ continue
+ attachment_map = {}
+ if attachment_ids:
+ attachment_rows = db.query(Attachment).filter(Attachment.id.in_(attachment_ids)).all() # type: ignore[name-defined]
+ attachment_map = {str(item.id): item for item in attachment_rows}
+ out = []
+ for index, row in enumerate(rows, start=1):
+ value_text = str(row.value_text or "").strip()
+ value_file = None
+ if str(row.field_type or "").lower() == "file" and value_text:
+ attachment = attachment_map.get(value_text)
+ if attachment is not None:
+ value_file = {
+ "attachment_id": str(attachment.id),
+ "file_name": attachment.file_name,
+ "mime_type": attachment.mime_type,
+ "size_bytes": int(attachment.size_bytes or 0),
+ "download_url": f"/api/admin/uploads/object/{attachment.id}",
+ }
+ out.append(
+ {
+ "id": str(row.id),
+ "request_message_id": str(row.request_message_id) if row.request_message_id else None,
+ "topic_template_id": str(row.topic_template_id) if row.topic_template_id else None,
+ "key": row.key,
+ "label": row.label,
+ "field_type": str(row.field_type or "text"),
+ "document_name": row.document_name,
+ "description": row.description,
+ "value_text": row.value_text,
+ "value_file": value_file,
+ "is_filled": bool(value_text),
+ "sort_order": int(row.sort_order or 0),
+ "index": index,
+ }
+ )
+ return out
+
+
@router.get("/requests/{request_id}/messages")
def list_request_messages(
request_id: str,
@@ -61,7 +188,7 @@ def list_request_messages(
req = _request_for_id_or_404(db, request_id)
_ensure_lawyer_can_view_request_or_403(admin, req)
rows = list_messages_for_request(db, req.id)
- return {"rows": [serialize_message(row) for row in rows], "total": len(rows)}
+ return {"rows": serialize_messages_for_request(db, req.id, rows), "total": len(rows)}
@router.post("/requests/{request_id}/messages", status_code=201)
@@ -95,3 +222,464 @@ def create_request_message(
actor_admin_user_id=actor_admin_user_id,
)
return serialize_message(row)
+
+
+@router.get("/requests/{request_id}/data-request-templates")
+def list_data_request_templates(
+ request_id: str,
+ document: str | None = None,
+ db: Session = Depends(get_db),
+ admin: dict = Depends(require_role("ADMIN", "LAWYER")),
+):
+ req = _request_for_id_or_404(db, request_id)
+ _ensure_lawyer_can_manage_request_or_403(admin, req)
+ topic_code = str(req.topic_code or "").strip()
+ if not topic_code:
+ return {"rows": [], "documents": [], "templates": []}
+ query = db.query(TopicDataTemplate).filter(TopicDataTemplate.topic_code == topic_code)
+ document_name = str(document or "").strip()
+ if document_name:
+ query = query.filter(TopicDataTemplate.document_name == document_name)
+ rows = (
+ query.order_by(
+ TopicDataTemplate.document_name.asc().nullsfirst(),
+ TopicDataTemplate.sort_order.asc(),
+ TopicDataTemplate.label.asc(),
+ TopicDataTemplate.key.asc(),
+ ).all()
+ )
+ all_docs_rows = (
+ db.query(TopicDataTemplate.document_name)
+ .filter(TopicDataTemplate.topic_code == topic_code, TopicDataTemplate.enabled.is_(True))
+ .distinct()
+ .all()
+ )
+ documents = sorted({str(row[0]).strip() for row in all_docs_rows if row[0]}, key=lambda x: x.lower())
+ template_rows = (
+ db.query(RequestDataTemplate)
+ .filter(RequestDataTemplate.topic_code == topic_code, RequestDataTemplate.enabled.is_(True))
+ .order_by(RequestDataTemplate.sort_order.asc(), RequestDataTemplate.name.asc())
+ .all()
+ )
+ return {
+ "rows": [_serialize_template(row) for row in rows if row.enabled], # legacy catalog payload
+ "documents": documents, # legacy
+ "templates": [_serialize_request_data_template(row) for row in template_rows],
+ }
+
+
+@router.get("/requests/{request_id}/data-requests/{message_id}")
+def get_data_request_batch(
+ request_id: str,
+ message_id: str,
+ db: Session = Depends(get_db),
+ admin: dict = Depends(require_role("ADMIN", "LAWYER")),
+):
+ req = _request_for_id_or_404(db, request_id)
+ _ensure_lawyer_can_view_request_or_403(admin, req)
+ msg_uuid = _parse_uuid_or_400(message_id, "message_id")
+ message = db.get(Message, msg_uuid)
+ if message is None or message.request_id != req.id:
+ raise HTTPException(status_code=404, detail="Сообщение запроса не найдено")
+ rows = (
+ db.query(RequestDataRequirement)
+ .filter(
+ RequestDataRequirement.request_id == req.id,
+ RequestDataRequirement.request_message_id == msg_uuid,
+ )
+ .order_by(RequestDataRequirement.sort_order.asc(), RequestDataRequirement.created_at.asc(), RequestDataRequirement.id.asc())
+ .all()
+ )
+ if not rows:
+ raise HTTPException(status_code=404, detail="Набор данных для сообщения не найден")
+ return {
+ "message_id": str(message.id),
+ "request_id": str(req.id),
+ "track_number": req.track_number,
+ "document_name": rows[0].document_name if rows else None,
+ "items": _serialize_data_request_items(db, rows),
+ }
+
+
+@router.get("/requests/{request_id}/data-request-templates/{template_id}")
+def get_data_request_template(
+ request_id: str,
+ template_id: str,
+ db: Session = Depends(get_db),
+ admin: dict = Depends(require_role("ADMIN", "LAWYER")),
+):
+ req = _request_for_id_or_404(db, request_id)
+ _ensure_lawyer_can_manage_request_or_403(admin, req)
+ template_uuid = _parse_uuid_or_400(template_id, "template_id")
+ template = db.get(RequestDataTemplate, template_uuid)
+ if template is None:
+ raise HTTPException(status_code=404, detail="Шаблон не найден")
+ if str(template.topic_code or "").strip() != str(req.topic_code or "").strip():
+ raise HTTPException(status_code=400, detail="Шаблон не соответствует теме заявки")
+ rows = (
+ db.query(RequestDataTemplateItem)
+ .filter(RequestDataTemplateItem.request_data_template_id == template.id)
+ .order_by(RequestDataTemplateItem.sort_order.asc(), RequestDataTemplateItem.created_at.asc(), RequestDataTemplateItem.id.asc())
+ .all()
+ )
+ return {
+ "template": _serialize_request_data_template(template),
+ "items": [_serialize_request_data_template_item(row) for row in rows],
+ }
+
+
+@router.post("/requests/{request_id}/data-request-templates", status_code=201)
+def save_data_request_template(
+ request_id: str,
+ payload: dict,
+ db: Session = Depends(get_db),
+ admin: dict = Depends(require_role("ADMIN", "LAWYER")),
+):
+ req = _request_for_id_or_404(db, request_id)
+ _ensure_lawyer_can_manage_request_or_403(admin, req)
+ topic_code = str(req.topic_code or "").strip()
+ if not topic_code:
+ raise HTTPException(status_code=400, detail="У заявки не указана тема")
+
+ body = payload or {}
+ name = str(body.get("name") or "").strip()
+ if not name:
+ raise HTTPException(status_code=400, detail="Укажите название шаблона")
+ raw_items = body.get("items")
+ if not isinstance(raw_items, list) or not raw_items:
+ raise HTTPException(status_code=400, detail="Шаблон должен содержать хотя бы одно поле")
+
+ actor_uuid = None
+ raw_actor = str(admin.get("sub") or "").strip()
+ if raw_actor:
+ try:
+ actor_uuid = UUID(raw_actor)
+ except ValueError:
+ actor_uuid = None
+ responsible = str(admin.get("email") or "").strip() or "Администратор системы"
+
+ template = None
+ template_id_raw = str(body.get("template_id") or "").strip()
+ if template_id_raw:
+ template = db.get(RequestDataTemplate, _parse_uuid_or_400(template_id_raw, "template_id"))
+ if template is None:
+ raise HTTPException(status_code=404, detail="Шаблон не найден")
+ if str(template.topic_code or "").strip() != topic_code:
+ raise HTTPException(status_code=400, detail="Шаблон не соответствует теме заявки")
+ else:
+ template = (
+ db.query(RequestDataTemplate)
+ .filter(RequestDataTemplate.topic_code == topic_code, RequestDataTemplate.name == name)
+ .first()
+ )
+
+ if template is None:
+ template = RequestDataTemplate(
+ topic_code=topic_code,
+ name=name,
+ enabled=True,
+ sort_order=0,
+ created_by_admin_id=actor_uuid,
+ responsible=responsible,
+ )
+ db.add(template)
+ db.flush()
+ else:
+ actor_role = str(admin.get("role") or "").upper()
+ if actor_role == "LAWYER":
+ owner_id = str(template.created_by_admin_id or "").strip()
+ actor_id = str(actor_uuid or "").strip()
+ if owner_id and actor_id and owner_id != actor_id:
+ raise HTTPException(status_code=403, detail="Юрист может изменять только свои шаблоны")
+ template.name = name
+ template.responsible = responsible
+ db.add(template)
+ db.flush()
+
+ touched_keys: set[str] = set()
+ normalized_items: list[tuple[int, TopicDataTemplate | None, str, str, str]] = []
+ for index, item in enumerate(raw_items):
+ if not isinstance(item, dict):
+ raise HTTPException(status_code=400, detail="Элемент шаблона должен быть объектом")
+ catalog = None
+ catalog_id_raw = str(item.get("topic_data_template_id") or item.get("topic_template_id") or "").strip()
+ if catalog_id_raw:
+ catalog = db.get(TopicDataTemplate, _parse_uuid_or_400(catalog_id_raw, "topic_data_template_id"))
+ if catalog is None:
+ raise HTTPException(status_code=400, detail="Поле справочника не найдено")
+ label = str(item.get("label") or (catalog.label if catalog else "")).strip()
+ if not label:
+ raise HTTPException(status_code=400, detail="Укажите наименование поля")
+ value_type = _normalize_value_type(item.get("value_type") or item.get("field_type") or (catalog.value_type if catalog else None))
+ key = str(item.get("key") or (catalog.key if catalog else "")).strip()
+ if not key:
+ key = _slugify_key(label)
+ key = key[:80]
+ if key in touched_keys:
+ raise HTTPException(status_code=400, detail=f'Поле "{label}" добавлено дважды')
+ touched_keys.add(key)
+
+ if catalog is None:
+ catalog = (
+ db.query(TopicDataTemplate)
+ .filter(TopicDataTemplate.topic_code == topic_code, TopicDataTemplate.key == key)
+ .first()
+ )
+ if catalog is None:
+ catalog = TopicDataTemplate(
+ topic_code=topic_code,
+ key=key,
+ label=label,
+ value_type=value_type,
+ enabled=True,
+ required=True,
+ sort_order=index,
+ responsible=responsible,
+ )
+ db.add(catalog)
+ db.flush()
+ else:
+ changed = False
+ if str(catalog.label or "") != label:
+ catalog.label = label
+ changed = True
+ if str(catalog.value_type or "string") != value_type:
+ catalog.value_type = value_type
+ changed = True
+ if changed:
+ catalog.responsible = responsible
+ db.add(catalog)
+ db.flush()
+ normalized_items.append((index, catalog, key, label, value_type))
+
+ existing_items = (
+ db.query(RequestDataTemplateItem)
+ .filter(RequestDataTemplateItem.request_data_template_id == template.id)
+ .all()
+ )
+ by_key = {str(row.key): row for row in existing_items}
+ for index, catalog, key, label, value_type in normalized_items:
+ row = by_key.get(key)
+ if row is None:
+ row = RequestDataTemplateItem(
+ request_data_template_id=template.id,
+ topic_data_template_id=catalog.id if catalog else None,
+ key=key,
+ label=label,
+ value_type=value_type,
+ sort_order=index,
+ responsible=responsible,
+ )
+ else:
+ row.topic_data_template_id = catalog.id if catalog else None
+ row.label = label
+ row.value_type = value_type
+ row.sort_order = index
+ row.responsible = responsible
+ db.add(row)
+ for row in existing_items:
+ if str(row.key) not in touched_keys:
+ db.delete(row)
+
+ db.commit()
+ db.refresh(template)
+ items = (
+ db.query(RequestDataTemplateItem)
+ .filter(RequestDataTemplateItem.request_data_template_id == template.id)
+ .order_by(RequestDataTemplateItem.sort_order.asc(), RequestDataTemplateItem.created_at.asc(), RequestDataTemplateItem.id.asc())
+ .all()
+ )
+ return {"template": _serialize_request_data_template(template), "items": [_serialize_request_data_template_item(row) for row in items]}
+
+
+@router.post("/requests/{request_id}/data-requests", status_code=201)
+def upsert_data_request_batch(
+ request_id: str,
+ payload: dict,
+ db: Session = Depends(get_db),
+ admin: dict = Depends(require_role("ADMIN", "LAWYER")),
+):
+ req = _request_for_id_or_404(db, request_id)
+ _ensure_lawyer_can_manage_request_or_403(admin, req)
+ actor_role = str(admin.get("role") or "").strip().upper()
+
+ body = payload or {}
+ raw_items = body.get("items")
+ if not isinstance(raw_items, list) or not raw_items:
+ raise HTTPException(status_code=400, detail="Нужно передать список полей запроса")
+
+ message_id_raw = str(body.get("message_id") or "").strip()
+ existing_message = None
+ existing_message_rows: list[RequestDataRequirement] = []
+ if message_id_raw:
+ msg_uuid = _parse_uuid_or_400(message_id_raw, "message_id")
+ existing_message = db.get(Message, msg_uuid)
+ if existing_message is None or existing_message.request_id != req.id:
+ raise HTTPException(status_code=404, detail="Сообщение запроса не найдено")
+ existing_message_rows = (
+ db.query(RequestDataRequirement)
+ .filter(
+ RequestDataRequirement.request_id == req.id,
+ RequestDataRequirement.request_message_id == existing_message.id,
+ )
+ .all()
+ )
+ else:
+ role = actor_role
+ actor_name = str(admin.get("email") or "").strip() or ("Юрист" if role == "LAWYER" else "Администратор")
+ actor_admin_user_id = str(admin.get("sub") or "").strip() or None
+ if actor_admin_user_id:
+ try:
+ actor_uuid = UUID(actor_admin_user_id)
+ except ValueError:
+ actor_uuid = None
+ if actor_uuid is not None:
+ actor_user = db.get(AdminUser, actor_uuid)
+ if actor_user is not None:
+ actor_name = str(actor_user.name or actor_user.email or actor_name)
+ existing_message = create_admin_or_lawyer_message(
+ db,
+ request=req,
+ body="Запрос",
+ actor_role=role,
+ actor_name=actor_name,
+ actor_admin_user_id=actor_admin_user_id,
+ )
+
+ message_uuid = existing_message.id
+ topic_code = str(req.topic_code or "").strip()
+ document_name_default = str(body.get("document_name") or "").strip() or None
+ actor_uuid = None
+ raw_actor = str(admin.get("sub") or "").strip()
+ if raw_actor:
+ try:
+ actor_uuid = UUID(raw_actor)
+ except ValueError:
+ actor_uuid = None
+
+ normalized_rows: list[RequestDataRequirement] = []
+ touched_keys: set[str] = set()
+ for index, item in enumerate(raw_items):
+ if not isinstance(item, dict):
+ raise HTTPException(status_code=400, detail="Элемент списка полей должен быть объектом")
+ template = None
+ template_id_raw = str(item.get("topic_template_id") or "").strip()
+ if template_id_raw:
+ template = db.get(TopicDataTemplate, _parse_uuid_or_400(template_id_raw, "topic_template_id"))
+ if template is None:
+ raise HTTPException(status_code=400, detail="Шаблон дополнительного поля не найден")
+
+ label = str(item.get("label") or (template.label if template else "")).strip()
+ if not label:
+ raise HTTPException(status_code=400, detail="Укажите наименование поля")
+ field_type = _normalize_value_type(item.get("field_type") or (template.value_type if template else None))
+ doc_name = str(item.get("document_name") or (template.document_name if template else "") or (document_name_default or "")).strip() or None
+ key = str(item.get("key") or (template.key if template else "")).strip()
+ if not key:
+ key = _slugify_key(label)
+ key = key[:80]
+ if key in touched_keys:
+ raise HTTPException(status_code=400, detail=f'Поле "{label}" добавлено дважды')
+ touched_keys.add(key)
+
+ topic_template_id = None
+ if template is not None:
+ topic_template_id = template.id
+ elif topic_code:
+ existing_template = (
+ db.query(TopicDataTemplate)
+ .filter(TopicDataTemplate.topic_code == topic_code, TopicDataTemplate.key == key)
+ .first()
+ )
+ if existing_template is None:
+ existing_template = TopicDataTemplate(
+ topic_code=topic_code,
+ key=key,
+ label=label,
+ value_type=field_type,
+ document_name=doc_name,
+ enabled=True,
+ required=True,
+ sort_order=index,
+ responsible=str(admin.get("email") or "").strip() or "Администратор системы",
+ )
+ db.add(existing_template)
+ db.flush()
+ else:
+ changed = False
+ if str(existing_template.label or "") != label:
+ existing_template.label = label
+ changed = True
+ if str(existing_template.value_type or "text") != field_type:
+ existing_template.value_type = field_type
+ changed = True
+ if str(existing_template.document_name or "") != str(doc_name or ""):
+ existing_template.document_name = doc_name
+ changed = True
+ if changed:
+ db.add(existing_template)
+ db.flush()
+ topic_template_id = existing_template.id
+
+ req_row = (
+ db.query(RequestDataRequirement)
+ .filter(RequestDataRequirement.request_id == req.id, RequestDataRequirement.key == key)
+ .first()
+ )
+ if req_row is None:
+ req_row = RequestDataRequirement(
+ request_id=req.id,
+ request_message_id=message_uuid,
+ topic_template_id=topic_template_id,
+ key=key,
+ label=label,
+ field_type=field_type,
+ document_name=doc_name,
+ required=True,
+ sort_order=index,
+ created_by_admin_id=actor_uuid,
+ responsible=str(admin.get("email") or "").strip() or "Администратор системы",
+ )
+ else:
+ if actor_role == "LAWYER" and str(req_row.value_text or "").strip():
+ current_message_id = str(req_row.request_message_id or "")
+ incoming_message_id = str(message_uuid or "")
+ current_topic_template_id = str(req_row.topic_template_id or "")
+ incoming_topic_template_id = str(topic_template_id or "")
+ current_doc_name = str(req_row.document_name or "") if req_row.document_name is not None else ""
+ incoming_doc_name = str(doc_name or "")
+ if (
+ str(req_row.label or "") != label
+ or str(req_row.field_type or "text") != field_type
+ or current_doc_name != incoming_doc_name
+ or current_topic_template_id != incoming_topic_template_id
+ or current_message_id != incoming_message_id
+ or int(req_row.sort_order or 0) != int(index)
+ ):
+ raise HTTPException(status_code=403, detail="Юрист не может изменять заполненные клиентом данные")
+ req_row.request_message_id = message_uuid
+ req_row.topic_template_id = topic_template_id
+ req_row.label = label
+ req_row.field_type = field_type
+ req_row.document_name = doc_name
+ req_row.sort_order = index
+ req_row.responsible = str(admin.get("email") or "").strip() or "Администратор системы"
+ db.add(req_row)
+ normalized_rows.append(req_row)
+
+ if message_id_raw:
+ if actor_role == "LAWYER":
+ for row in existing_message_rows:
+ if row.key not in touched_keys and str(row.value_text or "").strip():
+ raise HTTPException(status_code=403, detail="Юрист не может удалять заполненные клиентом данные")
+ for row in existing_message_rows:
+ if row.key not in touched_keys:
+ db.delete(row)
+
+ db.commit()
+ fresh_messages = list_messages_for_request(db, req.id)
+ serialized = serialize_messages_for_request(db, req.id, fresh_messages)
+ payload_row = next((item for item in serialized if str(item.get("id")) == str(message_uuid)), None)
+ if payload_row is None:
+ raise HTTPException(status_code=500, detail="Не удалось сформировать сообщение запроса")
+ return payload_row
diff --git a/app/api/admin/crud.py b/app/api/admin/crud.py
index c579e18..47cc80d 100644
--- a/app/api/admin/crud.py
+++ b/app/api/admin/crud.py
@@ -4,7 +4,7 @@ import importlib
import json
import pkgutil
import uuid
-from datetime import date, datetime, timezone
+from datetime import date, datetime, timedelta, timezone
from decimal import Decimal
from functools import lru_cache
from typing import Any
@@ -27,6 +27,8 @@ from app.models.form_field import FormField
from app.models.client import Client
from app.models.table_availability import TableAvailability
from app.models.request_data_requirement import RequestDataRequirement
+from app.models.request_data_template import RequestDataTemplate
+from app.models.request_data_template_item import RequestDataTemplateItem
from app.models.attachment import Attachment
from app.models.message import Message
from app.models.request import Request
@@ -75,6 +77,7 @@ REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid
REQUEST_CALCULATED_FIELDS = {"invoice_amount", "paid_at", "paid_by_admin_id", "total_attachments_bytes"}
INVOICE_CALCULATED_FIELDS = {"issued_by_admin_user_id", "issued_by_role", "issued_at", "paid_at"}
ALLOWED_ADMIN_ROLES = {"ADMIN", "LAWYER"}
+ALLOWED_REQUEST_DATA_VALUE_TYPES = {"string", "text", "date", "number", "file"}
# Per-table RBAC: table -> role -> actions.
# If a table is missing here, fallback rules are used.
@@ -103,9 +106,12 @@ TABLE_ROLE_ACTIONS: dict[str, dict[str, set[str]]] = {
"otp_sessions": {"ADMIN": {"query", "read"}},
"admin_users": {"ADMIN": set(CRUD_ACTIONS)},
"admin_user_topics": {"ADMIN": set(CRUD_ACTIONS)},
+ "landing_featured_staff": {"ADMIN": set(CRUD_ACTIONS)},
"topic_status_transitions": {"ADMIN": set(CRUD_ACTIONS)},
"topic_required_fields": {"ADMIN": set(CRUD_ACTIONS)},
"topic_data_templates": {"ADMIN": set(CRUD_ACTIONS)},
+ "request_data_templates": {"ADMIN": set(CRUD_ACTIONS)},
+ "request_data_template_items": {"ADMIN": set(CRUD_ACTIONS)},
"request_data_requirements": {"ADMIN": set(CRUD_ACTIONS)},
"notifications": {"ADMIN": {"query", "read", "update"}},
}
@@ -264,10 +270,13 @@ def _table_label(table_name: str) -> str:
"clients": "Клиенты",
"table_availability": "Доступность таблиц",
"topic_required_fields": "Обязательные поля темы",
- "topic_data_templates": "Шаблоны данных темы",
+ "topic_data_templates": "Дополнительные данные",
+ "request_data_templates": "Шаблоны доп. данных",
+ "request_data_template_items": "Набор данных шаблона",
"topic_status_transitions": "Переходы статусов темы",
"admin_users": "Пользователи",
"admin_user_topics": "Дополнительные темы юристов",
+ "landing_featured_staff": "Карусель сотрудников лендинга",
"attachments": "Вложения",
"messages": "Сообщения",
"audit_log": "Журнал аудита",
@@ -355,8 +364,16 @@ def _column_label(table_name: str, column_name: str) -> str:
"key": "Ключ",
"name": "Название",
"label": "Метка",
+ "caption": "Подпись",
+ "value_type": "Тип значения",
+ "document_name": "Документ",
+ "request_data_template_id": "Шаблон",
+ "request_data_template_item_id": "Элемент шаблона",
"text": "Текст",
"description": "Описание",
+ "request_message_id": "ID сообщения запроса",
+ "field_type": "Тип поля",
+ "value_text": "Данные",
"author": "Автор",
"source": "Источник",
"email": "Email",
@@ -384,6 +401,7 @@ def _column_label(table_name: str, column_name: str) -> str:
"updated_at": "Дата обновления",
"responsible": "Ответственный",
"sort_order": "Порядок",
+ "pinned": "Закреплен",
"is_active": "Активен",
"enabled": "Активен",
"required": "Обязательное",
@@ -396,6 +414,7 @@ def _column_label(table_name: str, column_name: str) -> str:
"primary_topic_code": "Профильная тема",
"default_rate": "Ставка по умолчанию",
"effective_rate": "Ставка (фикс.)",
+ "request_cost": "Стоимость заявки",
"salary_percent": "Процент зарплаты",
"invoice_amount": "Сумма счета",
"paid_by_admin_id": "Оплату подтвердил",
@@ -468,12 +487,17 @@ def _reference_override(table_name: str, column_name: str) -> tuple[str, str] |
("topic_required_fields", "topic_code"): ("topics", "code"),
("topic_required_fields", "field_key"): ("form_fields", "key"),
("topic_data_templates", "topic_code"): ("topics", "code"),
+ ("request_data_templates", "topic_code"): ("topics", "code"),
+ ("request_data_templates", "created_by_admin_id"): ("admin_users", "id"),
+ ("request_data_template_items", "request_data_template_id"): ("request_data_templates", "id"),
+ ("request_data_template_items", "topic_data_template_id"): ("topic_data_templates", "id"),
("topic_status_transitions", "topic_code"): ("topics", "code"),
("topic_status_transitions", "from_status"): ("statuses", "code"),
("topic_status_transitions", "to_status"): ("statuses", "code"),
("admin_users", "primary_topic_code"): ("topics", "code"),
("admin_user_topics", "admin_user_id"): ("admin_users", "id"),
("admin_user_topics", "topic_code"): ("topics", "code"),
+ ("landing_featured_staff", "admin_user_id"): ("admin_users", "id"),
("request_data_requirements", "request_id"): ("requests", "id"),
("request_data_requirements", "topic_template_id"): ("topic_data_templates", "id"),
("request_data_requirements", "created_by_admin_id"): ("admin_users", "id"),
@@ -530,6 +554,8 @@ def _reference_label_field(table_name: str, value_field: str) -> str:
"status_groups": "name",
"form_fields": "label",
"topic_data_templates": "label",
+ "request_data_templates": "name",
+ "request_data_template_items": "label",
"invoices": "invoice_number",
"messages": "body",
"attachments": "file_name",
@@ -784,6 +810,8 @@ def _apply_admin_user_fields_for_create(payload: dict[str, Any]) -> dict[str, An
raise HTTPException(status_code=400, detail="Email обязателен")
data["email"] = email
data["role"] = role
+ if "phone" in data:
+ data["phone"] = _normalize_optional_string(_normalize_client_phone(data.get("phone")))
data["avatar_url"] = _normalize_optional_string(data.get("avatar_url"))
data["primary_topic_code"] = _normalize_optional_string(data.get("primary_topic_code"))
data["password_hash"] = hash_password(raw_password)
@@ -809,6 +837,8 @@ def _apply_admin_user_fields_for_update(payload: dict[str, Any]) -> dict[str, An
if not email:
raise HTTPException(status_code=400, detail="Email не может быть пустым")
data["email"] = email
+ if "phone" in data:
+ data["phone"] = _normalize_optional_string(_normalize_client_phone(data.get("phone")))
if "avatar_url" in data:
data["avatar_url"] = _normalize_optional_string(data.get("avatar_url"))
if "primary_topic_code" in data:
@@ -936,6 +966,88 @@ def _apply_topic_data_templates_fields(db: Session, payload: dict[str, Any]) ->
if not key:
raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым')
data["key"] = key
+ if "value_type" in data:
+ value_type = str(data.get("value_type") or "").strip().lower()
+ if value_type not in ALLOWED_REQUEST_DATA_VALUE_TYPES:
+ raise HTTPException(status_code=400, detail='Поле "value_type" должно быть одним из: string, text, date, number, file')
+ data["value_type"] = value_type
+ if "document_name" in data:
+ data["document_name"] = _normalize_optional_string(data.get("document_name"))
+ return data
+
+
+def _apply_request_data_templates_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
+ data = dict(payload)
+ if "topic_code" in data:
+ topic_code = str(data.get("topic_code") or "").strip()
+ if not topic_code:
+ raise HTTPException(status_code=400, detail='Поле "topic_code" не может быть пустым')
+ _ensure_topic_exists_or_400(db, topic_code)
+ data["topic_code"] = topic_code
+ if "name" in data:
+ name = str(data.get("name") or "").strip()
+ if not name:
+ raise HTTPException(status_code=400, detail='Поле "name" не может быть пустым')
+ data["name"] = name
+ if "description" in data:
+ data["description"] = _normalize_optional_string(data.get("description"))
+ if "created_by_admin_id" in data and data.get("created_by_admin_id") is not None:
+ admin_id = _parse_uuid_or_400(data.get("created_by_admin_id"), "created_by_admin_id")
+ admin_user = db.get(AdminUser, admin_id)
+ if admin_user is None:
+ raise HTTPException(status_code=400, detail="Пользователь не найден")
+ data["created_by_admin_id"] = admin_id
+ return data
+
+
+def _apply_request_data_template_items_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
+ data = dict(payload)
+ template = None
+ if "request_data_template_id" in data:
+ template_id = _parse_uuid_or_400(data.get("request_data_template_id"), "request_data_template_id")
+ template = db.get(RequestDataTemplate, template_id)
+ if template is None:
+ raise HTTPException(status_code=400, detail="Шаблон не найден")
+ data["request_data_template_id"] = template_id
+ if "topic_data_template_id" in data and data.get("topic_data_template_id") is not None:
+ catalog_id = _parse_uuid_or_400(data.get("topic_data_template_id"), "topic_data_template_id")
+ catalog = db.get(TopicDataTemplate, catalog_id)
+ if catalog is None:
+ raise HTTPException(status_code=400, detail="Поле доп. данных не найдено")
+ data["topic_data_template_id"] = catalog_id
+ if "key" not in data or not str(data.get("key") or "").strip():
+ data["key"] = str(catalog.key or "").strip()
+ if "label" not in data or not str(data.get("label") or "").strip():
+ data["label"] = str(catalog.label or catalog.key or "").strip()
+ if "value_type" not in data or not str(data.get("value_type") or "").strip():
+ data["value_type"] = str(catalog.value_type or "string")
+ if template is not None and str(template.topic_code or "").strip() and str(catalog.topic_code or "").strip():
+ if str(template.topic_code) != str(catalog.topic_code):
+ raise HTTPException(status_code=400, detail="Поле не соответствует теме шаблона")
+ if "key" in data:
+ key = str(data.get("key") or "").strip()
+ if not key:
+ raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым')
+ data["key"] = key[:80]
+ if "label" in data:
+ label = str(data.get("label") or "").strip()
+ if not label:
+ raise HTTPException(status_code=400, detail='Поле "label" не может быть пустым')
+ data["label"] = label
+ if "value_type" in data:
+ value_type = str(data.get("value_type") or "").strip().lower()
+ if value_type not in ALLOWED_REQUEST_DATA_VALUE_TYPES:
+ raise HTTPException(status_code=400, detail='Поле "value_type" должно быть одним из: string, text, date, number, file')
+ data["value_type"] = value_type
+ if "sort_order" in data:
+ raw = data.get("sort_order")
+ if raw is None or str(raw).strip() == "":
+ data["sort_order"] = 0
+ else:
+ try:
+ data["sort_order"] = int(raw)
+ except (TypeError, ValueError):
+ raise HTTPException(status_code=400, detail='Поле "sort_order" должно быть целым числом')
return data
@@ -953,11 +1065,31 @@ def _apply_request_data_requirements_fields(db: Session, payload: dict[str, Any]
if template is None:
raise HTTPException(status_code=400, detail="Шаблон темы не найден")
data["topic_template_id"] = template_id
+ if "request_message_id" in data and data.get("request_message_id") is not None:
+ data["request_message_id"] = _parse_uuid_or_400(data.get("request_message_id"), "request_message_id")
if "key" in data:
key = str(data.get("key") or "").strip()
if not key:
raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым')
data["key"] = key
+ if "field_type" in data:
+ field_type = str(data.get("field_type") or "").strip().lower()
+ if field_type not in ALLOWED_REQUEST_DATA_VALUE_TYPES:
+ raise HTTPException(status_code=400, detail='Поле "field_type" должно быть одним из: string, text, date, number, file')
+ data["field_type"] = field_type
+ if "document_name" in data:
+ data["document_name"] = _normalize_optional_string(data.get("document_name"))
+ if "value_text" in data:
+ data["value_text"] = _normalize_optional_string(data.get("value_text"))
+ if "sort_order" in data:
+ raw_sort = data.get("sort_order")
+ if raw_sort is None or str(raw_sort).strip() == "":
+ data["sort_order"] = 0
+ else:
+ try:
+ data["sort_order"] = int(raw_sort)
+ except (TypeError, ValueError):
+ raise HTTPException(status_code=400, detail='Поле "sort_order" должно быть целым числом')
return data
@@ -1416,7 +1548,20 @@ def get_row(
if normalized == "attachments" and isinstance(row, Attachment):
req = _request_for_related_row_or_404(db, row)
_ensure_lawyer_can_view_request_or_403(admin, req)
- return _strip_hidden_fields(normalized, _row_to_dict(row))
+ payload = _strip_hidden_fields(normalized, _row_to_dict(row))
+ if normalized == "requests" and isinstance(row, Request):
+ assigned_lawyer_id = str(row.assigned_lawyer_id or "").strip()
+ if assigned_lawyer_id:
+ try:
+ lawyer_uuid = uuid.UUID(assigned_lawyer_id)
+ except ValueError:
+ lawyer_uuid = None
+ if lawyer_uuid is not None:
+ lawyer = db.get(AdminUser, lawyer_uuid)
+ if lawyer is not None:
+ payload["assigned_lawyer_name"] = lawyer.name or lawyer.email or assigned_lawyer_id
+ payload["assigned_lawyer_phone"] = _serialize_value(getattr(lawyer, "phone", None))
+ return payload
@router.post("/{table_name}", status_code=201)
@@ -1490,6 +1635,10 @@ def create_row(
clean_payload = _apply_topic_required_fields_fields(db, clean_payload)
if normalized == "topic_data_templates":
clean_payload = _apply_topic_data_templates_fields(db, clean_payload)
+ if normalized == "request_data_templates":
+ clean_payload = _apply_request_data_templates_fields(db, clean_payload)
+ if normalized == "request_data_template_items":
+ clean_payload = _apply_request_data_template_items_fields(db, clean_payload)
if normalized == "request_data_requirements":
clean_payload = _apply_request_data_requirements_fields(db, clean_payload)
if normalized == "topic_status_transitions":
@@ -1557,6 +1706,10 @@ def update_row(
clean_payload = _apply_topic_required_fields_fields(db, clean_payload)
if normalized == "topic_data_templates":
clean_payload = _apply_topic_data_templates_fields(db, clean_payload)
+ if normalized == "request_data_templates":
+ clean_payload = _apply_request_data_templates_fields(db, clean_payload)
+ if normalized == "request_data_template_items":
+ clean_payload = _apply_request_data_template_items_fields(db, clean_payload)
if normalized == "request_data_requirements":
clean_payload = _apply_request_data_requirements_fields(db, clean_payload)
if normalized == "topic_status_transitions":
@@ -1603,23 +1756,9 @@ def update_row(
if normalized == "requests" and "status_code" in clean_payload:
before_status = str(before.get("status_code") or "")
after_status = str(clean_payload.get("status_code") or "")
- topic_code = str(before.get("topic_code") or "").strip() or None
- if not transition_allowed_for_topic(db, topic_code, before_status, after_status):
- raise HTTPException(
- status_code=400,
- detail="Переход статуса не разрешен для выбранной темы",
- )
if before_status != after_status and isinstance(row, Request):
- extra_fields_override = clean_payload.get("extra_fields")
- if not isinstance(extra_fields_override, dict):
- extra_fields_override = row.extra_fields if isinstance(row.extra_fields, dict) else None
- validate_transition_requirements_or_400(
- db,
- row,
- from_status=before_status,
- to_status=after_status,
- extra_fields_override=extra_fields_override,
- )
+ if "important_date_at" not in clean_payload or clean_payload.get("important_date_at") is None:
+ clean_payload["important_date_at"] = datetime.now(timezone.utc) + timedelta(days=3)
billing_note = apply_billing_transition_effects(
db,
req=row,
@@ -1635,6 +1774,7 @@ def update_row(
from_status=before_status,
to_status=after_status,
admin=admin,
+ important_date_at=clean_payload.get("important_date_at"),
responsible=responsible,
)
notify_request_event(
@@ -1643,7 +1783,15 @@ def update_row(
event_type=NOTIFICATION_EVENT_STATUS,
actor_role=_actor_role(admin),
actor_admin_user_id=admin.get("sub"),
- body=(f"{before_status} -> {after_status}" + (f"\n{billing_note}" if billing_note else "")),
+ body=(
+ f"{before_status} -> {after_status}"
+ + (
+ f"\nВажная дата: {clean_payload.get('important_date_at').isoformat()}"
+ if isinstance(clean_payload.get("important_date_at"), datetime)
+ else ""
+ )
+ + (f"\n{billing_note}" if billing_note else "")
+ ),
responsible=responsible,
)
for key, value in clean_payload.items():
diff --git a/app/api/admin/metrics.py b/app/api/admin/metrics.py
index 7483b97..a957c08 100644
--- a/app/api/admin/metrics.py
+++ b/app/api/admin/metrics.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from datetime import datetime, timezone
+from datetime import datetime, timedelta, timezone
from decimal import Decimal
from uuid import UUID
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from app.core.deps import require_role
from app.db.session import get_db
from app.models.admin_user import AdminUser
+from app.models.audit_log import AuditLog
from app.models.request import Request
from app.models.status import Status
from app.models.status_history import StatusHistory
@@ -59,6 +60,33 @@ def _uuid_or_none(value: str | None) -> UUID | None:
return None
+def _extract_assigned_lawyer_from_audit(diff: dict | None, action: str | None) -> str | None:
+ if not isinstance(diff, dict):
+ return None
+ action_code = str(action or "").upper()
+ if action_code == "MANUAL_CLAIM":
+ value = diff.get("assigned_lawyer_id")
+ return str(value).strip() if value else None
+ if action_code == "MANUAL_REASSIGN":
+ value = diff.get("to_lawyer_id")
+ return str(value).strip() if value else None
+ if action_code in {"CREATE", "UPDATE"}:
+ after = diff.get("after")
+ before = diff.get("before")
+ if action_code == "UPDATE":
+ if not isinstance(after, dict) or not isinstance(before, dict):
+ return None
+ prev_value = str(before.get("assigned_lawyer_id") or "").strip()
+ next_value = str(after.get("assigned_lawyer_id") or "").strip()
+ if not next_value or next_value == prev_value:
+ return None
+ return next_value
+ if isinstance(after, dict):
+ value = str(after.get("assigned_lawyer_id") or "").strip()
+ return value or None
+ return None
+
+
@router.get("/overview")
def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))):
role = str(admin.get("role") or "").upper()
@@ -123,6 +151,32 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
paid_events_map = {str(lawyer_id): int(events) for lawyer_id, events, _ in paid_rows if lawyer_id}
monthly_gross_map = {str(lawyer_id): _to_float(gross) for lawyer_id, _, gross in paid_rows if lawyer_id}
+ monthly_completed_rows = (
+ db.query(Request.assigned_lawyer_id, func.count(func.distinct(StatusHistory.request_id)))
+ .join(StatusHistory, StatusHistory.request_id == Request.id)
+ .filter(Request.assigned_lawyer_id.is_not(None))
+ .filter(StatusHistory.created_at >= month_start, StatusHistory.created_at < next_month_start)
+ .filter(func.upper(StatusHistory.to_status).in_({str(code).upper() for code in terminal_codes}))
+ .group_by(Request.assigned_lawyer_id)
+ .all()
+ )
+ monthly_completed_map = {str(lawyer_id): int(count) for lawyer_id, count in monthly_completed_rows if lawyer_id}
+
+ monthly_assigned_map: dict[str, int] = {}
+ audit_rows = (
+ db.query(AuditLog.action, AuditLog.diff)
+ .filter(AuditLog.entity == "requests")
+ .filter(AuditLog.created_at >= month_start, AuditLog.created_at < next_month_start)
+ .all()
+ )
+ for action, diff in audit_rows:
+ assigned_to = _extract_assigned_lawyer_from_audit(diff, action)
+ if not assigned_to:
+ continue
+ monthly_assigned_map[assigned_to] = int(monthly_assigned_map.get(assigned_to, 0)) + 1
+
+ monthly_revenue = round(sum(monthly_gross_map.values()), 2)
+
lawyers = (
db.query(AdminUser)
.filter(AdminUser.role == "LAWYER", AdminUser.is_active.is_(True))
@@ -146,6 +200,8 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
"active_load": active_load_map.get(lawyer_id, 0),
"total_assigned": total_load_map.get(lawyer_id, 0),
"active_amount": round(active_amount_map.get(lawyer_id, 0.0), 2),
+ "monthly_assigned_count": monthly_assigned_map.get(lawyer_id, 0),
+ "monthly_completed_count": monthly_completed_map.get(lawyer_id, 0),
"monthly_paid_events": paid_events_map.get(lawyer_id, 0),
"monthly_paid_gross": round(monthly_paid_gross, 2),
"monthly_salary": round(monthly_salary, 2),
@@ -221,6 +277,18 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
scoped_lawyer_loads = lawyer_loads
sla_snapshot = compute_sla_snapshot(db)
+ next_day_start = datetime(now_utc.year, now_utc.month, now_utc.day, tzinfo=timezone.utc) + timedelta(days=1)
+ deadline_alert_query = (
+ db.query(func.count(Request.id))
+ .filter(Request.important_date_at.is_not(None))
+ .filter(Request.important_date_at < next_day_start)
+ .filter(Request.status_code.notin_(terminal_codes))
+ )
+ if role == "LAWYER" and actor_uuid is not None:
+ deadline_alert_query = deadline_alert_query.filter(Request.assigned_lawyer_id == str(actor_uuid))
+ elif role == "LAWYER":
+ deadline_alert_query = deadline_alert_query.filter(Request.id.is_(None))
+ deadline_alert_total = int(deadline_alert_query.scalar() or 0)
return {
"scope": role if role in {"ADMIN", "LAWYER"} else "ADMIN",
"new": int(by_status.get("NEW", 0)),
@@ -230,6 +298,11 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
"unassigned_total": unassigned_total,
"my_unread_updates": my_unread_updates,
"my_unread_by_event": my_unread_by_event,
+ "deadline_alert_total": deadline_alert_total,
+ "month_revenue": monthly_revenue,
+ "month_expenses": round(sum(_to_float(row.get("monthly_salary")) for row in scoped_lawyer_loads), 2)
+ if role == "LAWYER"
+ else round(sum(_to_float(row.get("monthly_salary")) for row in lawyer_loads), 2),
"frt_avg_minutes": sla_snapshot.get("frt_avg_minutes"),
"sla_overdue": sla_snapshot.get("overdue_total", 0),
"overdue_by_status": sla_snapshot.get("overdue_by_status", {}),
@@ -239,3 +312,79 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
"unread_for_lawyers": int(unread_for_lawyers),
"lawyer_loads": scoped_lawyer_loads,
}
+
+
+@router.get("/lawyers/{lawyer_id}/active-requests")
+def lawyer_active_requests(
+ lawyer_id: str,
+ db: Session = Depends(get_db),
+ admin=Depends(require_role("ADMIN", "LAWYER")),
+):
+ actor_role = str(admin.get("role") or "").upper()
+ actor_id = str(admin.get("sub") or "").strip()
+ if actor_role == "LAWYER" and str(lawyer_id) != actor_id:
+ return {"rows": [], "total": 0, "totals": {"amount": 0.0, "salary": 0.0}}
+
+ lawyer = db.query(AdminUser).filter(AdminUser.id == _uuid_or_none(lawyer_id), AdminUser.role == "LAWYER").first()
+ if lawyer is None:
+ return {"rows": [], "total": 0, "totals": {"amount": 0.0, "salary": 0.0}}
+
+ terminal_codes = _terminal_status_codes(db)
+ paid_codes = _paid_status_codes()
+ now_utc = datetime.now(timezone.utc)
+ month_start, next_month_start = _month_bounds(now_utc)
+
+ salary_percent = _to_float(lawyer.salary_percent)
+ paid_by_request_rows = (
+ db.query(StatusHistory.request_id, func.count(StatusHistory.id))
+ .join(Request, Request.id == StatusHistory.request_id)
+ .filter(Request.assigned_lawyer_id == str(lawyer.id))
+ .filter(StatusHistory.created_at >= month_start, StatusHistory.created_at < next_month_start)
+ .filter(func.upper(StatusHistory.to_status).in_(paid_codes))
+ .group_by(StatusHistory.request_id)
+ .all()
+ )
+ paid_events_per_request = {str(req_id): int(count) for req_id, count in paid_by_request_rows if req_id}
+
+ request_rows = (
+ db.query(Request)
+ .filter(Request.assigned_lawyer_id == str(lawyer.id))
+ .filter(Request.status_code.notin_(terminal_codes))
+ .order_by(Request.created_at.desc(), Request.track_number.asc())
+ .all()
+ )
+
+ rows = []
+ total_amount = 0.0
+ total_salary = 0.0
+ for req in request_rows:
+ req_id = str(req.id)
+ invoice_amount = _to_float(req.invoice_amount)
+ paid_events = int(paid_events_per_request.get(req_id, 0))
+ month_paid_amount = round(invoice_amount * paid_events, 2)
+ month_salary_amount = round(month_paid_amount * salary_percent / 100.0, 2)
+ total_amount += month_paid_amount
+ total_salary += month_salary_amount
+ rows.append(
+ {
+ "id": req_id,
+ "track_number": req.track_number,
+ "status_code": req.status_code,
+ "client_name": req.client_name,
+ "invoice_amount": round(invoice_amount, 2),
+ "month_paid_events": paid_events,
+ "month_paid_amount": month_paid_amount,
+ "month_salary_amount": month_salary_amount,
+ "created_at": req.created_at.isoformat() if req.created_at else None,
+ "paid_at": req.paid_at.isoformat() if req.paid_at else None,
+ }
+ )
+
+ return {
+ "rows": rows,
+ "total": len(rows),
+ "totals": {
+ "amount": round(total_amount, 2),
+ "salary": round(total_salary, 2),
+ },
+ }
diff --git a/app/api/admin/requests.py b/app/api/admin/requests.py
index db84d7a..ec09a44 100644
--- a/app/api/admin/requests.py
+++ b/app/api/admin/requests.py
@@ -16,16 +16,17 @@ from app.schemas.admin import (
RequestDataRequirementCreate,
RequestDataRequirementPatch,
RequestReassign,
+ RequestStatusChange,
)
from app.models.admin_user import AdminUser
from app.models.audit_log import AuditLog
+from app.models.client import Client
from app.models.request_data_requirement import RequestDataRequirement
from app.models.request import Request
from app.models.status import Status
from app.models.status_group import StatusGroup
from app.models.status_history import StatusHistory
from app.models.topic_data_template import TopicDataTemplate
-from app.models.topic_status_transition import TopicStatusTransition
from app.services.notifications import (
EVENT_STATUS as NOTIFICATION_EVENT_STATUS,
mark_admin_notifications_read,
@@ -33,9 +34,8 @@ from app.services.notifications import (
)
from app.services.request_read_markers import EVENT_STATUS, clear_unread_for_lawyer, mark_unread_for_client
from app.services.request_status import actor_admin_uuid, apply_status_change_effects
-from app.services.status_flow import transition_allowed_for_topic
from app.services.request_templates import validate_required_topic_fields_or_400
-from app.services.status_transition_requirements import normalize_string_list, validate_transition_requirements_or_400
+from app.services.status_transition_requirements import normalize_string_list
from app.services.billing_flow import apply_billing_transition_effects
from app.services.universal_query import apply_universal_query
@@ -106,6 +106,149 @@ def _parse_datetime_safe(value: object) -> datetime | None:
return parsed
+def _normalize_important_date_or_default(raw: object, *, default_days: int = 3) -> datetime:
+ parsed = _parse_datetime_safe(raw)
+ if parsed:
+ return parsed
+ return datetime.now(timezone.utc) + timedelta(days=default_days)
+
+
+def _terminal_status_codes(db: Session) -> set[str]:
+ rows = db.query(Status.code).filter(Status.is_terminal.is_(True)).all()
+ codes = {str(code or "").strip() for (code,) in rows if str(code or "").strip()}
+ return codes or {"RESOLVED", "CLOSED", "REJECTED"}
+
+
+def _coerce_request_bool_filter_or_400(value: object) -> bool:
+ if isinstance(value, bool):
+ return value
+ text = str(value or "").strip().lower()
+ if text in {"1", "true", "yes", "y", "да"}:
+ return True
+ if text in {"0", "false", "no", "n", "нет"}:
+ return False
+ raise HTTPException(status_code=400, detail="Значение фильтра должно быть boolean")
+
+
+def _split_request_special_filters(uq: UniversalQuery) -> tuple[UniversalQuery, list[FilterClause]]:
+ filters = list(uq.filters or [])
+ special: list[FilterClause] = []
+ regular: list[FilterClause] = []
+ for clause in filters:
+ field = str(getattr(clause, "field", "") or "").strip()
+ if field in {"has_unread_updates", "deadline_alert"}:
+ special.append(clause)
+ else:
+ regular.append(clause)
+ return UniversalQuery(filters=regular, sort=list(uq.sort or []), page=uq.page), special
+
+
+def _apply_request_special_filters(
+ base_query,
+ *,
+ db: Session,
+ role: str,
+ actor_id: str,
+ special_filters: list[FilterClause],
+):
+ if not special_filters:
+ return base_query
+ terminal_codes_cache: set[str] | None = None
+ for clause in special_filters:
+ field = str(clause.field or "").strip()
+ op = str(clause.op or "").strip()
+ if op not in {"=", "!="}:
+ raise HTTPException(status_code=400, detail=f'Оператор "{op}" не поддерживается для фильтра "{field}"')
+ expected = _coerce_request_bool_filter_or_400(clause.value)
+ if field == "has_unread_updates":
+ if role == "LAWYER":
+ expr = Request.lawyer_has_unread_updates.is_(True)
+ else:
+ expr = or_(
+ Request.lawyer_has_unread_updates.is_(True),
+ Request.client_has_unread_updates.is_(True),
+ )
+ elif field == "deadline_alert":
+ now_utc = datetime.now(timezone.utc)
+ next_day_start = datetime(now_utc.year, now_utc.month, now_utc.day, tzinfo=timezone.utc) + timedelta(days=1)
+ if terminal_codes_cache is None:
+ terminal_codes_cache = _terminal_status_codes(db)
+ expr = (
+ Request.important_date_at.is_not(None)
+ & (Request.important_date_at < next_day_start)
+ & (Request.status_code.notin_(terminal_codes_cache))
+ )
+ if role == "LAWYER":
+ expr = expr & (Request.assigned_lawyer_id == actor_id)
+ else:
+ continue
+ base_query = base_query.filter(expr if expected else ~expr)
+ return base_query
+
+
+def _normalize_client_phone(value: object) -> str:
+ text = "".join(ch for ch in str(value or "") if ch.isdigit() or ch == "+")
+ if not text:
+ return ""
+ if text.startswith("8") and len(text) == 11:
+ text = "+7" + text[1:]
+ if not text.startswith("+") and text.isdigit():
+ text = "+" + text
+ return text
+
+
+def _client_uuid_or_none(value: object) -> UUID | None:
+ raw = str(value or "").strip()
+ if not raw:
+ return None
+ try:
+ return UUID(raw)
+ except ValueError:
+ raise HTTPException(status_code=400, detail='Некорректный "client_id"')
+
+
+def _client_for_request_payload_or_400(
+ db: Session,
+ *,
+ client_id: object,
+ client_name: object,
+ client_phone: object,
+ responsible: str,
+) -> Client:
+ client_uuid = _client_uuid_or_none(client_id)
+ if client_uuid is not None:
+ row = db.get(Client, client_uuid)
+ if row is None:
+ raise HTTPException(status_code=404, detail="Клиент не найден")
+ return row
+
+ normalized_phone = _normalize_client_phone(client_phone)
+ if not normalized_phone:
+ raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно')
+ normalized_name = str(client_name or "").strip() or "Клиент"
+
+ row = db.query(Client).filter(Client.phone == normalized_phone).first()
+ if row is None:
+ row = Client(
+ full_name=normalized_name,
+ phone=normalized_phone,
+ responsible=responsible,
+ )
+ db.add(row)
+ db.flush()
+ return row
+
+ changed = False
+ if normalized_name and row.full_name != normalized_name:
+ row.full_name = normalized_name
+ changed = True
+ if changed:
+ row.responsible = responsible
+ db.add(row)
+ db.flush()
+ return row
+
+
def _extract_case_deadline(extra_fields: object) -> datetime | None:
if not isinstance(extra_fields, dict):
return None
@@ -281,8 +424,8 @@ def _request_data_requirement_row(row: RequestDataRequirement) -> dict:
def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN","LAWYER"))):
base_query = db.query(Request)
role = str(admin.get("role") or "").upper()
+ actor = str(admin.get("sub") or "").strip()
if role == "LAWYER":
- actor = str(admin.get("sub") or "").strip()
if not actor:
raise HTTPException(status_code=401, detail="Некорректный токен")
base_query = base_query.filter(
@@ -292,7 +435,15 @@ def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depe
)
)
- q = apply_universal_query(base_query, Request, uq)
+ regular_uq, special_filters = _split_request_special_filters(uq)
+ base_query = _apply_request_special_filters(
+ base_query,
+ db=db,
+ role=role,
+ actor_id=actor,
+ special_filters=special_filters,
+ )
+ q = apply_universal_query(base_query, Request, regular_uq)
total = q.count()
rows = q.offset(uq.page.offset).limit(uq.page.limit).all()
return {
@@ -300,11 +451,14 @@ def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depe
{
"id": str(r.id),
"track_number": r.track_number,
+ "client_id": str(r.client_id) if r.client_id else None,
"status_code": r.status_code,
"client_name": r.client_name,
"client_phone": r.client_phone,
"topic_code": r.topic_code,
+ "important_date_at": r.important_date_at.isoformat() if r.important_date_at else None,
"effective_rate": float(r.effective_rate) if r.effective_rate is not None else None,
+ "request_cost": float(r.request_cost) if r.request_cost is not None else None,
"invoice_amount": float(r.invoice_amount) if r.invoice_amount is not None else None,
"paid_at": r.paid_at.isoformat() if r.paid_at else None,
"paid_by_admin_id": r.paid_by_admin_id,
@@ -360,33 +514,8 @@ def get_requests_kanban(
request_id_to_row = {str(row.id): row for row in request_rows}
request_ids = [row.id for row in request_rows]
- topic_codes = {str(row.topic_code or "").strip() for row in request_rows if str(row.topic_code or "").strip()}
status_codes = {str(row.status_code or "").strip() for row in request_rows if str(row.status_code or "").strip()}
- transition_rows: list[TopicStatusTransition] = []
- if topic_codes:
- transition_rows = (
- db.query(TopicStatusTransition)
- .filter(
- TopicStatusTransition.enabled.is_(True),
- TopicStatusTransition.topic_code.in_(list(topic_codes)),
- )
- .order_by(
- TopicStatusTransition.topic_code.asc(),
- TopicStatusTransition.from_status.asc(),
- TopicStatusTransition.sort_order.asc(),
- TopicStatusTransition.to_status.asc(),
- )
- .all()
- )
- for row in transition_rows:
- from_code = str(row.from_status or "").strip()
- to_code = str(row.to_status or "").strip()
- if from_code:
- status_codes.add(from_code)
- if to_code:
- status_codes.add(to_code)
-
status_meta_map: dict[str, dict[str, object]] = {}
if status_codes:
status_rows = (
@@ -448,16 +577,30 @@ def get_requests_kanban(
current_status_changed_at[request_id] = row.created_at
previous_status_by_request[request_id] = str(row.from_status or "").strip()
- transitions_by_key: dict[tuple[str, str], list[TopicStatusTransition]] = {}
- transitions_to_key: dict[tuple[str, str], list[TopicStatusTransition]] = {}
- for row in transition_rows:
- topic_code = str(row.topic_code or "").strip()
- from_status = str(row.from_status or "").strip()
- to_status = str(row.to_status or "").strip()
- if not topic_code or not from_status or not to_status:
+ all_enabled_status_rows = (
+ db.query(Status, StatusGroup)
+ .outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id)
+ .filter(Status.enabled.is_(True))
+ .order_by(Status.sort_order.asc(), Status.name.asc(), Status.code.asc())
+ .all()
+ )
+ all_enabled_statuses: list[dict[str, object]] = []
+ for status_row, group_row in all_enabled_status_rows:
+ code = str(status_row.code or "").strip()
+ if not code:
continue
- transitions_by_key.setdefault((topic_code, from_status), []).append(row)
- transitions_to_key.setdefault((topic_code, to_status), []).append(row)
+ meta = {
+ "code": code,
+ "name": str(status_row.name or code),
+ "kind": str(status_row.kind or "DEFAULT"),
+ "is_terminal": bool(status_row.is_terminal),
+ "status_group_id": str(status_row.status_group_id) if status_row.status_group_id else None,
+ "status_group_name": (str(group_row.name) if group_row is not None and group_row.name else None),
+ "status_group_order": (int(group_row.sort_order or 0) if group_row is not None else None),
+ "sort_order": int(status_row.sort_order or 0),
+ }
+ status_meta_map.setdefault(code, meta)
+ all_enabled_statuses.append(meta)
status_groups_rows = db.query(StatusGroup).order_by(StatusGroup.sort_order.asc(), StatusGroup.name.asc()).all()
columns_catalog = [
@@ -474,7 +617,6 @@ def get_requests_kanban(
group_totals: dict[str, int] = {row["key"]: 0 for row in columns_catalog}
for row in request_rows:
request_id = str(row.id)
- topic_code = str(row.topic_code or "").strip()
status_code = str(row.status_code or "").strip()
status_meta = _status_meta_or_default(status_meta_map, status_code)
status_group = str(status_meta.get("status_group_id") or "").strip()
@@ -496,9 +638,9 @@ def get_requests_kanban(
}
columns_catalog.append(columns_by_key[status_group])
available_transitions = []
- for transition in transitions_by_key.get((topic_code, status_code), []):
- to_status = str(transition.to_status or "").strip()
- if not to_status:
+ for status_def in all_enabled_statuses:
+ to_status = str(status_def.get("code") or "").strip()
+ if not to_status or to_status == status_code:
continue
to_meta = _status_meta_or_default(status_meta_map, to_status)
target_group = str(to_meta.get("status_group_id") or "").strip()
@@ -514,26 +656,12 @@ def get_requests_kanban(
"to_status": to_status,
"to_status_name": str(to_meta.get("name") or to_status),
"target_group": target_group,
- "sla_hours": transition.sla_hours,
+ "is_terminal": bool(to_meta.get("is_terminal")),
}
)
- case_deadline = _extract_case_deadline(row.extra_fields)
- entered_at = current_status_changed_at.get(request_id) or row.created_at
- previous_status = previous_status_by_request.get(request_id)
- transition_candidates = transitions_to_key.get((topic_code, status_code), [])
- matched_transition = None
- if previous_status:
- for transition in transition_candidates:
- if str(transition.from_status or "").strip() == previous_status:
- matched_transition = transition
- break
- if matched_transition is None and transition_candidates:
- matched_transition = transition_candidates[0]
-
+ case_deadline = row.important_date_at or _extract_case_deadline(row.extra_fields)
sla_deadline = None
- if entered_at and matched_transition and matched_transition.sla_hours is not None:
- sla_deadline = entered_at + timedelta(hours=int(matched_transition.sla_hours))
assigned_id = str(row.assigned_lawyer_id or "").strip() or None
items.append(
@@ -544,6 +672,7 @@ def get_requests_kanban(
"client_phone": row.client_phone,
"topic_code": row.topic_code,
"status_code": status_code,
+ "important_date_at": row.important_date_at.isoformat() if row.important_date_at else None,
"status_name": str(status_meta.get("name") or status_code),
"status_group": status_group,
"status_group_name": status_group_name or None,
@@ -618,6 +747,13 @@ def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), a
validate_required_topic_fields_or_400(db, payload.topic_code, payload.extra_fields)
track = payload.track_number or f"TRK-{uuid4().hex[:10].upper()}"
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
+ client = _client_for_request_payload_or_400(
+ db,
+ client_id=payload.client_id,
+ client_name=payload.client_name,
+ client_phone=payload.client_phone,
+ responsible=responsible,
+ )
assigned_lawyer_id = str(payload.assigned_lawyer_id or "").strip() or None
effective_rate = payload.effective_rate
if assigned_lawyer_id:
@@ -627,14 +763,17 @@ def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), a
effective_rate = assigned_lawyer.default_rate
row = Request(
track_number=track,
- client_name=payload.client_name,
- client_phone=payload.client_phone,
+ client_id=client.id,
+ client_name=client.full_name,
+ client_phone=client.phone,
topic_code=payload.topic_code,
status_code=payload.status_code,
+ important_date_at=payload.important_date_at,
description=payload.description,
extra_fields=payload.extra_fields,
assigned_lawyer_id=assigned_lawyer_id,
effective_rate=effective_rate,
+ request_cost=payload.request_cost,
invoice_amount=payload.invoice_amount,
paid_at=payload.paid_at,
paid_by_admin_id=payload.paid_by_admin_id,
@@ -682,30 +821,32 @@ def update_request(
changes["effective_rate"] = assigned_lawyer.default_rate
old_status = str(row.status_code or "")
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
+ if {"client_id", "client_name", "client_phone"}.intersection(set(changes.keys())):
+ client = _client_for_request_payload_or_400(
+ db,
+ client_id=changes.get("client_id", row.client_id),
+ client_name=changes.get("client_name", row.client_name),
+ client_phone=changes.get("client_phone", row.client_phone),
+ responsible=responsible,
+ )
+ changes["client_id"] = client.id
+ changes["client_name"] = client.full_name
+ changes["client_phone"] = client.phone
+ status_changed = "status_code" in changes and str(changes.get("status_code") or "") != old_status
+ if status_changed and ("important_date_at" not in changes or changes.get("important_date_at") is None):
+ changes["important_date_at"] = _normalize_important_date_or_default(None)
for key, value in changes.items():
setattr(row, key, value)
- if "status_code" in changes and str(changes.get("status_code") or "") != old_status:
+ if status_changed:
next_status = str(changes.get("status_code") or "")
- if not transition_allowed_for_topic(
- db,
- str(row.topic_code or "").strip() or None,
- old_status,
- next_status,
- ):
- raise HTTPException(status_code=400, detail="Переход статуса не разрешен для выбранной темы")
- validate_transition_requirements_or_400(
- db,
- row,
- from_status=old_status,
- to_status=next_status,
- extra_fields_override=row.extra_fields if isinstance(row.extra_fields, dict) else None,
- )
+ important_date_at = row.important_date_at
billing_note = apply_billing_transition_effects(
db,
req=row,
from_status=old_status,
to_status=next_status,
admin=admin,
+ important_date_at=important_date_at,
responsible=responsible,
)
mark_unread_for_client(row, EVENT_STATUS)
@@ -723,7 +864,11 @@ def update_request(
event_type=NOTIFICATION_EVENT_STATUS,
actor_role=str(admin.get("role") or "").upper() or "ADMIN",
actor_admin_user_id=admin.get("sub"),
- body=(f"{old_status} -> {next_status}" + (f"\n{billing_note}" if billing_note else "")),
+ body=(
+ f"{old_status} -> {next_status}"
+ + (f"\nВажная дата: {important_date_at.isoformat()}" if important_date_at else "")
+ + (f"\n{billing_note}" if billing_note else "")
+ ),
responsible=responsible,
)
try:
@@ -772,14 +917,17 @@ def get_request(request_id: str, db: Session = Depends(get_db), admin=Depends(re
return {
"id": str(req.id),
"track_number": req.track_number,
+ "client_id": str(req.client_id) if req.client_id else None,
"client_name": req.client_name,
"client_phone": req.client_phone,
"topic_code": req.topic_code,
"status_code": req.status_code,
+ "important_date_at": req.important_date_at.isoformat() if req.important_date_at else None,
"description": req.description,
"extra_fields": req.extra_fields,
"assigned_lawyer_id": req.assigned_lawyer_id,
"effective_rate": float(req.effective_rate) if req.effective_rate is not None else None,
+ "request_cost": float(req.request_cost) if req.request_cost is not None else None,
"invoice_amount": float(req.invoice_amount) if req.invoice_amount is not None else None,
"paid_at": req.paid_at.isoformat() if req.paid_at else None,
"paid_by_admin_id": req.paid_by_admin_id,
@@ -793,6 +941,86 @@ def get_request(request_id: str, db: Session = Depends(get_db), admin=Depends(re
}
+@router.post("/{request_id}/status-change")
+def change_request_status(
+ request_id: str,
+ payload: RequestStatusChange,
+ db: Session = Depends(get_db),
+ admin=Depends(require_role("ADMIN", "LAWYER")),
+):
+ request_uuid = _request_uuid_or_400(request_id)
+ req = db.get(Request, request_uuid)
+ if not req:
+ raise HTTPException(status_code=404, detail="Заявка не найдена")
+ _ensure_lawyer_can_manage_request_or_403(admin, req)
+
+ next_status = str(payload.status_code or "").strip()
+ if not next_status:
+ raise HTTPException(status_code=400, detail='Поле "status_code" обязательно')
+
+ status_row = db.query(Status).filter(Status.code == next_status, Status.enabled.is_(True)).first()
+ if status_row is None:
+ raise HTTPException(status_code=400, detail="Указан несуществующий или неактивный статус")
+
+ old_status = str(req.status_code or "").strip()
+ if old_status == next_status:
+ raise HTTPException(status_code=400, detail="Выберите новый статус")
+
+ important_date_at = _normalize_important_date_or_default(payload.important_date_at)
+ comment = str(payload.comment or "").strip() or None
+ responsible = str(admin.get("email") or "").strip() or "Администратор системы"
+
+ req.status_code = next_status
+ req.important_date_at = important_date_at
+ req.responsible = responsible
+
+ billing_note = apply_billing_transition_effects(
+ db,
+ req=req,
+ from_status=old_status,
+ to_status=next_status,
+ admin=admin,
+ responsible=responsible,
+ )
+ mark_unread_for_client(req, EVENT_STATUS)
+ apply_status_change_effects(
+ db,
+ req,
+ from_status=old_status,
+ to_status=next_status,
+ admin=admin,
+ comment=comment,
+ important_date_at=important_date_at,
+ responsible=responsible,
+ )
+ notify_request_event(
+ db,
+ request=req,
+ event_type=NOTIFICATION_EVENT_STATUS,
+ actor_role=str(admin.get("role") or "").upper() or "ADMIN",
+ actor_admin_user_id=admin.get("sub"),
+ body=(
+ f"{old_status} -> {next_status}"
+ + f"\nВажная дата: {important_date_at.isoformat()}"
+ + (f"\n{comment}" if comment else "")
+ + (f"\n{billing_note}" if billing_note else "")
+ ),
+ responsible=responsible,
+ )
+
+ db.add(req)
+ db.commit()
+ db.refresh(req)
+ return {
+ "status": "ok",
+ "request_id": str(req.id),
+ "track_number": req.track_number,
+ "from_status": old_status or None,
+ "to_status": next_status,
+ "important_date_at": req.important_date_at.isoformat() if req.important_date_at else None,
+ }
+
+
@router.get("/{request_id}/status-route")
def get_request_status_route(
request_id: str,
@@ -808,22 +1036,6 @@ def get_request_status_route(
topic_code = str(req.topic_code or "").strip()
current_status = str(req.status_code or "").strip()
- transitions: list[TopicStatusTransition] = []
- if topic_code:
- transitions = (
- db.query(TopicStatusTransition)
- .filter(
- TopicStatusTransition.topic_code == topic_code,
- TopicStatusTransition.enabled.is_(True),
- )
- .order_by(
- TopicStatusTransition.sort_order.asc(),
- TopicStatusTransition.from_status.asc(),
- TopicStatusTransition.to_status.asc(),
- )
- .all()
- )
-
history_rows = (
db.query(StatusHistory)
.filter(StatusHistory.request_id == req.id)
@@ -841,23 +1053,28 @@ def get_request_status_route(
known_codes.add(from_code)
if to_code:
known_codes.add(to_code)
- for row in transitions:
- from_code = str(row.from_status or "").strip()
- to_code = str(row.to_status or "").strip()
- if from_code:
- known_codes.add(from_code)
- if to_code:
- known_codes.add(to_code)
-
statuses_map: dict[str, dict[str, str]] = {}
+ all_enabled_status_rows = db.query(Status, StatusGroup).outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id).filter(Status.enabled.is_(True)).all()
+ for status_row, _group_row in all_enabled_status_rows:
+ code = str(status_row.code or "").strip()
+ if code:
+ known_codes.add(code)
if known_codes:
- status_rows = db.query(Status).filter(Status.code.in_(list(known_codes))).all()
+ status_rows = (
+ db.query(Status, StatusGroup)
+ .outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id)
+ .filter(Status.code.in_(list(known_codes)))
+ .all()
+ )
statuses_map = {
- str(row.code): {
- "name": str(row.name or row.code),
- "kind": str(row.kind or "DEFAULT"),
+ str(status_row.code): {
+ "name": str(status_row.name or status_row.code),
+ "kind": str(status_row.kind or "DEFAULT"),
+ "is_terminal": bool(status_row.is_terminal),
+ "status_group_id": str(status_row.status_group_id) if status_row.status_group_id else None,
+ "status_group_name": (str(group_row.name) if group_row is not None and group_row.name else None),
}
- for row in status_rows
+ for status_row, group_row in status_rows
}
sequence_from_history: list[str] = []
@@ -885,27 +1102,8 @@ def get_request_status_route(
for code in sequence_from_history:
add_code(code)
- for row in transitions:
- add_code(str(row.from_status or ""))
- add_code(str(row.to_status or ""))
-
add_code(current_status)
- transition_by_to_status: dict[str, dict[str, object]] = {}
- for row in transitions:
- to_code = str(row.to_status or "").strip()
- if not to_code:
- continue
- current = transition_by_to_status.get(to_code)
- if current is None or int(row.sort_order or 0) < int(current.get("sort_order") or 0):
- transition_by_to_status[to_code] = {
- "from_status": str(row.from_status or "").strip() or None,
- "sla_hours": row.sla_hours,
- "required_data_keys": normalize_string_list(row.required_data_keys),
- "required_mime_types": normalize_string_list(row.required_mime_types),
- "sort_order": int(row.sort_order or 0),
- }
-
changed_at_by_status: dict[str, str] = {}
for row in history_rows:
to_code = str(row.to_status or "").strip()
@@ -922,7 +1120,6 @@ def get_request_status_route(
nodes: list[dict[str, str | int | None]] = []
for index, code in enumerate(ordered_codes):
meta = statuses_map.get(code) or {}
- transition_meta = transition_by_to_status.get(code) or {}
state = "pending"
if code == current_status:
state = "current"
@@ -930,18 +1127,6 @@ def get_request_status_route(
state = "completed"
note_parts: list[str] = []
- from_status = transition_meta.get("from_status")
- if from_status:
- note_parts.append(f"Переход из статуса «{status_name(str(from_status))}»")
- sla_hours = transition_meta.get("sla_hours")
- if sla_hours is not None:
- note_parts.append(f"SLA: {sla_hours} ч")
- required_data_keys = transition_meta.get("required_data_keys") or []
- if required_data_keys:
- note_parts.append("Данные: " + ", ".join(str(item) for item in required_data_keys))
- required_mime_types = transition_meta.get("required_mime_types") or []
- if required_mime_types:
- note_parts.append("Файлы: " + ", ".join(str(item) for item in required_mime_types))
kind = str(meta.get("kind") or "DEFAULT")
if kind == "INVOICE":
note_parts.append("Этап выставления счета")
@@ -954,19 +1139,88 @@ def get_request_status_route(
"name": status_name(code),
"kind": kind,
"state": state,
- "sla_hours": sla_hours,
- "required_data_keys": required_data_keys,
- "required_mime_types": required_mime_types,
"changed_at": changed_at_by_status.get(code),
"note": " • ".join(note_parts),
}
)
+ history_entries: list[dict[str, object]] = []
+ timeline: list[dict[str, object]] = []
+ for row in history_rows:
+ timeline.append(
+ {
+ "id": str(row.id),
+ "from_status": str(row.from_status or "").strip() or None,
+ "to_status": str(row.to_status or "").strip() or None,
+ "to_status_name": status_name(str(row.to_status or "").strip()) if str(row.to_status or "").strip() else None,
+ "created_at": row.created_at,
+ "important_date_at": row.important_date_at,
+ "comment": row.comment,
+ }
+ )
+ if not timeline:
+ timeline.append(
+ {
+ "id": "current",
+ "from_status": None,
+ "to_status": current_status or None,
+ "to_status_name": status_name(current_status) if current_status else None,
+ "created_at": req.updated_at or req.created_at,
+ "important_date_at": req.important_date_at,
+ "comment": None,
+ }
+ )
+ for index, item in enumerate(timeline):
+ current_at = item.get("created_at")
+ next_at = timeline[index + 1].get("created_at") if index + 1 < len(timeline) else datetime.now(timezone.utc)
+ duration_seconds = None
+ if isinstance(current_at, datetime) and isinstance(next_at, datetime):
+ delta = next_at - current_at
+ duration_seconds = max(0, int(delta.total_seconds()))
+ history_entries.append(
+ {
+ "id": item.get("id"),
+ "from_status": item.get("from_status"),
+ "to_status": item.get("to_status"),
+ "to_status_name": item.get("to_status_name"),
+ "changed_at": current_at.isoformat() if isinstance(current_at, datetime) else None,
+ "important_date_at": item.get("important_date_at").isoformat() if isinstance(item.get("important_date_at"), datetime) else None,
+ "comment": item.get("comment"),
+ "duration_seconds": duration_seconds,
+ }
+ )
+
+ available_statuses: list[dict[str, object]] = []
+ for status_row, group_row in sorted(
+ all_enabled_status_rows,
+ key=lambda pair: (
+ int(pair[1].sort_order or 0) if pair[1] is not None else 999,
+ int(pair[0].sort_order or 0),
+ str(pair[0].name or pair[0].code).lower(),
+ ),
+ ):
+ code = str(status_row.code or "").strip()
+ if not code:
+ continue
+ available_statuses.append(
+ {
+ "code": code,
+ "name": str(status_row.name or code),
+ "kind": str(status_row.kind or "DEFAULT"),
+ "is_terminal": bool(status_row.is_terminal),
+ "status_group_id": str(status_row.status_group_id) if status_row.status_group_id else None,
+ "status_group_name": (str(group_row.name) if group_row is not None and group_row.name else None),
+ }
+ )
+
return {
"request_id": str(req.id),
"track_number": req.track_number,
"topic_code": req.topic_code,
"current_status": current_status or None,
+ "current_important_date_at": req.important_date_at.isoformat() if req.important_date_at else None,
+ "available_statuses": available_statuses,
+ "history": list(reversed(history_entries)),
"nodes": nodes,
}
diff --git a/app/api/admin/router.py b/app/api/admin/router.py
index 12f4089..4e1ea7f 100644
--- a/app/api/admin/router.py
+++ b/app/api/admin/router.py
@@ -1,5 +1,5 @@
from fastapi import APIRouter
-from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics, crud, notifications, invoices, chat
+from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics, crud, notifications, invoices, chat, test_utils
router = APIRouter()
router.include_router(auth.router, prefix="/auth", tags=["AdminAuth"])
@@ -13,3 +13,4 @@ router.include_router(notifications.router, prefix="/notifications", tags=["Admi
router.include_router(invoices.router, prefix="/invoices", tags=["AdminInvoices"])
router.include_router(chat.router, prefix="/chat", tags=["AdminChat"])
router.include_router(crud.router, prefix="/crud", tags=["AdminCrud"])
+router.include_router(test_utils.router, prefix="/test-utils", tags=["AdminTestUtils"])
diff --git a/app/api/admin/test_utils.py b/app/api/admin/test_utils.py
new file mode 100644
index 0000000..8247f0f
--- /dev/null
+++ b/app/api/admin/test_utils.py
@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel, Field
+from sqlalchemy.orm import Session
+
+from app.core.config import settings
+from app.core.deps import require_role
+from app.db.session import get_db
+from app.services.test_data_cleanup import CleanupSpec, cleanup_test_data
+
+
+router = APIRouter()
+
+
+class TestDataCleanupPayload(BaseModel):
+ track_numbers: list[str] = Field(default_factory=list)
+ phones: list[str] = Field(default_factory=list)
+ emails: list[str] = Field(default_factory=list)
+ include_default_e2e_patterns: bool = True
+
+
+def _guard_local_only() -> None:
+ env = str(settings.APP_ENV or "").strip().lower()
+ if env in {"prod", "production"}:
+ raise HTTPException(status_code=404, detail="Not found")
+
+
+@router.post("/cleanup-test-data")
+def cleanup_test_data_endpoint(
+ payload: TestDataCleanupPayload,
+ db: Session = Depends(get_db),
+ admin: dict[str, Any] = Depends(require_role("ADMIN")),
+):
+ _guard_local_only()
+ counts = cleanup_test_data(
+ db,
+ CleanupSpec(
+ track_numbers=payload.track_numbers,
+ phones=payload.phones,
+ emails=payload.emails,
+ include_default_e2e_patterns=bool(payload.include_default_e2e_patterns),
+ ),
+ )
+ return {
+ "status": "ok",
+ "environment": settings.APP_ENV,
+ "requested_by": str(admin.get("email") or admin.get("sub") or ""),
+ "deleted": counts,
+ }
diff --git a/app/api/public/chat.py b/app/api/public/chat.py
index a4ae402..68d8367 100644
--- a/app/api/public/chat.py
+++ b/app/api/public/chat.py
@@ -1,17 +1,42 @@
from __future__ import annotations
+from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.deps import get_public_session
from app.db.session import get_db
+from app.models.attachment import Attachment
+from app.models.message import Message
from app.models.request import Request
+from app.models.request_data_requirement import RequestDataRequirement
from app.schemas.public import PublicMessageCreate
-from app.services.chat_service import create_client_message, list_messages_for_request, serialize_message
+from app.services.chat_service import create_client_message, list_messages_for_request, serialize_message, serialize_messages_for_request
+from app.services.request_read_markers import EVENT_MESSAGE, mark_unread_for_lawyer
router = APIRouter()
+def _attachment_meta_for_public(req: Request, value_text: str | None, db: Session) -> dict | None:
+ raw = str(value_text or "").strip()
+ if not raw:
+ return None
+ try:
+ attachment_uuid = UUID(raw)
+ except ValueError:
+ return None
+ attachment = db.get(Attachment, attachment_uuid)
+ if attachment is None or attachment.request_id != req.id:
+ return None
+ return {
+ "attachment_id": str(attachment.id),
+ "file_name": attachment.file_name,
+ "mime_type": attachment.mime_type,
+ "size_bytes": int(attachment.size_bytes or 0),
+ "download_url": f"/api/public/uploads/object/{attachment.id}",
+ }
+
+
def _normalize_phone(raw: str | None) -> str:
value = str(raw or "").strip()
if not value:
@@ -60,7 +85,7 @@ def list_messages_by_track(
req = _request_for_track_or_404(db, track_number)
_ensure_view_access_or_403(session, req)
rows = list_messages_for_request(db, req.id)
- return [serialize_message(row) for row in rows]
+ return serialize_messages_for_request(db, req.id, rows)
@router.post("/requests/{track_number}/messages", status_code=201)
@@ -74,3 +99,131 @@ def create_message_by_track(
_ensure_view_access_or_403(session, req)
row = create_client_message(db, request=req, body=payload.body)
return serialize_message(row)
+
+
+@router.get("/requests/{track_number}/data-requests/{message_id}")
+def get_data_request_by_message(
+ track_number: str,
+ message_id: str,
+ db: Session = Depends(get_db),
+ session: dict = Depends(get_public_session),
+):
+ req = _request_for_track_or_404(db, track_number)
+ _ensure_view_access_or_403(session, req)
+ try:
+ message_uuid = UUID(str(message_id))
+ except ValueError:
+ raise HTTPException(status_code=400, detail="Некорректный идентификатор сообщения")
+ message = db.get(Message, message_uuid)
+ if message is None or message.request_id != req.id:
+ raise HTTPException(status_code=404, detail="Сообщение запроса не найдено")
+ rows = (
+ db.query(RequestDataRequirement)
+ .filter(
+ RequestDataRequirement.request_id == req.id,
+ RequestDataRequirement.request_message_id == message_uuid,
+ )
+ .order_by(RequestDataRequirement.sort_order.asc(), RequestDataRequirement.created_at.asc(), RequestDataRequirement.id.asc())
+ .all()
+ )
+ if not rows:
+ raise HTTPException(status_code=404, detail="Запрос данных не найден")
+ return {
+ "message_id": str(message.id),
+ "request_id": str(req.id),
+ "track_number": req.track_number,
+ "items": [
+ {
+ "id": str(row.id),
+ "key": row.key,
+ "label": row.label,
+ "field_type": str(row.field_type or "text"),
+ "value_text": row.value_text,
+ "value_file": _attachment_meta_for_public(req, row.value_text, db) if str(row.field_type or "").lower() == "file" else None,
+ "is_filled": bool(str(row.value_text or "").strip()),
+ "sort_order": int(row.sort_order or 0),
+ }
+ for row in rows
+ ],
+ }
+
+
+@router.post("/requests/{track_number}/data-requests/{message_id}")
+def save_data_request_values(
+ track_number: str,
+ message_id: str,
+ payload: dict,
+ db: Session = Depends(get_db),
+ session: dict = Depends(get_public_session),
+):
+ req = _request_for_track_or_404(db, track_number)
+ _ensure_view_access_or_403(session, req)
+ try:
+ message_uuid = UUID(str(message_id))
+ except ValueError:
+ raise HTTPException(status_code=400, detail="Некорректный идентификатор сообщения")
+ message = db.get(Message, message_uuid)
+ if message is None or message.request_id != req.id:
+ raise HTTPException(status_code=404, detail="Сообщение запроса не найдено")
+
+ raw_items = (payload or {}).get("items")
+ if not isinstance(raw_items, list):
+ raise HTTPException(status_code=400, detail="Ожидается список items")
+
+ rows = (
+ db.query(RequestDataRequirement)
+ .filter(
+ RequestDataRequirement.request_id == req.id,
+ RequestDataRequirement.request_message_id == message_uuid,
+ )
+ .all()
+ )
+ by_id = {str(row.id): row for row in rows}
+ by_key = {str(row.key): row for row in rows}
+ updated = 0
+ for item in raw_items:
+ if not isinstance(item, dict):
+ continue
+ target = None
+ raw_id = str(item.get("id") or "").strip()
+ if raw_id:
+ target = by_id.get(raw_id)
+ if target is None:
+ raw_key = str(item.get("key") or "").strip()
+ if raw_key:
+ target = by_key.get(raw_key)
+ if target is None:
+ continue
+ if str(target.field_type or "").lower() == "file":
+ attachment_id_raw = str(item.get("attachment_id") or item.get("value_text") or "").strip()
+ if attachment_id_raw:
+ try:
+ attachment_uuid = UUID(attachment_id_raw)
+ except ValueError:
+ raise HTTPException(status_code=400, detail="Некорректный attachment_id для файла")
+ attachment = db.get(Attachment, attachment_uuid)
+ if attachment is None or attachment.request_id != req.id:
+ raise HTTPException(status_code=400, detail="Файл для поля не найден или недоступен")
+ normalized = str(attachment.id)
+ else:
+ normalized = ""
+ else:
+ value_text = item.get("value_text")
+ normalized = str(value_text).strip() if value_text is not None else ""
+ target.value_text = normalized or None
+ target.responsible = "Клиент"
+ db.add(target)
+ updated += 1
+
+ if updated:
+ mark_unread_for_lawyer(req, EVENT_MESSAGE)
+ req.responsible = "Клиент"
+ db.add(req)
+ db.commit()
+ else:
+ db.rollback()
+
+ messages = list_messages_for_request(db, req.id)
+ serialized = serialize_messages_for_request(db, req.id, messages)
+ current = next((item for item in serialized if str(item.get("id")) == str(message_uuid)), None)
+ return {"updated": updated, "message": current}
diff --git a/app/api/public/featured_staff.py b/app/api/public/featured_staff.py
new file mode 100644
index 0000000..24d533e
--- /dev/null
+++ b/app/api/public/featured_staff.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy import and_
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.models.admin_user import AdminUser
+from app.models.landing_featured_staff import LandingFeaturedStaff
+from app.models.topic import Topic
+
+router = APIRouter()
+
+
+@router.get("")
+def list_featured_staff(
+ limit: int = Query(20, ge=1, le=100),
+ db: Session = Depends(get_db),
+):
+ topic_names = {
+ str(row.code): str(row.name)
+ for row in db.query(Topic).filter(Topic.enabled.is_(True)).all()
+ }
+
+ rows = (
+ db.query(LandingFeaturedStaff, AdminUser)
+ .join(AdminUser, AdminUser.id == LandingFeaturedStaff.admin_user_id)
+ .filter(
+ LandingFeaturedStaff.enabled.is_(True),
+ AdminUser.is_active.is_(True),
+ AdminUser.role.in_(("ADMIN", "LAWYER")),
+ AdminUser.avatar_url.is_not(None),
+ and_(AdminUser.avatar_url != ""),
+ )
+ .order_by(
+ LandingFeaturedStaff.pinned.desc(),
+ LandingFeaturedStaff.sort_order.asc(),
+ LandingFeaturedStaff.created_at.asc(),
+ )
+ .limit(limit)
+ .all()
+ )
+
+ result = []
+ for slot, user in rows:
+ role_code = str(user.role or "").upper()
+ role_label = "Администратор" if role_code == "ADMIN" else "Юрист"
+ primary_topic_code = str(user.primary_topic_code or "").strip() or None
+ result.append(
+ {
+ "id": str(slot.id),
+ "admin_user_id": str(user.id),
+ "name": user.name,
+ "role": role_code,
+ "role_label": role_label,
+ "avatar_url": user.avatar_url,
+ "caption": str(slot.caption or "").strip() or None,
+ "pinned": bool(slot.pinned),
+ "sort_order": int(slot.sort_order or 0),
+ "primary_topic_code": primary_topic_code,
+ "primary_topic_name": topic_names.get(primary_topic_code or "", primary_topic_code),
+ }
+ )
+ return {"items": result, "total": len(result)}
diff --git a/app/api/public/router.py b/app/api/public/router.py
index 60f48e0..f038e03 100644
--- a/app/api/public/router.py
+++ b/app/api/public/router.py
@@ -1,9 +1,10 @@
from fastapi import APIRouter
-from app.api.public import requests, otp, quotes, uploads, chat
+from app.api.public import requests, otp, quotes, uploads, chat, featured_staff
router = APIRouter()
router.include_router(requests.router, prefix="/requests", tags=["Public"])
router.include_router(otp.router, prefix="/otp", tags=["Public"])
router.include_router(quotes.router, prefix="/quotes", tags=["Public"])
+router.include_router(featured_staff.router, prefix="/featured-staff", tags=["Public"])
router.include_router(uploads.router, prefix="/uploads", tags=["PublicFiles"])
router.include_router(chat.router, prefix="/chat", tags=["PublicChat"])
diff --git a/app/core/http_hardening.py b/app/core/http_hardening.py
index 7f45274..4a82cf0 100644
--- a/app/core/http_hardening.py
+++ b/app/core/http_hardening.py
@@ -23,6 +23,19 @@ SECURITY_HEADERS = {
"Content-Security-Policy": "default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
}
+FRAMEABLE_FILE_SECURITY_HEADERS = {
+ **SECURITY_HEADERS,
+ "X-Frame-Options": "SAMEORIGIN",
+ "Content-Security-Policy": "default-src 'self'; object-src 'none'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'",
+}
+
+_FRAMEABLE_PATH_PATTERNS = (
+ re.compile(r"^/api/public/uploads/object/"),
+ re.compile(r"^/api/admin/uploads/object/"),
+ re.compile(r"^/api/public/requests/[^/]+/invoices/[^/]+/pdf$"),
+ re.compile(r"^/api/admin/invoices/[^/]+/pdf$"),
+)
+
def _request_id_from_header(raw: str | None) -> str:
value = str(raw or "").strip()
@@ -33,6 +46,14 @@ def _request_id_from_header(raw: str | None) -> str:
return value
+def _response_security_headers(request: Request) -> dict[str, str]:
+ method = str(request.method or "").upper()
+ path = str(request.url.path or "")
+ if method in {"GET", "HEAD"} and any(pattern.search(path) for pattern in _FRAMEABLE_PATH_PATTERNS):
+ return FRAMEABLE_FILE_SECURITY_HEADERS
+ return SECURITY_HEADERS
+
+
def install_http_hardening(app: FastAPI) -> None:
@app.middleware("http")
async def _http_hardening_middleware(request: Request, call_next):
@@ -42,7 +63,7 @@ def install_http_hardening(app: FastAPI) -> None:
response = await call_next(request)
- for key, value in SECURITY_HEADERS.items():
+ for key, value in _response_security_headers(request).items():
response.headers[key] = value
# Backend serves application data and operational endpoints only.
# Keep responses non-cacheable to avoid stale or sensitive data reuse.
diff --git a/app/data/cleanup_test_artifacts.py b/app/data/cleanup_test_artifacts.py
new file mode 100644
index 0000000..656197d
--- /dev/null
+++ b/app/data/cleanup_test_artifacts.py
@@ -0,0 +1,38 @@
+from __future__ import annotations
+
+import argparse
+
+from app.db.session import SessionLocal
+from app.services.test_data_cleanup import CleanupSpec, cleanup_test_data
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Cleanup E2E / test artifacts from development DB")
+ parser.add_argument("--track", action="append", dest="tracks", default=[], help="Track number to cleanup (repeatable)")
+ parser.add_argument("--phone", action="append", dest="phones", default=[], help="Phone to cleanup (repeatable)")
+ parser.add_argument("--email", action="append", dest="emails", default=[], help="Email to cleanup (repeatable)")
+ parser.add_argument(
+ "--no-default-patterns",
+ action="store_true",
+ help="Disable cleanup by default E2E patterns (use only explicit track/phone/email values)",
+ )
+ args = parser.parse_args()
+
+ db = SessionLocal()
+ try:
+ result = cleanup_test_data(
+ db,
+ CleanupSpec(
+ track_numbers=args.tracks,
+ phones=args.phones,
+ emails=args.emails,
+ include_default_e2e_patterns=not bool(args.no_default_patterns),
+ ),
+ )
+ print(result)
+ finally:
+ db.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/app/data/manual_test_seed.py b/app/data/manual_test_seed.py
new file mode 100644
index 0000000..bc026e3
--- /dev/null
+++ b/app/data/manual_test_seed.py
@@ -0,0 +1,694 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import datetime, timedelta, timezone
+from decimal import Decimal
+from pathlib import Path
+from typing import Iterable
+from uuid import uuid4
+
+from sqlalchemy import or_
+
+from app.core.config import settings
+from app.core.security import hash_password
+from app.db.session import SessionLocal
+from app.models.admin_user import AdminUser
+from app.models.admin_user_topic import AdminUserTopic
+from app.models.attachment import Attachment
+from app.models.audit_log import AuditLog
+from app.models.client import Client
+from app.models.invoice import Invoice
+from app.models.message import Message
+from app.models.notification import Notification
+from app.models.request import Request
+from app.models.request_data_requirement import RequestDataRequirement
+from app.models.security_audit_log import SecurityAuditLog
+from app.models.status import Status
+from app.models.status_group import StatusGroup
+from app.models.status_history import StatusHistory
+from app.models.topic import Topic
+from app.models.topic_data_template import TopicDataTemplate
+from app.services.admin_bootstrap import ensure_bootstrap_admin_for_login
+from app.services.s3_storage import get_s3_storage
+
+
+UTC = timezone.utc
+NOW = datetime.now(UTC).replace(second=0, microsecond=0)
+REQUEST_PREFIX = "TRK-MAN-"
+CLIENT_PHONE_PREFIX = "+79001000"
+LAWYER_PHONE_PREFIX = "+79002000"
+LAWYER_EMAIL_DOMAIN = "example.com"
+LAWYER_PASSWORD = "LawyerManual-123!"
+PROJECT_ROOT = Path(__file__).resolve().parents[2]
+ACCESS_FILE_PATH = PROJECT_ROOT / "context" / "15_manual_test_access.md"
+
+
+STATUS_GROUPS = [
+ ("Новые", 10),
+ ("В работе", 20),
+ ("Ожидание", 30),
+ ("Завершены", 40),
+]
+
+STATUSES = [
+ {"code": "NEW", "name": "Новая", "group": "Новые", "sort_order": 10, "is_terminal": False},
+ {"code": "ASSIGNED", "name": "Назначена", "group": "Новые", "sort_order": 20, "is_terminal": False},
+ {"code": "IN_PROGRESS", "name": "В работе", "group": "В работе", "sort_order": 30, "is_terminal": False},
+ {"code": "WAITING_CLIENT", "name": "Ожидание клиента", "group": "Ожидание", "sort_order": 40, "is_terminal": False},
+ {"code": "WAITING_DOCUMENTS", "name": "Ожидание документов", "group": "Ожидание", "sort_order": 50, "is_terminal": False},
+ {"code": "PAUSED", "name": "Пауза", "group": "Ожидание", "sort_order": 60, "is_terminal": False},
+ {"code": "RESOLVED", "name": "Решена", "group": "Завершены", "sort_order": 70, "is_terminal": True},
+ {"code": "CLOSED", "name": "Закрыта", "group": "Завершены", "sort_order": 80, "is_terminal": True},
+]
+
+TOPICS = [
+ ("manual-civil", "Гражданские споры", 10),
+ ("manual-family", "Семейное право", 20),
+ ("manual-labor", "Трудовые споры", 30),
+ ("manual-tax", "Налоговые вопросы", 40),
+ ("manual-contract", "Договорная работа", 50),
+]
+
+LAWYERS = [
+ {
+ "email": f"lawyer1.manual@{LAWYER_EMAIL_DOMAIN}",
+ "name": "Иван Волков",
+ "phone": f"{LAWYER_PHONE_PREFIX}01",
+ "primary_topic_code": "manual-civil",
+ "extra_topics": ["manual-contract"],
+ "default_rate": Decimal("5000.00"),
+ "salary_percent": Decimal("35.00"),
+ },
+ {
+ "email": f"lawyer2.manual@{LAWYER_EMAIL_DOMAIN}",
+ "name": "Мария Егорова",
+ "phone": f"{LAWYER_PHONE_PREFIX}02",
+ "primary_topic_code": "manual-family",
+ "extra_topics": ["manual-labor"],
+ "default_rate": Decimal("4500.00"),
+ "salary_percent": Decimal("32.00"),
+ },
+ {
+ "email": f"lawyer3.manual@{LAWYER_EMAIL_DOMAIN}",
+ "name": "Павел Климов",
+ "phone": f"{LAWYER_PHONE_PREFIX}03",
+ "primary_topic_code": "manual-tax",
+ "extra_topics": ["manual-contract"],
+ "default_rate": Decimal("6500.00"),
+ "salary_percent": Decimal("40.00"),
+ },
+ {
+ "email": f"lawyer4.manual@{LAWYER_EMAIL_DOMAIN}",
+ "name": "Ольга Смирнова",
+ "phone": f"{LAWYER_PHONE_PREFIX}04",
+ "primary_topic_code": "manual-contract",
+ "extra_topics": ["manual-civil", "manual-tax"],
+ "default_rate": Decimal("5500.00"),
+ "salary_percent": Decimal("38.00"),
+ },
+]
+
+CLIENTS = [
+ ("Ручной Клиент 01", f"{CLIENT_PHONE_PREFIX}01"),
+ ("Ручной Клиент 02", f"{CLIENT_PHONE_PREFIX}02"),
+ ("Ручной Клиент 03", f"{CLIENT_PHONE_PREFIX}03"),
+ ("Ручной Клиент 04", f"{CLIENT_PHONE_PREFIX}04"),
+ ("Ручной Клиент 05", f"{CLIENT_PHONE_PREFIX}05"),
+ ("Ручной Клиент 06", f"{CLIENT_PHONE_PREFIX}06"),
+ ("Ручной Клиент 07", f"{CLIENT_PHONE_PREFIX}07"),
+ ("Ручной Клиент 08", f"{CLIENT_PHONE_PREFIX}08"),
+ ("Ручной Клиент 09", f"{CLIENT_PHONE_PREFIX}09"),
+ ("Ручной Клиент 10", f"{CLIENT_PHONE_PREFIX}10"),
+]
+
+
+@dataclass
+class SeedRequestSpec:
+ idx: int
+ client_phone: str
+ topic_code: str
+ status_code: str
+ assigned_lawyer_email: str | None
+ created_days_ago: int
+ last_update_hours_ago: int
+ important_in_days: int | None
+ request_cost: Decimal
+ invoice_amount: Decimal | None
+ invoice_status: str | None
+ paid_days_ago: int | None
+ chat_pairs: int
+ client_unread: bool
+ lawyer_unread: bool
+ extra_note: str
+
+
+REQUEST_SPECS: list[SeedRequestSpec] = [
+ SeedRequestSpec(1, CLIENTS[0][1], "manual-civil", "NEW", None, 1, 2, 2, Decimal("15000"), None, None, None, 2, False, False, "Первичная консультация"),
+ SeedRequestSpec(2, CLIENTS[0][1], "manual-contract", "IN_PROGRESS", LAWYERS[0]["email"], 9, 4, 1, Decimal("80000"), Decimal("30000"), "PAID", 3, 4, True, False, "Договор и претензия"),
+ SeedRequestSpec(3, CLIENTS[0][1], "manual-tax", "WAITING_CLIENT", LAWYERS[2]["email"], 16, 8, -1, Decimal("120000"), Decimal("50000"), "WAITING_PAYMENT", None, 3, False, True, "Запрошены акты"),
+ SeedRequestSpec(4, CLIENTS[0][1], "manual-tax", "RESOLVED", LAWYERS[2]["email"], 34, 72, None, Decimal("95000"), Decimal("95000"), "PAID", 10, 2, False, False, "Решено через досудебное"),
+ SeedRequestSpec(5, CLIENTS[0][1], "manual-family", "CLOSED", LAWYERS[1]["email"], 64, 240, None, Decimal("60000"), Decimal("60000"), "PAID", 28, 2, False, False, "Завершено мировым соглашением"),
+ SeedRequestSpec(6, CLIENTS[1][1], "manual-family", "IN_PROGRESS", LAWYERS[1]["email"], 4, 3, 0, Decimal("45000"), Decimal("20000"), "WAITING_PAYMENT", None, 4, False, True, "Подготовка иска"),
+ SeedRequestSpec(7, CLIENTS[1][1], "manual-labor", "WAITING_DOCUMENTS", LAWYERS[1]["email"], 7, 12, 3, Decimal("38000"), None, None, None, 3, True, False, "Ожидаем трудовой договор"),
+ SeedRequestSpec(8, CLIENTS[1][1], "manual-labor", "NEW", None, 0, 1, 3, Decimal("25000"), None, None, None, 1, False, False, "Новая заявка без назначения"),
+ SeedRequestSpec(9, CLIENTS[1][1], "manual-contract", "ASSIGNED", LAWYERS[3]["email"], 2, 5, 2, Decimal("70000"), None, None, None, 2, True, False, "Назначена, ожидает старта"),
+ SeedRequestSpec(10, CLIENTS[2][1], "manual-civil", "IN_PROGRESS", LAWYERS[0]["email"], 11, 6, 4, Decimal("110000"), Decimal("40000"), "PAID", 5, 5, False, True, "Судебное представительство"),
+ SeedRequestSpec(11, CLIENTS[2][1], "manual-contract", "WAITING_CLIENT", LAWYERS[3]["email"], 14, 20, 1, Decimal("52000"), Decimal("15000"), "WAITING_PAYMENT", None, 3, False, True, "Дозапрос доверенности"),
+ SeedRequestSpec(12, CLIENTS[2][1], "manual-tax", "PAUSED", LAWYERS[2]["email"], 21, 48, 7, Decimal("135000"), None, None, None, 2, True, False, "Пауза до ответа инспекции"),
+ SeedRequestSpec(13, CLIENTS[3][1], "manual-civil", "WAITING_DOCUMENTS", LAWYERS[0]["email"], 6, 9, -2, Decimal("50000"), None, None, None, 3, True, False, "Просрочен дедлайн по документам"),
+ SeedRequestSpec(14, CLIENTS[3][1], "manual-family", "RESOLVED", LAWYERS[1]["email"], 27, 96, None, Decimal("42000"), Decimal("42000"), "PAID", 12, 2, False, False, "Закрытие по соглашению"),
+ SeedRequestSpec(15, CLIENTS[4][1], "manual-tax", "IN_PROGRESS", LAWYERS[2]["email"], 3, 2, 5, Decimal("210000"), Decimal("90000"), "PAID", 1, 4, False, True, "Налоговая проверка"),
+ SeedRequestSpec(16, CLIENTS[4][1], "manual-contract", "WAITING_CLIENT", LAWYERS[3]["email"], 5, 10, 2, Decimal("65000"), None, None, None, 3, True, False, "Ждем подписанный акт"),
+ SeedRequestSpec(17, CLIENTS[5][1], "manual-labor", "NEW", None, 1, 1, 3, Decimal("18000"), None, None, None, 1, False, False, "Консультация по увольнению"),
+ SeedRequestSpec(18, CLIENTS[6][1], "manual-civil", "IN_PROGRESS", LAWYERS[0]["email"], 8, 7, 2, Decimal("92000"), Decimal("30000"), "WAITING_PAYMENT", None, 4, False, True, "Исполнительное производство"),
+ SeedRequestSpec(19, CLIENTS[7][1], "manual-family", "ASSIGNED", LAWYERS[1]["email"], 2, 6, 3, Decimal("55000"), None, None, None, 2, True, False, "Раздел имущества"),
+ SeedRequestSpec(20, CLIENTS[8][1], "manual-tax", "WAITING_DOCUMENTS", LAWYERS[2]["email"], 10, 18, 1, Decimal("175000"), None, None, None, 3, True, False, "Требуются книги учета"),
+ SeedRequestSpec(21, CLIENTS[9][1], "manual-contract", "NEW", None, 0, 0, 3, Decimal("30000"), None, None, None, 1, False, False, "Проверка оферты"),
+]
+
+
+def _money(value: Decimal | None) -> Decimal | None:
+ if value is None:
+ return None
+ return Decimal(value).quantize(Decimal("0.01"))
+
+
+def _track_number(idx: int) -> str:
+ return f"{REQUEST_PREFIX}{idx:04d}"
+
+
+def _ensure_status_groups(db) -> dict[str, StatusGroup]:
+ out: dict[str, StatusGroup] = {}
+ for name, sort_order in STATUS_GROUPS:
+ row = db.query(StatusGroup).filter(StatusGroup.name == name).first()
+ if row is None:
+ row = StatusGroup(name=name, sort_order=sort_order, responsible="Сид ручных тестов")
+ db.add(row)
+ db.flush()
+ else:
+ row.sort_order = sort_order
+ row.responsible = "Сид ручных тестов"
+ out[name] = row
+ return out
+
+
+def _ensure_statuses(db, groups: dict[str, StatusGroup]) -> dict[str, Status]:
+ out: dict[str, Status] = {}
+ for item in STATUSES:
+ row = db.query(Status).filter(Status.code == item["code"]).first()
+ if row is None:
+ row = Status(
+ code=item["code"],
+ name=item["name"],
+ status_group_id=groups[item["group"]].id,
+ enabled=True,
+ sort_order=int(item["sort_order"]),
+ is_terminal=bool(item["is_terminal"]),
+ responsible="Сид ручных тестов",
+ )
+ db.add(row)
+ else:
+ row.name = str(item["name"])
+ row.status_group_id = groups[item["group"]].id
+ row.enabled = True
+ row.sort_order = int(item["sort_order"])
+ row.is_terminal = bool(item["is_terminal"])
+ row.responsible = "Сид ручных тестов"
+ out[item["code"]] = row
+ return out
+
+
+def _ensure_topics(db) -> dict[str, Topic]:
+ out: dict[str, Topic] = {}
+ for code, name, sort_order in TOPICS:
+ row = db.query(Topic).filter(Topic.code == code).first()
+ if row is None:
+ row = Topic(code=code, name=name, enabled=True, sort_order=sort_order, responsible="Сид ручных тестов")
+ db.add(row)
+ else:
+ row.name = name
+ row.enabled = True
+ row.sort_order = sort_order
+ row.responsible = "Сид ручных тестов"
+ out[code] = row
+ return out
+
+
+def _ensure_bootstrap_admin(db) -> AdminUser:
+ admin = ensure_bootstrap_admin_for_login(db, settings.ADMIN_BOOTSTRAP_EMAIL, settings.ADMIN_BOOTSTRAP_PASSWORD)
+ if admin is None:
+ row = db.query(AdminUser).filter(AdminUser.email == settings.ADMIN_BOOTSTRAP_EMAIL).first()
+ if row:
+ return row
+ raise RuntimeError("Не удалось обеспечить bootstrap-администратора")
+ if not str(admin.phone or "").strip():
+ admin.phone = "+79009999999"
+ admin.responsible = "Сид ручных тестов"
+ db.add(admin)
+ db.commit()
+ db.refresh(admin)
+ return admin
+
+
+def _ensure_lawyers(db) -> dict[str, AdminUser]:
+ out: dict[str, AdminUser] = {}
+ for idx, item in enumerate(LAWYERS, start=1):
+ row = db.query(AdminUser).filter(AdminUser.email == item["email"]).first()
+ if row is None:
+ row = AdminUser(
+ role="LAWYER",
+ name=item["name"],
+ email=item["email"],
+ phone=item["phone"],
+ password_hash=hash_password(LAWYER_PASSWORD),
+ primary_topic_code=item["primary_topic_code"],
+ default_rate=item["default_rate"],
+ salary_percent=item["salary_percent"],
+ is_active=True,
+ responsible="Сид ручных тестов",
+ )
+ db.add(row)
+ db.flush()
+ else:
+ row.role = "LAWYER"
+ row.name = item["name"]
+ row.phone = item["phone"]
+ row.primary_topic_code = item["primary_topic_code"]
+ row.default_rate = item["default_rate"]
+ row.salary_percent = item["salary_percent"]
+ row.is_active = True
+ row.responsible = "Сид ручных тестов"
+ if not str(row.password_hash or "").strip():
+ row.password_hash = hash_password(LAWYER_PASSWORD)
+ out[item["email"]] = row
+
+ db.flush()
+ for item in LAWYERS:
+ row = out[item["email"]]
+ db.query(AdminUserTopic).filter(AdminUserTopic.admin_user_id == row.id).delete(synchronize_session=False)
+ for topic_code in item["extra_topics"]:
+ db.add(
+ AdminUserTopic(
+ admin_user_id=row.id,
+ topic_code=topic_code,
+ responsible="Сид ручных тестов",
+ )
+ )
+ return out
+
+
+def _ensure_clients(db) -> dict[str, Client]:
+ out: dict[str, Client] = {}
+ for full_name, phone in CLIENTS:
+ row = db.query(Client).filter(Client.phone == phone).first()
+ if row is None:
+ row = Client(full_name=full_name, phone=phone, responsible="Сид ручных тестов")
+ db.add(row)
+ else:
+ row.full_name = full_name
+ row.responsible = "Сид ручных тестов"
+ out[phone] = row
+ return out
+
+
+def _seed_request_cleanup(db, request_ids: list, request_tracks: list[str]) -> dict[str, int]:
+ counts = {
+ "attachments": 0,
+ "messages": 0,
+ "status_history": 0,
+ "invoices": 0,
+ "notifications": 0,
+ "request_data_requirements": 0,
+ "audit_log": 0,
+ "security_audit_log": 0,
+ "requests": 0,
+ }
+ if not request_ids:
+ return counts
+
+ attachment_rows = db.query(Attachment).filter(Attachment.request_id.in_(request_ids)).all()
+ attachment_ids = [row.id for row in attachment_rows]
+ try:
+ storage = get_s3_storage()
+ storage.ensure_bucket()
+ for row in attachment_rows:
+ try:
+ storage.client.delete_object(Bucket=storage.bucket, Key=row.s3_key)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ counts["notifications"] += db.query(Notification).filter(Notification.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
+ counts["request_data_requirements"] += (
+ db.query(RequestDataRequirement).filter(RequestDataRequirement.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
+ )
+ counts["status_history"] += db.query(StatusHistory).filter(StatusHistory.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
+ counts["invoices"] += db.query(Invoice).filter(Invoice.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
+ counts["messages"] += db.query(Message).filter(Message.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
+ counts["attachments"] += db.query(Attachment).filter(Attachment.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
+ counts["security_audit_log"] += (
+ db.query(SecurityAuditLog).filter(SecurityAuditLog.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
+ )
+ if attachment_ids:
+ counts["security_audit_log"] += (
+ db.query(SecurityAuditLog).filter(SecurityAuditLog.attachment_id.in_(attachment_ids)).delete(synchronize_session=False) or 0
+ )
+ counts["audit_log"] += (
+ db.query(AuditLog)
+ .filter(AuditLog.entity == "requests", AuditLog.entity_id.in_([str(v) for v in request_ids]))
+ .delete(synchronize_session=False)
+ or 0
+ )
+ if request_tracks:
+ counts["notifications"] += (
+ db.query(Notification)
+ .filter(Notification.recipient_track_number.in_(request_tracks))
+ .delete(synchronize_session=False)
+ or 0
+ )
+ counts["requests"] += db.query(Request).filter(Request.id.in_(request_ids)).delete(synchronize_session=False) or 0
+ return counts
+
+
+def _rebuild_manual_requests(db, clients_by_phone: dict[str, Client], lawyers_by_email: dict[str, AdminUser]) -> list[Request]:
+ manual_existing = db.query(Request).filter(Request.track_number.like(f"{REQUEST_PREFIX}%")).all()
+ cleanup_counts = _seed_request_cleanup(
+ db,
+ [row.id for row in manual_existing],
+ [str(row.track_number or "") for row in manual_existing],
+ )
+
+ created_requests: list[Request] = []
+ for spec in REQUEST_SPECS:
+ client = clients_by_phone[spec.client_phone]
+ lawyer = lawyers_by_email.get(spec.assigned_lawyer_email or "") if spec.assigned_lawyer_email else None
+
+ created_at = NOW - timedelta(days=spec.created_days_ago)
+ updated_at = NOW - timedelta(hours=spec.last_update_hours_ago)
+ important_date = None if spec.important_in_days is None else (NOW + timedelta(days=spec.important_in_days))
+ status_timeline = _build_status_timeline(spec.status_code, created_at, important_date)
+
+ row = Request(
+ track_number=_track_number(spec.idx),
+ client_id=client.id,
+ client_name=client.full_name,
+ client_phone=client.phone,
+ topic_code=spec.topic_code,
+ status_code=spec.status_code,
+ important_date_at=important_date,
+ description=(
+ f"Тестовая заявка для ручной проверки платформы. {spec.extra_note}. "
+ f"Клиент: {client.full_name}. Стадия: {spec.status_code}."
+ ),
+ extra_fields={
+ "номер_договора": f"MAN-{spec.idx:04d}",
+ "канал": "manual_seed",
+ "приоритет": "средний" if spec.idx % 3 else "высокий",
+ },
+ assigned_lawyer_id=str(lawyer.id) if lawyer else None,
+ effective_rate=_money(lawyer.default_rate if lawyer else Decimal("0.00")) if lawyer else None,
+ request_cost=_money(spec.request_cost),
+ invoice_amount=_money(spec.invoice_amount),
+ paid_at=(NOW - timedelta(days=spec.paid_days_ago)) if spec.paid_days_ago is not None else None,
+ paid_by_admin_id=None,
+ total_attachments_bytes=0,
+ client_has_unread_updates=spec.client_unread,
+ client_unread_event_type="MESSAGE" if spec.client_unread else None,
+ lawyer_has_unread_updates=spec.lawyer_unread,
+ lawyer_unread_event_type="MESSAGE" if spec.lawyer_unread else None,
+ responsible=(lawyer.name if lawyer else "Не назначено"),
+ created_at=created_at,
+ updated_at=updated_at,
+ )
+ db.add(row)
+ db.flush()
+ created_requests.append(row)
+
+ _seed_status_history(db, row, status_timeline, lawyers_by_email, lawyer)
+ _seed_chat_messages(db, row, lawyer, spec.chat_pairs)
+ _seed_notifications(db, row, lawyer, spec.client_unread, spec.lawyer_unread)
+ if spec.invoice_status and spec.invoice_amount is not None:
+ _seed_invoice(db, row, client, lawyer, spec.invoice_status, spec.invoice_amount, spec.paid_days_ago)
+
+ print(
+ "manual seed cleanup:",
+ ", ".join(f"{k}={v}" for k, v in cleanup_counts.items() if v),
+ )
+ return created_requests
+
+
+def _build_status_timeline(final_status: str, created_at: datetime, important_date: datetime | None):
+ chain_by_final = {
+ "NEW": ["NEW"],
+ "ASSIGNED": ["NEW", "ASSIGNED"],
+ "IN_PROGRESS": ["NEW", "ASSIGNED", "IN_PROGRESS"],
+ "WAITING_CLIENT": ["NEW", "ASSIGNED", "IN_PROGRESS", "WAITING_CLIENT"],
+ "WAITING_DOCUMENTS": ["NEW", "ASSIGNED", "IN_PROGRESS", "WAITING_DOCUMENTS"],
+ "PAUSED": ["NEW", "ASSIGNED", "IN_PROGRESS", "PAUSED"],
+ "RESOLVED": ["NEW", "ASSIGNED", "IN_PROGRESS", "RESOLVED"],
+ "CLOSED": ["NEW", "ASSIGNED", "IN_PROGRESS", "WAITING_CLIENT", "CLOSED"],
+ }
+ chain = chain_by_final.get(final_status, ["NEW", final_status])
+ steps = []
+ base = created_at
+ for i, status_code in enumerate(chain):
+ step_at = base + timedelta(hours=max(1, i * 18))
+ if i == len(chain) - 1:
+ imp = important_date
+ else:
+ imp = step_at + timedelta(days=3)
+ steps.append({"to_status": status_code, "at": step_at, "important_date_at": imp})
+ return steps
+
+
+def _seed_status_history(db, request: Request, timeline: list[dict], lawyers_by_email: dict[str, AdminUser], assigned_lawyer: AdminUser | None):
+ actor = assigned_lawyer
+ for idx, item in enumerate(timeline):
+ from_status = timeline[idx - 1]["to_status"] if idx > 0 else None
+ db.add(
+ StatusHistory(
+ request_id=request.id,
+ from_status=from_status,
+ to_status=item["to_status"],
+ changed_by_admin_id=actor.id if actor else None,
+ comment=(
+ "Создание заявки" if idx == 0 else f"Переход в статус {item['to_status']} (сценарий ручного теста)"
+ ),
+ important_date_at=item["important_date_at"],
+ responsible=(actor.name if actor else "Сид ручных тестов"),
+ created_at=item["at"],
+ updated_at=item["at"],
+ )
+ )
+
+
+def _seed_chat_messages(db, request: Request, lawyer: AdminUser | None, chat_pairs: int):
+ start_time = (request.created_at or NOW) + timedelta(hours=2)
+ for i in range(chat_pairs):
+ client_time = start_time + timedelta(hours=i * 8)
+ db.add(
+ Message(
+ request_id=request.id,
+ author_type="CLIENT",
+ author_name=request.client_name,
+ body=f"Клиент: сообщение #{i + 1} по заявке {request.track_number}",
+ immutable=False,
+ responsible="Клиент",
+ created_at=client_time,
+ updated_at=client_time,
+ )
+ )
+ if lawyer:
+ lawyer_time = client_time + timedelta(hours=1)
+ db.add(
+ Message(
+ request_id=request.id,
+ author_type="LAWYER",
+ author_name=lawyer.name,
+ body=f"Юрист: ответ #{i + 1} по заявке {request.track_number}",
+ immutable=False,
+ responsible=lawyer.name,
+ created_at=lawyer_time,
+ updated_at=lawyer_time,
+ )
+ )
+
+
+def _seed_notifications(db, request: Request, lawyer: AdminUser | None, client_unread: bool, lawyer_unread: bool):
+ base_payload = {
+ "request_id": str(request.id),
+ "track_number": request.track_number,
+ "status_code": request.status_code,
+ "topic_code": request.topic_code,
+ }
+ if client_unread:
+ db.add(
+ Notification(
+ request_id=request.id,
+ recipient_type="CLIENT",
+ recipient_track_number=request.track_number,
+ event_type="MESSAGE",
+ title=f"Новое сообщение по заявке {request.track_number}",
+ body="Есть непрочитанный ответ юриста",
+ payload=base_payload,
+ is_read=False,
+ responsible="Сид ручных тестов",
+ dedupe_key=f"manual-seed:client:{request.track_number}",
+ created_at=(request.updated_at or NOW) - timedelta(minutes=5),
+ updated_at=(request.updated_at or NOW) - timedelta(minutes=5),
+ )
+ )
+ if lawyer_unread and lawyer:
+ db.add(
+ Notification(
+ request_id=request.id,
+ recipient_type="ADMIN_USER",
+ recipient_admin_user_id=lawyer.id,
+ event_type="MESSAGE",
+ title=f"Новое сообщение по заявке {request.track_number}",
+ body="Есть непрочитанное сообщение клиента",
+ payload=base_payload,
+ is_read=False,
+ responsible="Сид ручных тестов",
+ dedupe_key=f"manual-seed:lawyer:{lawyer.id}:{request.track_number}",
+ created_at=(request.updated_at or NOW) - timedelta(minutes=4),
+ updated_at=(request.updated_at or NOW) - timedelta(minutes=4),
+ )
+ )
+
+
+def _seed_invoice(
+ db,
+ request: Request,
+ client: Client,
+ lawyer: AdminUser | None,
+ status: str,
+ amount: Decimal,
+ paid_days_ago: int | None,
+):
+ issued_at = (request.created_at or NOW) + timedelta(days=1)
+ paid_at = (NOW - timedelta(days=paid_days_ago)) if (status == "PAID" and paid_days_ago is not None) else None
+ db.add(
+ Invoice(
+ request_id=request.id,
+ client_id=client.id,
+ invoice_number=f"INV-MAN-{request.track_number[-4:]}",
+ status=status,
+ amount=_money(amount) or Decimal("0.00"),
+ currency="RUB",
+ payer_display_name=client.full_name,
+ payer_details_encrypted=None,
+ issued_by_admin_user_id=lawyer.id if lawyer else None,
+ issued_by_role=("LAWYER" if lawyer else "ADMIN"),
+ issued_at=issued_at,
+ paid_at=paid_at,
+ responsible=(lawyer.name if lawyer else "Администратор системы"),
+ created_at=issued_at,
+ updated_at=(paid_at or issued_at),
+ )
+ )
+
+
+def _write_access_file(
+ admin: AdminUser,
+ lawyers_by_email: dict[str, AdminUser],
+ clients_by_phone: dict[str, Client],
+ requests: list[Request],
+) -> None:
+ requests_by_phone: dict[str, list[Request]] = {}
+ for row in requests:
+ requests_by_phone.setdefault(str(row.client_phone), []).append(row)
+ for rows in requests_by_phone.values():
+ rows.sort(key=lambda r: str(r.track_number))
+
+ topic_name_by_code = {code: name for code, name, _ in TOPICS}
+ lawyer_by_id = {str(row.id): row for row in lawyers_by_email.values()}
+
+ lines: list[str] = []
+ lines.append("# Тестовые доступы для ручной проверки")
+ lines.append("")
+ lines.append("Сид: `app/data/manual_test_seed.py` (идемпотентный, пересоздает заявки `TRK-MAN-*`).")
+ lines.append(f"Обновлено: `{datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S %Z')}`")
+ lines.append("")
+ lines.append("## Администратор")
+ lines.append(f"- Email: `{admin.email}`")
+ lines.append(f"- Пароль: `{settings.ADMIN_BOOTSTRAP_PASSWORD}`")
+ lines.append(f"- Телефон: `{admin.phone or '-'}`")
+ lines.append("")
+ lines.append("## Юристы (4)")
+ for item in LAWYERS:
+ row = lawyers_by_email[item["email"]]
+ lines.append(
+ f"- {row.name}: `{row.email}` / `{LAWYER_PASSWORD}` | тел.: `{row.phone or '-'}` | "
+ f"основная тема: `{topic_name_by_code.get(str(row.primary_topic_code or ''), row.primary_topic_code or '-')}`"
+ )
+ lines.append("")
+ lines.append("## Клиенты (10) и заявки")
+ lines.append("Для клиента вход через OTP (код выводится в backend-консоль в mock-режиме).")
+ for full_name, phone in CLIENTS:
+ client_rows = requests_by_phone.get(phone, [])
+ lines.append(f"- {full_name} | тел.: `{phone}` | заявок: `{len(client_rows)}`")
+ for req in client_rows:
+ lawyer_name = "-"
+ if req.assigned_lawyer_id and str(req.assigned_lawyer_id) in lawyer_by_id:
+ lawyer_name = lawyer_by_id[str(req.assigned_lawyer_id)].name
+ important = req.important_date_at.astimezone(UTC).strftime("%d.%m.%y %H:%M") if req.important_date_at else "-"
+ lines.append(
+ f" - `{req.track_number}` | статус: `{req.status_code}` | тема: `{topic_name_by_code.get(str(req.topic_code or ''), req.topic_code or '-')}` "
+ f"| юрист: `{lawyer_name}` | важная дата: `{important}`"
+ )
+ lines.append("")
+ lines.append("## Примечания")
+ lines.append("- В выборке есть неназначенные заявки, активные, ожидающие и терминальные статусы.")
+ lines.append("- Есть заявки с оплаченными и ожидающими оплату счетами для проверки dashboard/финансов.")
+ lines.append("- В активных заявках есть переписка и непрочитанные уведомления.")
+ ACCESS_FILE_PATH.parent.mkdir(parents=True, exist_ok=True)
+ ACCESS_FILE_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+def seed_manual_test_data() -> dict[str, int]:
+ db = SessionLocal()
+ try:
+ _ensure_bootstrap_admin(db)
+ _ensure_topics(db)
+ groups = _ensure_status_groups(db)
+ _ensure_statuses(db, groups)
+ lawyers_by_email = _ensure_lawyers(db)
+ clients_by_phone = _ensure_clients(db)
+ db.flush()
+
+ created_requests = _rebuild_manual_requests(db, clients_by_phone, lawyers_by_email)
+ db.commit()
+
+ admin = db.query(AdminUser).filter(AdminUser.email == settings.ADMIN_BOOTSTRAP_EMAIL).first()
+ if admin is None:
+ raise RuntimeError("Bootstrap admin not found after seed")
+
+ # refresh rows for final access file output
+ final_requests = (
+ db.query(Request)
+ .filter(Request.track_number.like(f"{REQUEST_PREFIX}%"))
+ .order_by(Request.track_number.asc())
+ .all()
+ )
+ _write_access_file(admin, lawyers_by_email, clients_by_phone, final_requests)
+
+ summary = {
+ "clients": db.query(Client).filter(Client.phone.like(f"{CLIENT_PHONE_PREFIX}%")).count(),
+ "lawyers": db.query(AdminUser).filter(AdminUser.email.like("lawyer%.manual@%")).count(),
+ "requests": len(final_requests),
+ "messages": db.query(Message).join(Request, Message.request_id == Request.id).filter(Request.track_number.like(f"{REQUEST_PREFIX}%")).count(),
+ "status_history": db.query(StatusHistory).join(Request, StatusHistory.request_id == Request.id).filter(Request.track_number.like(f"{REQUEST_PREFIX}%")).count(),
+ "invoices": db.query(Invoice).join(Request, Invoice.request_id == Request.id).filter(Request.track_number.like(f"{REQUEST_PREFIX}%")).count(),
+ "notifications": db.query(Notification).join(Request, Notification.request_id == Request.id).filter(Request.track_number.like(f"{REQUEST_PREFIX}%")).count(),
+ }
+ print("manual seed summary:", summary)
+ print("manual access file:", str(ACCESS_FILE_PATH))
+ return summary
+ finally:
+ db.close()
+
+
+if __name__ == "__main__":
+ seed_manual_test_data()
diff --git a/app/models/admin_user.py b/app/models/admin_user.py
index 8f6246b..b4e8a2d 100644
--- a/app/models/admin_user.py
+++ b/app/models/admin_user.py
@@ -8,6 +8,7 @@ class AdminUser(Base, UUIDMixin, TimestampMixin):
role: Mapped[str] = mapped_column(String(20), nullable=False) # ADMIN|LAWYER
name: Mapped[str] = mapped_column(String(200), nullable=False)
email: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
+ phone: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
primary_topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
diff --git a/app/models/landing_featured_staff.py b/app/models/landing_featured_staff.py
new file mode 100644
index 0000000..e2ca9f0
--- /dev/null
+++ b/app/models/landing_featured_staff.py
@@ -0,0 +1,18 @@
+import uuid
+
+from sqlalchemy import Boolean, Integer, String
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.db.session import Base
+from app.models.common import TimestampMixin, UUIDMixin
+
+
+class LandingFeaturedStaff(Base, UUIDMixin, TimestampMixin):
+ __tablename__ = "landing_featured_staff"
+
+ admin_user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, unique=True, index=True)
+ caption: Mapped[str | None] = mapped_column(String(300), nullable=True)
+ sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)
+ pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
+ enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
diff --git a/app/models/request.py b/app/models/request.py
index a25a667..7f433e1 100644
--- a/app/models/request.py
+++ b/app/models/request.py
@@ -15,10 +15,12 @@ class Request(Base, UUIDMixin, TimestampMixin):
client_phone: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
status_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True, default="NEW")
+ important_date_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
extra_fields: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
assigned_lawyer_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
effective_rate: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True)
+ request_cost: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
invoice_amount: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
paid_by_admin_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
diff --git a/app/models/request_data_requirement.py b/app/models/request_data_requirement.py
index 62fb814..f3912a0 100644
--- a/app/models/request_data_requirement.py
+++ b/app/models/request_data_requirement.py
@@ -1,6 +1,6 @@
import uuid
-from sqlalchemy import String, Boolean, Text, UniqueConstraint
+from sqlalchemy import String, Boolean, Text, UniqueConstraint, Integer
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
@@ -19,9 +19,14 @@ class RequestDataRequirement(Base, UUIDMixin, TimestampMixin):
)
request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, index=True)
+ request_message_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True)
topic_template_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True)
key: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
label: Mapped[str] = mapped_column(String(200), nullable=False)
+ field_type: Mapped[str] = mapped_column(String(20), nullable=False, default="text")
+ document_name: Mapped[str | None] = mapped_column(String(200), nullable=True, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ value_text: Mapped[str | None] = mapped_column(String(500), nullable=True)
required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_by_admin_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
diff --git a/app/models/request_data_template.py b/app/models/request_data_template.py
new file mode 100644
index 0000000..d859c55
--- /dev/null
+++ b/app/models/request_data_template.py
@@ -0,0 +1,24 @@
+from __future__ import annotations
+
+import uuid
+
+from sqlalchemy import Boolean, Integer, String, Text, UniqueConstraint
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.db.session import Base
+from app.models.common import TimestampMixin, UUIDMixin
+
+
+class RequestDataTemplate(Base, UUIDMixin, TimestampMixin):
+ __tablename__ = "request_data_templates"
+ __table_args__ = (
+ UniqueConstraint("topic_code", "name", name="uq_request_data_templates_topic_name"),
+ )
+
+ topic_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
+ name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
+ created_by_admin_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True)
diff --git a/app/models/request_data_template_item.py b/app/models/request_data_template_item.py
new file mode 100644
index 0000000..0050f92
--- /dev/null
+++ b/app/models/request_data_template_item.py
@@ -0,0 +1,24 @@
+from __future__ import annotations
+
+import uuid
+
+from sqlalchemy import Integer, String, UniqueConstraint
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.db.session import Base
+from app.models.common import TimestampMixin, UUIDMixin
+
+
+class RequestDataTemplateItem(Base, UUIDMixin, TimestampMixin):
+ __tablename__ = "request_data_template_items"
+ __table_args__ = (
+ UniqueConstraint("request_data_template_id", "key", name="uq_request_data_template_items_template_key"),
+ )
+
+ request_data_template_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, index=True)
+ topic_data_template_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True)
+ key: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
+ label: Mapped[str] = mapped_column(String(200), nullable=False)
+ value_type: Mapped[str] = mapped_column(String(20), nullable=False, default="string")
+ sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
diff --git a/app/models/status_history.py b/app/models/status_history.py
index 94af80c..8e2fca5 100644
--- a/app/models/status_history.py
+++ b/app/models/status_history.py
@@ -1,5 +1,6 @@
import uuid
-from sqlalchemy import String
+from datetime import datetime
+from sqlalchemy import DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.db.session import Base
@@ -12,3 +13,4 @@ class StatusHistory(Base, UUIDMixin, TimestampMixin):
to_status: Mapped[str] = mapped_column(String(50), nullable=False)
changed_by_admin_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
comment: Mapped[str | None] = mapped_column(String(400), nullable=True)
+ important_date_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
diff --git a/app/models/topic_data_template.py b/app/models/topic_data_template.py
index e8cebf2..8388a59 100644
--- a/app/models/topic_data_template.py
+++ b/app/models/topic_data_template.py
@@ -18,6 +18,8 @@ class TopicDataTemplate(Base, UUIDMixin, TimestampMixin):
topic_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
key: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
label: Mapped[str] = mapped_column(String(200), nullable=False)
+ value_type: Mapped[str] = mapped_column(String(20), nullable=False, default="text")
+ document_name: Mapped[str | None] = mapped_column(String(200), nullable=True, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
diff --git a/app/schemas/admin.py b/app/schemas/admin.py
index d2eaf8a..a6837f9 100644
--- a/app/schemas/admin.py
+++ b/app/schemas/admin.py
@@ -63,14 +63,17 @@ class FormFieldUpsert(BaseModel):
class RequestAdminCreate(BaseModel):
track_number: Optional[str] = None
+ client_id: Optional[str] = None
client_name: str
client_phone: str
topic_code: Optional[str] = None
status_code: str = "NEW"
+ important_date_at: Optional[datetime] = None
description: Optional[str] = None
extra_fields: dict = Field(default_factory=dict)
assigned_lawyer_id: Optional[str] = None
effective_rate: Optional[float] = None
+ request_cost: Optional[float] = None
invoice_amount: Optional[float] = None
paid_at: Optional[datetime] = None
paid_by_admin_id: Optional[str] = None
@@ -79,14 +82,17 @@ class RequestAdminCreate(BaseModel):
class RequestAdminPatch(BaseModel):
track_number: Optional[str] = None
+ client_id: Optional[str] = None
client_name: Optional[str] = None
client_phone: Optional[str] = None
topic_code: Optional[str] = None
status_code: Optional[str] = None
+ important_date_at: Optional[datetime] = None
description: Optional[str] = None
extra_fields: Optional[dict] = None
assigned_lawyer_id: Optional[str] = None
effective_rate: Optional[float] = None
+ request_cost: Optional[float] = None
invoice_amount: Optional[float] = None
paid_at: Optional[datetime] = None
paid_by_admin_id: Optional[str] = None
@@ -97,6 +103,12 @@ class RequestReassign(BaseModel):
lawyer_id: str
+class RequestStatusChange(BaseModel):
+ status_code: str
+ important_date_at: Optional[datetime] = None
+ comment: Optional[str] = None
+
+
class RequestDataRequirementCreate(BaseModel):
key: str
label: str
diff --git a/app/services/chat_service.py b/app/services/chat_service.py
index b8ebd76..974a0e5 100644
--- a/app/services/chat_service.py
+++ b/app/services/chat_service.py
@@ -6,7 +6,9 @@ from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.models.message import Message
+from app.models.attachment import Attachment
from app.models.request import Request
+from app.models.request_data_requirement import RequestDataRequirement
from app.services.notifications import EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE, notify_request_event
from app.services.request_read_markers import EVENT_MESSAGE, mark_unread_for_client, mark_unread_for_lawyer
@@ -32,6 +34,113 @@ def serialize_message(row: Message) -> dict[str, Any]:
}
+def _truncate_request_data_label(label: str, limit: int = 18) -> str:
+ text = str(label or "").strip()
+ if len(text) <= limit:
+ return text
+ return text[: max(3, limit - 3)].rstrip() + "..."
+
+
+def serialize_messages_for_request(db: Session, request_id: Any, rows: list[Message]) -> list[dict[str, Any]]:
+ message_ids = []
+ for row in rows:
+ try:
+ message_ids.append(row.id)
+ except Exception:
+ continue
+ requirements = (
+ db.query(RequestDataRequirement)
+ .filter(
+ RequestDataRequirement.request_id == request_id,
+ RequestDataRequirement.request_message_id.in_(message_ids) if message_ids else False,
+ )
+ .order_by(
+ RequestDataRequirement.request_message_id.asc(),
+ RequestDataRequirement.sort_order.asc(),
+ RequestDataRequirement.created_at.asc(),
+ RequestDataRequirement.id.asc(),
+ )
+ .all()
+ if message_ids
+ else []
+ )
+ by_message_id: dict[str, list[RequestDataRequirement]] = {}
+ for item in requirements:
+ mid = str(item.request_message_id or "").strip()
+ if not mid:
+ continue
+ by_message_id.setdefault(mid, []).append(item)
+ file_attachment_ids = []
+ for item in requirements:
+ if str(item.field_type or "").lower() != "file":
+ continue
+ raw = str(item.value_text or "").strip()
+ if not raw:
+ continue
+ try:
+ file_attachment_ids.append(raw)
+ except Exception:
+ continue
+ attachment_map: dict[str, Attachment] = {}
+ if file_attachment_ids:
+ attachment_rows = db.query(Attachment).filter(Attachment.id.in_(file_attachment_ids)).all()
+ attachment_map = {str(row.id): row for row in attachment_rows}
+
+ out: list[dict[str, Any]] = []
+ for row in rows:
+ payload = serialize_message(row)
+ linked = by_message_id.get(str(row.id), [])
+ if linked:
+ linked_sorted = sorted(
+ linked,
+ key=lambda req: (
+ 1 if str(req.value_text or "").strip() else 0,
+ int(req.sort_order or 0),
+ req.created_at.timestamp() if getattr(req, "created_at", None) else 0,
+ str(req.id),
+ ),
+ )
+ items = []
+ all_filled = True
+ for idx, req in enumerate(linked_sorted, start=1):
+ value_text = str(req.value_text or "").strip()
+ is_filled = bool(value_text)
+ if not is_filled:
+ all_filled = False
+ items.append(
+ {
+ "id": str(req.id),
+ "index": idx,
+ "key": req.key,
+ "label": req.label,
+ "label_short": _truncate_request_data_label(str(req.label or "")),
+ "field_type": str(req.field_type or "text"),
+ "document_name": req.document_name,
+ "value_text": req.value_text,
+ "value_file": (
+ {
+ "attachment_id": str(attachment_map[value_text].id),
+ "file_name": attachment_map[value_text].file_name,
+ "mime_type": attachment_map[value_text].mime_type,
+ "size_bytes": int(attachment_map[value_text].size_bytes or 0),
+ "download_url": None,
+ }
+ if str(req.field_type or "").lower() == "file" and value_text in attachment_map
+ else None
+ ),
+ "is_filled": is_filled,
+ }
+ )
+ payload["message_kind"] = "REQUEST_DATA"
+ payload["request_data_items"] = items
+ payload["request_data_all_filled"] = all_filled and bool(items)
+ payload["body"] = "Запрос"
+ else:
+ payload["message_kind"] = "TEXT"
+ out.append(payload)
+ return out
+
+
def create_client_message(db: Session, *, request: Request, body: str) -> Message:
message_body = str(body or "").strip()
if not message_body:
diff --git a/app/services/request_status.py b/app/services/request_status.py
index 5156578..2750e43 100644
--- a/app/services/request_status.py
+++ b/app/services/request_status.py
@@ -2,6 +2,7 @@ from __future__ import annotations
import uuid
from typing import Any
+from datetime import datetime
from sqlalchemy.orm import Session
@@ -42,6 +43,7 @@ def register_status_history(
*,
admin: dict[str, Any] | None = None,
comment: str | None = None,
+ important_date_at: datetime | None = None,
responsible: str = "Администратор системы",
) -> None:
db.add(
@@ -51,6 +53,7 @@ def register_status_history(
to_status=str(to_status or "").strip(),
changed_by_admin_id=actor_admin_uuid(admin),
comment=comment,
+ important_date_at=important_date_at,
responsible=responsible,
)
)
@@ -64,6 +67,7 @@ def apply_status_change_effects(
to_status: str,
admin: dict[str, Any] | None = None,
comment: str | None = None,
+ important_date_at: datetime | None = None,
responsible: str = "Администратор системы",
) -> None:
old_code = str(from_status or "").strip()
@@ -78,5 +82,6 @@ def apply_status_change_effects(
new_code,
admin=admin,
comment=comment,
+ important_date_at=important_date_at,
responsible=responsible,
)
diff --git a/app/services/test_data_cleanup.py b/app/services/test_data_cleanup.py
new file mode 100644
index 0000000..57c96b9
--- /dev/null
+++ b/app/services/test_data_cleanup.py
@@ -0,0 +1,249 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Iterable
+from uuid import UUID
+
+from sqlalchemy import or_
+from sqlalchemy.orm import Session
+
+from app.models.admin_user import AdminUser
+from app.models.admin_user_topic import AdminUserTopic
+from app.models.attachment import Attachment
+from app.models.audit_log import AuditLog
+from app.models.client import Client
+from app.models.invoice import Invoice
+from app.models.message import Message
+from app.models.notification import Notification
+from app.models.otp_session import OtpSession
+from app.models.request import Request
+from app.models.request_data_requirement import RequestDataRequirement
+from app.models.request_data_template import RequestDataTemplate
+from app.models.request_data_template_item import RequestDataTemplateItem
+from app.models.security_audit_log import SecurityAuditLog
+from app.models.status_history import StatusHistory
+from app.models.topic import Topic
+from app.models.topic_data_template import TopicDataTemplate
+from app.services.s3_storage import get_s3_storage
+
+
+@dataclass
+class CleanupSpec:
+ track_numbers: list[str] = field(default_factory=list)
+ phones: list[str] = field(default_factory=list)
+ emails: list[str] = field(default_factory=list)
+ include_default_e2e_patterns: bool = True
+
+
+def _normalize_list(values: Iterable[object] | None) -> list[str]:
+ out: list[str] = []
+ seen: set[str] = set()
+ for raw in values or []:
+ text = str(raw or "").strip()
+ if not text or text in seen:
+ continue
+ seen.add(text)
+ out.append(text)
+ return out
+
+
+def _safe_delete_s3_objects(rows: list[Attachment]) -> int:
+ if not rows:
+ return 0
+ deleted = 0
+ try:
+ storage = get_s3_storage()
+ for row in rows:
+ key = str(row.s3_key or "").strip()
+ if not key:
+ continue
+ try:
+ storage.ensure_bucket()
+ storage.client.delete_object(Bucket=storage.bucket, Key=key)
+ deleted += 1
+ except Exception:
+ continue
+ except Exception:
+ return 0
+ return deleted
+
+
+def cleanup_test_data(db: Session, spec: CleanupSpec | None = None) -> dict[str, int]:
+ payload = spec or CleanupSpec()
+ track_numbers = _normalize_list(payload.track_numbers)
+ phones = _normalize_list(payload.phones)
+ emails = [item.lower() for item in _normalize_list(payload.emails)]
+
+ request_query = db.query(Request)
+ request_filters = []
+ if track_numbers:
+ request_filters.append(Request.track_number.in_(track_numbers))
+ if phones:
+ request_filters.append(Request.client_phone.in_(phones))
+ if payload.include_default_e2e_patterns:
+ request_filters.extend(
+ [
+ Request.client_name.ilike("Клиент E2E %"),
+ Request.description.ilike("%e2e%"),
+ Request.description.ilike("%E2E%"),
+ ]
+ )
+ request_rows = request_query.filter(or_(*request_filters)).all() if request_filters else []
+ request_ids = [row.id for row in request_rows]
+ request_tracks = [str(row.track_number or "") for row in request_rows]
+ client_ids_from_requests = [row.client_id for row in request_rows if row.client_id]
+
+ attachment_rows = db.query(Attachment).filter(Attachment.request_id.in_(request_ids)).all() if request_ids else []
+ attachment_ids = [row.id for row in attachment_rows]
+ s3_deleted = _safe_delete_s3_objects(attachment_rows)
+
+ deleted_counts: dict[str, int] = {
+ "requests": 0,
+ "messages": 0,
+ "attachments": 0,
+ "status_history": 0,
+ "invoices": 0,
+ "notifications": 0,
+ "request_data_requirements": 0,
+ "security_audit_log": 0,
+ "audit_log": 0,
+ "otp_sessions": 0,
+ "clients": 0,
+ "admin_users": 0,
+ "admin_user_topics": 0,
+ "topics": 0,
+ "topic_data_templates": 0,
+ "request_data_templates": 0,
+ "request_data_template_items": 0,
+ "s3_objects": s3_deleted,
+ }
+
+ if request_ids:
+ deleted_counts["notifications"] += (
+ db.query(Notification).filter(Notification.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
+ )
+ deleted_counts["request_data_requirements"] += (
+ db.query(RequestDataRequirement).filter(RequestDataRequirement.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
+ )
+ deleted_counts["status_history"] += (
+ db.query(StatusHistory).filter(StatusHistory.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
+ )
+ deleted_counts["invoices"] += (
+ db.query(Invoice).filter(Invoice.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
+ )
+ deleted_counts["messages"] += (
+ db.query(Message).filter(Message.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
+ )
+ deleted_counts["attachments"] += (
+ db.query(Attachment).filter(Attachment.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
+ )
+ deleted_counts["security_audit_log"] += (
+ db.query(SecurityAuditLog).filter(SecurityAuditLog.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
+ )
+ if attachment_ids:
+ deleted_counts["security_audit_log"] += (
+ db.query(SecurityAuditLog).filter(SecurityAuditLog.attachment_id.in_(attachment_ids)).delete(synchronize_session=False) or 0
+ )
+ request_id_strs = {str(item) for item in request_ids}
+ deleted_counts["audit_log"] += (
+ db.query(AuditLog)
+ .filter(AuditLog.entity == "requests", AuditLog.entity_id.in_(list(request_id_strs)))
+ .delete(synchronize_session=False)
+ or 0
+ )
+ deleted_counts["requests"] += (
+ db.query(Request).filter(Request.id.in_(request_ids)).delete(synchronize_session=False) or 0
+ )
+
+ otp_filters = []
+ if phones:
+ otp_filters.append(OtpSession.phone.in_(phones))
+ if request_tracks:
+ otp_filters.append(OtpSession.track_number.in_(request_tracks))
+ if otp_filters:
+ deleted_counts["otp_sessions"] += db.query(OtpSession).filter(or_(*otp_filters)).delete(synchronize_session=False) or 0
+
+ client_filters = []
+ if client_ids_from_requests:
+ client_filters.append(Client.id.in_(client_ids_from_requests))
+ if phones:
+ client_filters.append(Client.phone.in_(phones))
+ if payload.include_default_e2e_patterns:
+ client_filters.append(Client.full_name.ilike("Клиент E2E %"))
+ if client_filters:
+ deleted_counts["clients"] += db.query(Client).filter(or_(*client_filters)).delete(synchronize_session=False) or 0
+
+ user_rows: list[AdminUser] = []
+ user_filters = []
+ if emails:
+ user_filters.append(AdminUser.email.in_(emails))
+ if payload.include_default_e2e_patterns:
+ user_filters.extend([AdminUser.email.ilike("ui-lawyer-%@example.com"), AdminUser.name.ilike("Юрист UI %")])
+ if user_filters:
+ user_rows = db.query(AdminUser).filter(or_(*user_filters)).all()
+ user_ids = [row.id for row in user_rows]
+ if user_ids:
+ deleted_counts["admin_user_topics"] += (
+ db.query(AdminUserTopic).filter(AdminUserTopic.admin_user_id.in_(user_ids)).delete(synchronize_session=False) or 0
+ )
+ deleted_counts["notifications"] += (
+ db.query(Notification).filter(Notification.recipient_admin_user_id.in_(user_ids)).delete(synchronize_session=False) or 0
+ )
+ deleted_counts["audit_log"] += (
+ db.query(AuditLog).filter(AuditLog.actor_admin_id.in_(user_ids)).delete(synchronize_session=False) or 0
+ )
+ deleted_counts["admin_users"] += (
+ db.query(AdminUser).filter(AdminUser.id.in_(user_ids)).delete(synchronize_session=False) or 0
+ )
+
+ topic_rows: list[Topic] = []
+ topic_filters = []
+ if payload.include_default_e2e_patterns:
+ topic_filters.append(Topic.name.ilike("Тема UI %"))
+ if topic_filters:
+ topic_rows = db.query(Topic).filter(or_(*topic_filters)).all()
+ topic_codes = [str(row.code or "").strip() for row in topic_rows if str(row.code or "").strip()]
+ if topic_codes:
+ deleted_counts["topic_data_templates"] += (
+ db.query(TopicDataTemplate).filter(TopicDataTemplate.topic_code.in_(topic_codes)).delete(synchronize_session=False) or 0
+ )
+ template_rows = db.query(RequestDataTemplate).filter(RequestDataTemplate.topic_code.in_(topic_codes)).all()
+ template_ids = [row.id for row in template_rows]
+ if template_ids:
+ deleted_counts["request_data_template_items"] += (
+ db.query(RequestDataTemplateItem)
+ .filter(RequestDataTemplateItem.request_data_template_id.in_(template_ids))
+ .delete(synchronize_session=False)
+ or 0
+ )
+ deleted_counts["request_data_templates"] += (
+ db.query(RequestDataTemplate).filter(RequestDataTemplate.id.in_(template_ids)).delete(synchronize_session=False) or 0
+ )
+ deleted_counts["topics"] += db.query(Topic).filter(Topic.code.in_(topic_codes)).delete(synchronize_session=False) or 0
+
+ if payload.include_default_e2e_patterns:
+ deleted_counts["topic_data_templates"] += (
+ db.query(TopicDataTemplate)
+ .filter(or_(TopicDataTemplate.label.ilike("%E2E%"), TopicDataTemplate.label.ilike("%e2e%")))
+ .delete(synchronize_session=False)
+ or 0
+ )
+ template_rows = (
+ db.query(RequestDataTemplate)
+ .filter(or_(RequestDataTemplate.name.ilike("%E2E%"), RequestDataTemplate.name.ilike("%e2e%")))
+ .all()
+ )
+ template_ids = [row.id for row in template_rows]
+ if template_ids:
+ deleted_counts["request_data_template_items"] += (
+ db.query(RequestDataTemplateItem)
+ .filter(RequestDataTemplateItem.request_data_template_id.in_(template_ids))
+ .delete(synchronize_session=False)
+ or 0
+ )
+ deleted_counts["request_data_templates"] += (
+ db.query(RequestDataTemplate).filter(RequestDataTemplate.id.in_(template_ids)).delete(synchronize_session=False) or 0
+ )
+
+ db.commit()
+ return deleted_counts
diff --git a/app/services/universal_query.py b/app/services/universal_query.py
index 423b703..1123774 100644
--- a/app/services/universal_query.py
+++ b/app/services/universal_query.py
@@ -1,31 +1,146 @@
import uuid
+from datetime import date, datetime, timezone
+from datetime import timedelta
+from decimal import Decimal, InvalidOperation
from fastapi import HTTPException
-from sqlalchemy.orm import Query
from sqlalchemy import asc, desc
+from sqlalchemy.orm import Query
from app.schemas.universal import UniversalQuery
+def _bad_filter_value(column_key: str, kind: str) -> HTTPException:
+ return HTTPException(status_code=400, detail=f'Некорректное значение фильтра для поля "{column_key}" ({kind})')
+
+
+def _coerce_bool_filter_value(column_key: str, value):
+ if isinstance(value, bool):
+ return value
+ text = str(value or "").strip().lower()
+ if text in {"1", "true", "yes", "y", "да"}:
+ return True
+ if text in {"0", "false", "no", "n", "нет"}:
+ return False
+ raise _bad_filter_value(column_key, "boolean")
+
+
+def _coerce_number_filter_value(column_key: str, value, python_type):
+ if value is None:
+ return None
+ if python_type in {int, float} and isinstance(value, (int, float)):
+ return python_type(value)
+ if python_type is Decimal and isinstance(value, Decimal):
+ return value
+ text = str(value).strip()
+ if not text:
+ raise _bad_filter_value(column_key, "number")
+ normalized = text.replace(",", ".")
+ try:
+ if python_type is int:
+ return int(normalized)
+ if python_type is float:
+ return float(normalized)
+ if python_type is Decimal:
+ return Decimal(normalized)
+ return python_type(normalized)
+ except (ValueError, TypeError, InvalidOperation):
+ raise _bad_filter_value(column_key, "number")
+
+
+def _coerce_date_filter_value(column_key: str, value):
+ if isinstance(value, date) and not isinstance(value, datetime):
+ return value
+ text = str(value or "").strip()
+ if not text:
+ raise _bad_filter_value(column_key, "date")
+ try:
+ # Accept either YYYY-MM-DD or full ISO datetime and take its date part.
+ if "T" in text or " " in text:
+ return datetime.fromisoformat(text.replace("Z", "+00:00")).date()
+ return date.fromisoformat(text)
+ except ValueError:
+ raise _bad_filter_value(column_key, "date")
+
+
+def _coerce_datetime_filter_value(column_key: str, value):
+ if isinstance(value, datetime):
+ parsed = value
+ else:
+ text = str(value or "").strip()
+ if not text:
+ raise _bad_filter_value(column_key, "datetime")
+ try:
+ if "T" not in text and " " not in text and len(text) == 10:
+ # Date-only filter value for timestamp columns -> start of the day.
+ parsed = datetime.combine(date.fromisoformat(text), datetime.min.time())
+ else:
+ parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
+ except ValueError:
+ raise _bad_filter_value(column_key, "datetime")
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed
+
+
def _coerce_filter_value(column, value):
try:
python_type = column.property.columns[0].type.python_type
except Exception:
return value
- if python_type is uuid.UUID and isinstance(value, str):
+ if python_type is uuid.UUID:
+ if isinstance(value, uuid.UUID):
+ return value
try:
- return uuid.UUID(value)
+ return uuid.UUID(str(value or "").strip())
except ValueError:
raise HTTPException(status_code=400, detail=f'Некорректный UUID в фильтре поля "{column.key}"')
+ if python_type is bool:
+ return _coerce_bool_filter_value(column.key, value)
+ if python_type in {int, float, Decimal}:
+ return _coerce_number_filter_value(column.key, value, python_type)
+ if python_type is date:
+ return _coerce_date_filter_value(column.key, value)
+ if python_type is datetime:
+ return _coerce_datetime_filter_value(column.key, value)
return value
+def _column_python_type(column):
+ try:
+ return column.property.columns[0].type.python_type
+ except Exception:
+ return None
+
+
+def _is_date_only_filter_literal(raw_value) -> bool:
+ if isinstance(raw_value, date) and not isinstance(raw_value, datetime):
+ return True
+ if not isinstance(raw_value, str):
+ return False
+ text = raw_value.strip()
+ if not text or "T" in text or " " in text:
+ return False
+ try:
+ date.fromisoformat(text)
+ return True
+ except ValueError:
+ return False
+
+
def apply_universal_query(q: Query, model, uq: UniversalQuery) -> Query:
for f in uq.filters:
col = getattr(model, f.field, None)
if col is None:
continue
+ col_python_type = _column_python_type(col)
value = _coerce_filter_value(col, f.value)
+ if col_python_type is datetime and f.op in {"=", "!="} and _is_date_only_filter_literal(f.value):
+ day_start = value
+ day_end = day_start + timedelta(days=1)
+ day_expr = (col >= day_start) & (col < day_end)
+ q = q.filter(day_expr if f.op == "=" else ~day_expr)
+ continue
if f.op == "=":
q = q.filter(col == value)
elif f.op == "!=":
diff --git a/app/web/admin.css b/app/web/admin.css
index 7c2717b..2b4892f 100644
--- a/app/web/admin.css
+++ b/app/web/admin.css
@@ -122,6 +122,8 @@
.main {
padding: 1.2rem;
+ min-width: 0;
+ overflow-x: hidden;
}
.topbar {
@@ -156,6 +158,65 @@
letter-spacing: 0.06em;
}
+ .topbar-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ flex-shrink: 0;
+ }
+
+ .topbar-alert-btn {
+ width: 38px;
+ height: 38px;
+ border-radius: 50%;
+ position: relative;
+ color: #cbd9ee;
+ }
+
+ .topbar-alert-btn.has-alert.alert-success {
+ border-color: rgba(62, 214, 146, 0.52);
+ background: rgba(62, 214, 146, 0.14);
+ color: #bff7de;
+ box-shadow: 0 0 0 3px rgba(62, 214, 146, 0.12);
+ }
+
+ .topbar-alert-btn.has-alert.alert-danger {
+ border-color: rgba(255, 98, 98, 0.58);
+ background: rgba(255, 98, 98, 0.14);
+ color: #ffd7d7;
+ box-shadow: 0 0 0 3px rgba(255, 98, 98, 0.12);
+ }
+
+ .topbar-alert-dot {
+ position: absolute;
+ right: 2px;
+ top: 2px;
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+ border: 2px solid rgba(8, 16, 24, 0.96);
+ background: #6f839e;
+ opacity: 0;
+ transform: scale(0.85);
+ transition: opacity 0.15s ease, transform 0.15s ease;
+ pointer-events: none;
+ }
+
+ .topbar-alert-btn.has-alert .topbar-alert-dot {
+ opacity: 1;
+ transform: scale(1);
+ }
+
+ .topbar-alert-btn.has-alert.alert-success .topbar-alert-dot {
+ background: #3ed692;
+ box-shadow: 0 0 0 3px rgba(62, 214, 146, 0.18);
+ }
+
+ .topbar-alert-btn.has-alert.alert-danger .topbar-alert-dot {
+ background: #ff6767;
+ box-shadow: 0 0 0 3px rgba(255, 103, 103, 0.16);
+ }
+
.btn {
border: 1px solid transparent;
border-radius: 999px;
@@ -194,6 +255,7 @@
background: linear-gradient(160deg, rgba(20, 30, 40, 0.93), rgba(14, 22, 30, 0.96));
box-shadow: var(--shadow);
padding: 1rem;
+ min-width: 0;
}
.section.active { display: block; }
@@ -258,24 +320,223 @@
color: #f6dab0;
}
+ .lawyer-dashboard-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 0.75rem;
+ align-items: stretch;
+ }
+
+ .lawyer-dashboard-card {
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ background: rgba(255, 255, 255, 0.025);
+ padding: 0.8rem;
+ color: inherit;
+ text-align: left;
+ cursor: pointer;
+ display: grid;
+ grid-template-columns: 120px 1fr;
+ gap: 0.8rem;
+ transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease;
+ }
+
+ .lawyer-dashboard-card:hover,
+ .lawyer-dashboard-card:focus-visible {
+ border-color: rgba(212, 168, 106, 0.45);
+ background: rgba(212, 168, 106, 0.04);
+ transform: translateY(-1px);
+ outline: none;
+ }
+
+ .lawyer-dashboard-left {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ align-items: center;
+ text-align: center;
+ }
+
+ .lawyer-dashboard-avatar {
+ width: 84px;
+ height: 84px;
+ border-radius: 50%;
+ display: grid;
+ place-items: center;
+ border: 1px solid var(--line);
+ background: rgba(255, 255, 255, 0.03);
+ }
+
+ .lawyer-dashboard-name {
+ color: #eaf2ff;
+ line-height: 1.25;
+ font-size: 0.95rem;
+ margin-top: 0.1rem;
+ }
+
+ .lawyer-dashboard-topic {
+ color: var(--muted);
+ font-size: 0.8rem;
+ line-height: 1.3;
+ }
+
+ .lawyer-dashboard-right {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 0.38rem;
+ align-content: start;
+ }
+
+ .lawyer-metric-pair {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.5rem;
+ align-items: baseline;
+ border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
+ padding-bottom: 0.22rem;
+ }
+
+ .lawyer-metric-pair span {
+ color: var(--muted);
+ font-size: 0.78rem;
+ }
+
+ .lawyer-metric-pair b {
+ color: #f6dab0;
+ font-size: 0.88rem;
+ text-align: right;
+ }
+
+ .lawyer-dashboard-modal {
+ width: min(1180px, 100%);
+ max-height: 90vh;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
+
+ .lawyer-dashboard-modal .modal-head {
+ flex: 0 0 auto;
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ padding-bottom: 0.45rem;
+ background: linear-gradient(160deg, rgba(21, 31, 42, 0.96), rgba(14, 21, 28, 0.98));
+ }
+
+ .lawyer-dashboard-modal-summary {
+ display: grid;
+ grid-template-columns: 120px 1fr;
+ gap: 0.85rem;
+ align-items: center;
+ margin-bottom: 0.65rem;
+ flex: 0 0 auto;
+ }
+
+ .lawyer-dashboard-modal-avatar {
+ border: none;
+ border-radius: 0;
+ min-height: 110px;
+ background: transparent;
+ display: grid;
+ place-items: center;
+ align-self: center;
+ }
+
+ .lawyer-dashboard-modal-metrics {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.4rem 0.75rem;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 0.65rem 0.75rem;
+ background: rgba(255, 255, 255, 0.02);
+ }
+
+ .lawyer-dashboard-modal-scroll {
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.015);
+ padding: 0.55rem;
+ overflow: hidden;
+ min-height: 0;
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ gap: 0.45rem;
+ }
+
+ .lawyer-dashboard-modal-table-area {
+ min-height: 0;
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .lawyer-dashboard-modal-table-area .table-wrap {
+ min-height: 220px;
+ max-height: min(42vh, 420px);
+ overflow: auto;
+ border: none;
+ border-radius: 0;
+ background: transparent;
+ }
+
+ .lawyer-dashboard-modal-table-area thead th {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ background: linear-gradient(160deg, rgba(24, 36, 48, 0.98), rgba(16, 24, 32, 0.98));
+ box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04);
+ }
+
+ .lawyer-dashboard-modal-footer {
+ margin-top: 0.55rem;
+ flex: 0 0 auto;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+ justify-content: flex-end;
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
+ padding-top: 0.55rem;
+ background: linear-gradient(180deg, rgba(13, 20, 27, 0), rgba(13, 20, 27, 0.42));
+ }
+
+ .lawyer-dashboard-total-chip {
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ padding: 0.32rem 0.65rem;
+ background: rgba(255, 255, 255, 0.03);
+ color: var(--muted);
+ font-size: 0.82rem;
+ }
+
+ .lawyer-dashboard-total-chip b {
+ color: #f6dab0;
+ margin-left: 0.25rem;
+ }
+
.kanban-wrap {
display: flex;
flex-direction: column;
gap: 0.75rem;
+ min-width: 0;
}
.kanban-board {
display: flex;
- flex-wrap: wrap;
+ flex-wrap: nowrap;
gap: 0.75rem;
- overflow-x: hidden;
+ overflow-x: auto;
+ overflow-y: hidden;
+ max-width: 100%;
padding-bottom: 0.25rem;
align-items: stretch;
- align-content: flex-start;
+ align-content: stretch;
}
.kanban-column {
- flex: 1 1 300px;
+ flex: 0 0 320px;
min-width: 260px;
border: 1px solid var(--line);
border-radius: 14px;
@@ -535,7 +796,7 @@
.filter-chip {
display: inline-flex;
align-items: center;
- gap: 0.35rem;
+ gap: 0;
padding: 0.25rem 0.5rem;
border: 1px solid rgba(212, 168, 106, 0.3);
border-radius: 999px;
@@ -544,12 +805,15 @@
font-size: 0.76rem;
line-height: 1.2;
cursor: pointer;
+ transition: padding-right 0.18s ease, border-color 0.18s ease, background 0.18s ease;
}
.filter-chip button {
border: none;
- width: 18px;
+ width: 0;
height: 18px;
+ min-width: 0;
+ margin-left: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
color: #fce3bd;
@@ -557,6 +821,35 @@
font-size: 0.82rem;
line-height: 1;
padding: 0;
+ opacity: 0;
+ overflow: hidden;
+ pointer-events: none;
+ transform: scale(0.85);
+ transition:
+ width 0.18s ease,
+ margin-left 0.18s ease,
+ opacity 0.14s ease,
+ transform 0.18s ease,
+ background 0.18s ease;
+ }
+
+ .filter-chip:hover,
+ .filter-chip:focus-within {
+ padding-right: 0.35rem;
+ border-color: rgba(212, 168, 106, 0.44);
+ }
+
+ .filter-chip:hover button,
+ .filter-chip:focus-within button {
+ width: 18px;
+ margin-left: 0.35rem;
+ opacity: 1;
+ pointer-events: auto;
+ transform: scale(1);
+ }
+
+ .filter-chip button:hover {
+ background: rgba(255, 255, 255, 0.18);
}
.chip-placeholder {
@@ -606,6 +899,15 @@
min-height: 38px;
}
+ input:focus,
+ textarea:focus,
+ select:focus {
+ outline: none;
+ border-color: rgba(120, 163, 235, 0.72);
+ box-shadow: 0 0 0 3px rgba(89, 133, 210, 0.18);
+ background: rgba(255, 255, 255, 0.045);
+ }
+
textarea {
min-height: 108px;
resize: vertical;
@@ -624,6 +926,30 @@
min-width: 840px;
}
+ .request-track-link {
+ border: none;
+ background: transparent;
+ padding: 0;
+ margin: 0;
+ cursor: pointer;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ }
+
+ .request-track-link code {
+ color: #f5dbb5;
+ font-weight: 700;
+ text-decoration: underline;
+ text-decoration-color: rgba(245, 219, 181, 0.35);
+ text-underline-offset: 2px;
+ }
+
+ .request-track-link:hover code,
+ .request-track-link:focus-visible code {
+ text-decoration-color: rgba(245, 219, 181, 0.9);
+ }
+
th, td {
padding: 0.63rem 0.65rem;
border-bottom: 1px solid var(--line);
@@ -779,29 +1105,36 @@
color: #ffd9d9;
}
- .icon-btn::after {
- content: attr(data-tooltip);
- position: absolute;
- left: 50%;
- bottom: calc(100% + 7px);
- transform: translate(-50%, 2px);
+ .icon-btn::after,
+ .has-tooltip::after {
+ content: none;
+ display: none;
+ }
+
+ .global-tooltip-layer {
+ position: fixed;
+ z-index: 2000;
+ transform: translate(-50%, -100%);
background: #081018;
border: 1px solid var(--line);
color: #dce6f5;
font-size: 0.72rem;
+ line-height: 1.3;
+ border-radius: 8px;
+ padding: 0.28rem 0.45rem;
+ box-shadow: 0 8px 18px rgba(0, 0, 0, 0.32);
white-space: nowrap;
- border-radius: 7px;
- padding: 0.24rem 0.4rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
opacity: 0;
pointer-events: none;
- transition: opacity 0.16s ease, transform 0.16s ease;
- z-index: 3;
+ visibility: hidden;
+ transition: opacity 0.12s ease;
}
- .icon-btn:hover::after,
- .icon-btn:focus-visible::after {
+ .global-tooltip-layer.open {
opacity: 1;
- transform: translate(-50%, 0);
+ visibility: visible;
}
.pager {
@@ -1036,12 +1369,54 @@
gap: 0.75rem;
}
+ .request-card-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ margin-bottom: 0.45rem;
+ }
+
+ .request-card-head h3 {
+ margin: 0;
+ }
+
+ .request-card-head-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ }
+
+ .request-card-finance-btn {
+ width: 34px;
+ height: 34px;
+ border-radius: 10px;
+ font-weight: 800;
+ font-size: 0.95rem;
+ color: #f2d39d;
+ border-color: rgba(228, 184, 104, 0.35);
+ background: rgba(219, 162, 67, 0.09);
+ }
+
+ .request-card-data-btn {
+ width: 34px;
+ height: 34px;
+ border-radius: 10px;
+ color: #b8d6ff;
+ border-color: rgba(110, 160, 230, 0.28);
+ background: rgba(92, 139, 214, 0.08);
+ }
+
.request-card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.55rem;
}
+ .request-card-grid-compact {
+ align-items: start;
+ }
+
.request-field {
border: 1px solid var(--line);
border-radius: 10px;
@@ -1051,6 +1426,13 @@
gap: 0.2rem;
}
+ .request-field-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.4rem;
+ }
+
.request-field-label {
font-size: 0.72rem;
letter-spacing: 0.06em;
@@ -1066,6 +1448,43 @@
line-height: 1.35;
}
+ .request-contact-value {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.28rem;
+ width: fit-content;
+ max-width: 100%;
+ cursor: help;
+ border-bottom: 1px dashed rgba(159, 176, 197, 0.45);
+ padding-bottom: 1px;
+ }
+
+ .request-contact-value:hover {
+ border-bottom-color: rgba(229, 196, 132, 0.5);
+ color: #eef5ff;
+ }
+
+ .request-field-span-2 {
+ grid-column: 1 / -1;
+ }
+
+ .request-field-description .request-field-value {
+ white-space: pre-wrap;
+ display: block;
+ min-height: calc(1.35em * 4);
+ max-height: calc(1.35em * 4);
+ overflow: auto;
+ padding-right: 0.1rem;
+ }
+
+ .request-field-expand-btn {
+ width: 28px;
+ height: 28px;
+ border-radius: 8px;
+ flex: 0 0 auto;
+ }
+
.request-description-block,
.request-extra-block {
margin-top: 0.7rem;
@@ -1090,6 +1509,516 @@
overflow: auto;
}
+ .request-finance-modal {
+ width: min(560px, 100%);
+ }
+
+ .request-finance-subtitle {
+ margin: 0.2rem 0 0;
+ }
+
+ .request-finance-grid {
+ margin-top: 0.2rem;
+ }
+
+ .request-data-modal {
+ width: min(860px, 100%);
+ }
+
+ .request-data-summary-modal {
+ width: min(760px, 100%);
+ max-height: min(78vh, 760px);
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ }
+
+ .request-data-summary-list {
+ min-height: 0;
+ overflow-y: auto;
+ display: grid;
+ gap: 0.45rem;
+ padding-right: 0.1rem;
+ }
+
+ .request-data-summary-row {
+ display: grid;
+ grid-template-columns: minmax(180px, 0.9fr) minmax(0, 1.6fr);
+ gap: 0.6rem;
+ align-items: start;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 0.5rem 0.55rem;
+ background: rgba(255, 255, 255, 0.02);
+ }
+
+ .request-data-summary-label {
+ color: #a9bad0;
+ font-size: 0.82rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ }
+
+ .request-data-summary-value {
+ color: #e5eefb;
+ word-break: break-word;
+ white-space: pre-wrap;
+ }
+
+ .request-data-summary-file .chat-message-file-chip {
+ margin-top: 0;
+ }
+
+ .request-status-change-modal {
+ width: min(920px, 100%);
+ max-height: 88vh;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
+
+ .request-status-change-modal form.stack {
+ min-height: 0;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ }
+
+ .request-status-change-modal input,
+ .request-status-change-modal textarea,
+ .request-status-change-modal select {
+ color-scheme: dark;
+ }
+
+ .request-status-change-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(220px, 0.7fr);
+ gap: 0.6rem;
+ align-items: end;
+ }
+
+ .request-status-change-files {
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.02);
+ padding: 0.55rem;
+ display: grid;
+ gap: 0.45rem;
+ }
+
+ .request-status-change-files-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ }
+
+ .request-status-history-block {
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.02);
+ padding: 0.55rem;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.45rem;
+ }
+
+ .request-status-history-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ }
+
+ .request-status-history-list {
+ border: 1px solid rgba(120, 144, 175, 0.18);
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.01);
+ padding: 0.5rem 0.55rem 0.35rem;
+ max-height: 260px;
+ overflow-y: auto;
+ margin: 0;
+ list-style: none;
+ }
+
+ .request-status-history-route-item {
+ padding-left: 2rem;
+ padding-bottom: 0.7rem;
+ }
+
+ .request-status-history-route-item .route-dot {
+ top: 0.32rem;
+ width: 1.04rem;
+ height: 1.04rem;
+ }
+
+ .request-status-history-route-item::before {
+ left: 0.52rem;
+ top: 0.56rem;
+ bottom: -0.16rem;
+ }
+
+ .request-status-history-route-item .route-body {
+ border: 1px solid rgba(120, 144, 175, 0.14);
+ border-radius: 10px;
+ padding: 0.45rem 0.5rem;
+ background: rgba(255, 255, 255, 0.015);
+ display: grid;
+ gap: 0.2rem;
+ }
+
+ .request-status-history-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.45rem;
+ }
+
+ .request-status-history-chip {
+ border-radius: 999px;
+ border: 1px solid rgba(200, 138, 87, 0.35);
+ background: rgba(200, 138, 87, 0.13);
+ color: #f4cfaa;
+ padding: 0.14rem 0.45rem;
+ font-size: 0.7rem;
+ font-weight: 700;
+ white-space: nowrap;
+ }
+
+ .request-status-history-meta {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.55rem;
+ color: #a4b5cb;
+ font-size: 0.78rem;
+ flex-wrap: wrap;
+ }
+
+ .request-status-history-list .route-body b {
+ font-size: 0.88rem;
+ text-transform: none;
+ letter-spacing: normal;
+ }
+
+ .request-status-history-list .route-time {
+ margin-top: 0;
+ font-size: 0.74rem;
+ }
+
+ .request-status-history-comment {
+ color: #dfe9f8;
+ font-size: 0.84rem;
+ white-space: pre-wrap;
+ word-break: break-word;
+ margin-top: 0.1rem;
+ }
+
+ .request-data-modal-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 0.55rem;
+ align-items: end;
+ }
+
+ .request-data-manual-row {
+ grid-template-columns: minmax(0, 1fr) 180px auto;
+ }
+
+ .request-data-modal-actions-inline {
+ display: flex;
+ align-items: flex-end;
+ gap: 0.45rem;
+ flex-wrap: wrap;
+ padding-bottom: 0.05rem;
+ }
+
+ .request-data-combobox {
+ position: relative;
+ z-index: 5;
+ }
+
+ .request-data-template-badge {
+ position: absolute;
+ top: 50%;
+ right: 0.45rem;
+ transform: translateY(-50%);
+ border-radius: 999px;
+ padding: 0.14rem 0.5rem;
+ font-size: 0.7rem;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ border: 1px solid rgba(120, 149, 186, 0.35);
+ background: rgba(47, 66, 96, 0.3);
+ color: #d9e7fb;
+ pointer-events: none;
+ max-width: 52%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .request-data-template-badge.create {
+ color: #cdeecf;
+ border-color: rgba(95, 179, 106, 0.35);
+ background: rgba(65, 137, 77, 0.16);
+ }
+
+ .request-data-template-badge.existing {
+ color: #d7e8ff;
+ border-color: rgba(109, 150, 210, 0.35);
+ background: rgba(70, 110, 170, 0.16);
+ }
+
+ .request-data-template-badge.readonly {
+ color: #ffd6a8;
+ border-color: rgba(214, 153, 83, 0.35);
+ background: rgba(175, 114, 54, 0.16);
+ }
+
+ .request-data-suggest-list {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: calc(100% + 0.3rem);
+ z-index: 80;
+ border: 1px solid rgba(95, 124, 163, 0.55);
+ background: linear-gradient(180deg, rgba(18, 28, 41, 0.98), rgba(13, 20, 31, 0.98));
+ border-radius: 12px;
+ box-shadow: 0 14px 30px rgba(0, 0, 0, 0.32);
+ max-height: 240px;
+ overflow: auto;
+ padding: 0.25rem;
+ display: grid;
+ gap: 0.18rem;
+ }
+
+ .request-data-suggest-item {
+ width: 100%;
+ border: 1px solid transparent;
+ background: rgba(255, 255, 255, 0.02);
+ color: #e7effc;
+ border-radius: 10px;
+ padding: 0.4rem 0.5rem;
+ text-align: left;
+ cursor: pointer;
+ display: grid;
+ gap: 0.1rem;
+ font: inherit;
+ }
+
+ .request-data-suggest-item small {
+ color: #9ab0cc;
+ font-size: 0.72rem;
+ }
+
+ .request-data-suggest-item:hover {
+ border-color: rgba(123, 159, 205, 0.45);
+ background: rgba(75, 111, 163, 0.18);
+ }
+
+ .request-data-rows {
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 0.55rem;
+ background: rgba(255, 255, 255, 0.02);
+ max-height: 260px;
+ overflow: auto;
+ display: grid;
+ gap: 0.45rem;
+ }
+
+ .request-data-row {
+ display: grid;
+ grid-template-columns: 38px minmax(0, 1fr) 170px 38px;
+ gap: 0.45rem;
+ align-items: end;
+ border-bottom: 1px solid rgba(130, 151, 180, 0.1);
+ padding-bottom: 0.45rem;
+ transition: background-color 0.16s ease, border-color 0.16s ease, transform 0.16s ease, box-shadow 0.16s ease;
+ }
+
+ .request-data-row:last-child {
+ border-bottom: none;
+ padding-bottom: 0;
+ }
+
+ .request-data-row.drag-over {
+ background: rgba(77, 126, 199, 0.1);
+ border-color: rgba(112, 161, 228, 0.28);
+ border-radius: 10px;
+ box-shadow: inset 0 0 0 1px rgba(112, 161, 228, 0.22);
+ }
+
+ .request-data-row.dragging {
+ opacity: 0.82;
+ transform: scale(0.995);
+ }
+
+ .request-data-row.row-locked {
+ opacity: 0.96;
+ }
+
+ .request-data-row-controls {
+ display: flex;
+ gap: 0.35rem;
+ align-items: center;
+ justify-content: flex-end;
+ padding-bottom: 0.02rem;
+ }
+
+ .modal-actions-right {
+ justify-content: flex-end;
+ }
+
+ .request-data-row-index-handle,
+ .request-data-row-action-btn {
+ width: 38px;
+ height: 38px;
+ border-radius: 10px;
+ }
+
+ .request-data-row-index-handle {
+ align-self: end;
+ margin-bottom: 0.02rem;
+ color: #bfd0e4;
+ font-weight: 700;
+ font-size: 0.8rem;
+ cursor: grab;
+ }
+
+ .request-data-row-index-handle:active {
+ cursor: grabbing;
+ }
+
+ .request-data-row-index-handle span {
+ display: inline-block;
+ transform: translateY(0.5px);
+ }
+
+ .request-data-row.row-locked .request-data-row-index-handle {
+ cursor: not-allowed;
+ color: #879bb3;
+ }
+
+ .request-data-submit-btn {
+ min-height: 38px;
+ padding: 0.48rem 0.9rem;
+ align-self: flex-end;
+ }
+
+ .request-data-modal .modal-actions {
+ margin-top: 0.35rem;
+ }
+
+ .request-description-modal {
+ width: min(980px, 100%);
+ height: min(86vh, 900px);
+ max-height: 86vh;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
+
+ .request-description-modal-headline {
+ margin-top: 0.25rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ }
+
+ .request-description-modal-headline .request-finance-subtitle {
+ margin: 0;
+ min-width: 0;
+ }
+
+ .request-description-status-chip {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 999px;
+ border: 1px solid rgba(121, 152, 197, 0.38);
+ background: rgba(76, 110, 165, 0.2);
+ color: #deebff;
+ padding: 0.18rem 0.55rem;
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ white-space: nowrap;
+ }
+
+ .request-description-modal-body {
+ display: grid;
+ grid-template-rows: minmax(0, 1fr) auto;
+ gap: 0.55rem;
+ min-height: 0;
+ flex: 1 1 auto;
+ overflow: hidden;
+ }
+
+ .request-description-modal-main {
+ min-height: 0;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.02);
+ padding: 0.65rem;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ gap: 0.45rem;
+ min-height: 0;
+ }
+
+ .request-description-modal-title {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ }
+
+ .request-description-modal-text {
+ padding: 0.5rem 0.2rem 0.55rem 0.1rem;
+ color: #dfe9f8;
+ line-height: 1.5;
+ white-space: pre-wrap;
+ word-break: break-word;
+ overflow: auto;
+ font-size: 0.95rem;
+ }
+
+ .request-description-modal-meta-wrap {
+ border: 1px solid rgba(130, 151, 180, 0.14);
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.015);
+ padding: 0.55rem 0.65rem;
+ }
+
+ .request-description-modal-meta {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.45rem 0.8rem;
+ align-content: start;
+ }
+
+ .request-description-meta-item {
+ min-width: 0;
+ display: grid;
+ gap: 0.18rem;
+ align-content: start;
+ padding: 0.05rem 0;
+ }
+
+ .request-description-meta-item.align-right {
+ justify-items: end;
+ text-align: right;
+ }
+
+ .request-description-meta-item .request-field-value {
+ font-size: 0.92rem;
+ }
+
.request-status-route {
margin-top: 0.85rem;
padding-top: 0.8rem;
@@ -1339,6 +2268,90 @@
text-align: right;
}
+ .chat-request-data-bubble {
+ cursor: pointer;
+ border-color: rgba(221, 168, 87, 0.42);
+ background: linear-gradient(165deg, rgba(96, 69, 20, 0.92), rgba(58, 41, 14, 0.96));
+ }
+
+ .chat-message.outgoing .chat-request-data-bubble {
+ border-color: rgba(228, 180, 96, 0.52);
+ background: linear-gradient(165deg, rgba(123, 86, 26, 0.95), rgba(85, 59, 18, 0.98));
+ }
+
+ .chat-request-data-bubble.all-filled {
+ border-color: rgba(87, 177, 113, 0.42);
+ background: linear-gradient(165deg, rgba(36, 96, 52, 0.92), rgba(24, 66, 35, 0.96));
+ }
+
+ .chat-request-data-head {
+ font-size: 0.84rem;
+ font-weight: 800;
+ color: #ffe0a6;
+ margin-bottom: 0.2rem;
+ }
+
+ .chat-request-data-bubble.all-filled .chat-request-data-head {
+ color: #d3f4dc;
+ margin-bottom: 0.08rem;
+ }
+
+ .chat-request-data-list {
+ display: grid;
+ gap: 0.16rem;
+ max-height: 11.6rem;
+ overflow: hidden;
+ }
+
+ .chat-request-data-item {
+ display: flex;
+ align-items: baseline;
+ gap: 0.3rem;
+ font-size: 0.8rem;
+ color: #edf1f8;
+ }
+
+ .chat-request-data-item.filled .chat-request-data-label {
+ text-decoration: line-through;
+ color: #c4cfde;
+ }
+
+ .chat-request-data-index {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.15rem;
+ min-width: 2rem;
+ color: #ffe1af;
+ font-weight: 700;
+ }
+
+ .chat-request-data-check {
+ color: #66db87;
+ font-weight: 900;
+ }
+
+ .chat-request-data-collapsed {
+ margin: 0;
+ color: #d3f5dc;
+ font-weight: 800;
+ font-size: 0.86rem;
+ }
+
+ .chat-request-data-more {
+ color: #c2cfde;
+ font-size: 0.78rem;
+ font-weight: 700;
+ letter-spacing: 0.01em;
+ padding-left: 2.02rem;
+ }
+
+ @media (max-width: 900px) {
+ .request-data-summary-row {
+ grid-template-columns: 1fr;
+ gap: 0.25rem;
+ }
+ }
+
.chat-message-files {
margin-top: 0.35rem;
display: flex;
@@ -1527,6 +2540,21 @@
border: none;
}
+ .request-preview-text {
+ width: 100%;
+ min-height: min(60vh, 520px);
+ margin: 0;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: rgba(10, 16, 24, 0.88);
+ color: #dbe7f8;
+ padding: 0.7rem;
+ overflow: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
+ font: 500 0.86rem/1.45 "JetBrains Mono", "Fira Code", monospace;
+ }
+
.request-preview-image {
max-width: 100%;
max-height: 72vh;
@@ -1555,6 +2583,12 @@
font-size: 0.96rem;
}
+ .workspace-head-icon-glyph {
+ display: inline-block;
+ transform: rotate(180deg);
+ line-height: 1;
+ }
+
.overlay {
position: fixed;
inset: 0;
@@ -1633,6 +2667,7 @@
@media (max-width: 1160px) {
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .lawyer-dashboard-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.kanban-column {
flex-basis: calc(50% - 0.375rem);
min-width: 240px;
@@ -1643,6 +2678,12 @@
.request-modal-grid { grid-template-columns: 1fr; }
.request-workspace-layout { grid-template-columns: 1fr; }
.request-card-grid { grid-template-columns: 1fr; }
+ .request-description-modal-body { grid-template-rows: minmax(0, 1fr) auto; }
+ .request-description-modal-meta { grid-template-columns: 1fr; }
+ .request-description-meta-item.align-right {
+ justify-items: start;
+ text-align: left;
+ }
}
@media (max-width: 920px) {
@@ -1658,6 +2699,20 @@
.filters {
grid-template-columns: 1fr;
}
+ .lawyer-dashboard-grid { grid-template-columns: 1fr; }
+ .lawyer-dashboard-card {
+ grid-template-columns: 1fr;
+ }
+ .lawyer-dashboard-left {
+ align-items: flex-start;
+ text-align: left;
+ }
+ .lawyer-dashboard-modal-summary {
+ grid-template-columns: 1fr;
+ }
+ .lawyer-dashboard-modal-metrics {
+ grid-template-columns: 1fr;
+ }
.kanban-column {
flex-basis: 100%;
min-width: 0;
diff --git a/app/web/admin.jsx b/app/web/admin.jsx
index f7a6198..95acffe 100644
--- a/app/web/admin.jsx
+++ b/app/web/admin.jsx
@@ -1,522 +1,69 @@
+import {
+ DEFAULT_FORM_FIELD_TYPES,
+ INVOICE_STATUS_LABELS,
+ LS_TOKEN,
+ OPERATOR_LABELS,
+ ROLE_LABELS,
+ STATUS_LABELS,
+ STATUS_KIND_LABELS,
+ TABLE_KEY_ALIASES,
+ TABLE_MUTATION_CONFIG,
+ TABLE_SERVER_CONFIG,
+ TABLE_UNALIASES,
+ PAGE_SIZE,
+} from "./admin/shared/constants.js";
+import { createTableState } from "./admin/shared/state.js";
+import { KanbanBoard } from "./admin/features/kanban/KanbanBoard.jsx";
+import { ConfigSection } from "./admin/features/config/ConfigSection.jsx";
+import { DashboardSection } from "./admin/features/dashboard/DashboardSection.jsx";
+import { InvoicesSection } from "./admin/features/invoices/InvoicesSection.jsx";
+import { RequestsSection } from "./admin/features/requests/RequestsSection.jsx";
+import { QuotesSection } from "./admin/features/quotes/QuotesSection.jsx";
+import { RequestWorkspace } from "./admin/features/requests/RequestWorkspace.jsx";
+import { AvailableTablesSection } from "./admin/features/tables/AvailableTablesSection.jsx";
+import { useAdminApi } from "./admin/hooks/useAdminApi.js";
+import { useAdminCatalogLoaders } from "./admin/hooks/useAdminCatalogLoaders.js";
+import { useKanban } from "./admin/hooks/useKanban.js";
+import { useRequestWorkspace } from "./admin/hooks/useRequestWorkspace.js";
+import { useTableActions } from "./admin/hooks/useTableActions.js";
+import { useTableFilterActions } from "./admin/hooks/useTableFilterActions.js";
+import { useTablesState } from "./admin/hooks/useTablesState.js";
+import {
+ avatarColor,
+ boolFilterLabel,
+ buildUniversalQuery,
+ canAccessSection,
+ decodeJwtPayload,
+ detectAttachmentPreviewKind,
+ fallbackStatusGroup,
+ fmtAmount,
+ fmtBytes,
+ fmtDateOnly,
+ fmtKanbanDate,
+ fmtTimeOnly,
+ getOperatorsForType,
+ humanizeKey,
+ localizeMeta,
+ localizeRequestDetails,
+ metaKindToFilterType,
+ metaKindToRecordType,
+ normalizeReferenceMeta,
+ normalizeStringList,
+ resolveAdminObjectSrc,
+ resolveAdminRoute,
+ resolveAvatarSrc,
+ resolveDeadlineTone,
+ roleLabel,
+ sortByName,
+ statusLabel,
+ translateApiError,
+ userInitials,
+} from "./admin/shared/utils.js";
+
(function () {
- const { useCallback, useEffect, useMemo, useRef, useState } = React;
-
- const LS_TOKEN = "admin_access_token";
- const PAGE_SIZE = 50;
- const DEFAULT_FORM_FIELD_TYPES = ["string", "text", "number", "boolean", "date"];
- const ALL_OPERATORS = ["=", "!=", ">", "<", ">=", "<=", "~"];
- const OPERATOR_LABELS = {
- "=": "=",
- "!=": "!=",
- ">": ">",
- "<": "<",
- ">=": ">=",
- "<=": "<=",
- "~": "~",
- };
-
- const ROLE_LABELS = {
- ADMIN: "Администратор",
- LAWYER: "Юрист",
- };
-
- const STATUS_LABELS = {
- NEW: "Новая",
- IN_PROGRESS: "В работе",
- WAITING_CLIENT: "Ожидание клиента",
- WAITING_COURT: "Ожидание суда",
- RESOLVED: "Решена",
- CLOSED: "Закрыта",
- REJECTED: "Отклонена",
- };
- const INVOICE_STATUS_LABELS = {
- WAITING_PAYMENT: "Ожидает оплату",
- PAID: "Оплачен",
- CANCELED: "Отменен",
- };
- const STATUS_KIND_LABELS = {
- DEFAULT: "Обычный",
- INVOICE: "Выставление счета",
- PAID: "Оплачено",
- };
-
- const REQUEST_UPDATE_EVENT_LABELS = {
- MESSAGE: "сообщение",
- ATTACHMENT: "файл",
- STATUS: "статус",
- };
- const KANBAN_GROUPS = [
- { key: "NEW", label: "Новые" },
- { key: "IN_PROGRESS", label: "В работе" },
- { key: "WAITING", label: "Ожидание" },
- { key: "DONE", label: "Завершены" },
- ];
-
- const TABLE_SERVER_CONFIG = {
- requests: {
- table: "requests",
- endpoint: "/api/admin/crud/requests/query",
- sort: [{ field: "created_at", dir: "desc" }],
- },
- invoices: {
- table: "invoices",
- endpoint: "/api/admin/invoices/query",
- sort: [{ field: "issued_at", dir: "desc" }],
- },
- quotes: {
- table: "quotes",
- endpoint: "/api/admin/crud/quotes/query",
- sort: [{ field: "sort_order", dir: "asc" }],
- },
- topics: {
- table: "topics",
- endpoint: "/api/admin/crud/topics/query",
- sort: [{ field: "sort_order", dir: "asc" }],
- },
- statuses: {
- table: "statuses",
- endpoint: "/api/admin/crud/statuses/query",
- sort: [{ field: "sort_order", dir: "asc" }],
- },
- formFields: {
- table: "form_fields",
- endpoint: "/api/admin/crud/form_fields/query",
- sort: [{ field: "sort_order", dir: "asc" }],
- },
- topicRequiredFields: {
- table: "topic_required_fields",
- endpoint: "/api/admin/crud/topic_required_fields/query",
- sort: [{ field: "sort_order", dir: "asc" }],
- },
- topicDataTemplates: {
- table: "topic_data_templates",
- endpoint: "/api/admin/crud/topic_data_templates/query",
- sort: [{ field: "sort_order", dir: "asc" }],
- },
- statusTransitions: {
- table: "topic_status_transitions",
- endpoint: "/api/admin/crud/topic_status_transitions/query",
- sort: [{ field: "sort_order", dir: "asc" }],
- },
- users: {
- table: "admin_users",
- endpoint: "/api/admin/crud/admin_users/query",
- sort: [{ field: "created_at", dir: "desc" }],
- },
- userTopics: {
- table: "admin_user_topics",
- endpoint: "/api/admin/crud/admin_user_topics/query",
- sort: [{ field: "created_at", dir: "desc" }],
- },
- };
-
- const TABLE_MUTATION_CONFIG = Object.fromEntries(
- Object.entries(TABLE_SERVER_CONFIG).map(([tableKey, config]) => [
- tableKey,
- {
- create: "/api/admin/crud/" + config.table,
- update: (id) => "/api/admin/crud/" + config.table + "/" + id,
- delete: (id) => "/api/admin/crud/" + config.table + "/" + id,
- },
- ])
- );
- TABLE_MUTATION_CONFIG.invoices = {
- create: "/api/admin/invoices",
- update: (id) => "/api/admin/invoices/" + id,
- delete: (id) => "/api/admin/invoices/" + id,
- };
- const TABLE_KEY_ALIASES = {
- form_fields: "formFields",
- status_groups: "statusGroups",
- topic_required_fields: "topicRequiredFields",
- topic_data_templates: "topicDataTemplates",
- topic_status_transitions: "statusTransitions",
- admin_users: "users",
- admin_user_topics: "userTopics",
- };
- const TABLE_UNALIASES = Object.fromEntries(Object.entries(TABLE_KEY_ALIASES).map(([table, alias]) => [alias, table]));
- const KNOWN_CONFIG_TABLE_KEYS = new Set([
- "quotes",
- "topics",
- "statuses",
- "formFields",
- "topicRequiredFields",
- "topicDataTemplates",
- "statusTransitions",
- "users",
- "userTopics",
- ]);
-
- function createTableState() {
- return {
- filters: [],
- sort: null,
- offset: 0,
- total: 0,
- showAll: false,
- rows: [],
- };
- }
-
- function createRequestModalState() {
- return {
- loading: false,
- requestId: null,
- trackNumber: "",
- requestData: null,
- statusRouteNodes: [],
- messages: [],
- attachments: [],
- messageDraft: "",
- selectedFiles: [],
- fileUploading: false,
- };
- }
-
- function resolveAdminRoute(search) {
- const params = new URLSearchParams(String(search || ""));
- const section = String(params.get("section") || "").trim();
- const view = String(params.get("view") || "").trim();
- const requestId = String(params.get("requestId") || "").trim();
- return { section, view, requestId };
- }
-
- function humanizeKey(value) {
- const text = String(value || "")
- .replace(/[_-]+/g, " ")
- .replace(/\s+/g, " ")
- .trim();
- if (!text) return "-";
- return text.charAt(0).toUpperCase() + text.slice(1);
- }
-
- function metaKindToFilterType(kind) {
- if (kind === "boolean") return "boolean";
- if (kind === "number") return "number";
- if (kind === "date" || kind === "datetime") return "date";
- return "text";
- }
-
- function metaKindToRecordType(kind) {
- if (kind === "boolean") return "boolean";
- if (kind === "number") return "number";
- if (kind === "json") return "json";
- return "text";
- }
-
- function decodeJwtPayload(token) {
- try {
- const payload = token.split(".")[1] || "";
- const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
- const json = decodeURIComponent(
- atob(base64)
- .split("")
- .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
- .join("")
- );
- return JSON.parse(json);
- } catch (_) {
- return null;
- }
- }
-
- function sortByName(items) {
- return [...items].sort((a, b) => String(a.name || a.code || "").localeCompare(String(b.name || b.code || ""), "ru"));
- }
-
- function roleLabel(role) {
- return ROLE_LABELS[role] || role || "-";
- }
-
- function statusLabel(code) {
- return STATUS_LABELS[code] || code || "-";
- }
-
- function invoiceStatusLabel(code) {
- return INVOICE_STATUS_LABELS[code] || code || "-";
- }
-
- function statusKindLabel(code) {
- return STATUS_KIND_LABELS[code] || code || "-";
- }
-
- function fallbackStatusGroup(statusCode) {
- const code = String(statusCode || "").toUpperCase();
- if (!code) return "NEW";
- if (code.startsWith("NEW")) return "NEW";
- if (code.includes("WAIT") || code.includes("PEND") || code.includes("HOLD")) return "WAITING";
- if (code.includes("CLOSE") || code.includes("RESOLV") || code.includes("REJECT") || code.includes("DONE") || code.includes("PAID")) return "DONE";
- return "IN_PROGRESS";
- }
-
- function boolLabel(value) {
- return value ? "Да" : "Нет";
- }
-
- function boolFilterLabel(value) {
- return value ? "True" : "False";
- }
-
- function fmtDate(value) {
- if (!value) return "-";
- const date = new Date(value);
- return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString("ru-RU");
- }
-
- function fmtDateOnly(value) {
- if (!value) return "-";
- const date = new Date(value);
- return Number.isNaN(date.getTime())
- ? String(value)
- : date.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric" });
- }
-
- function fmtTimeOnly(value) {
- if (!value) return "-";
- const date = new Date(value);
- return Number.isNaN(date.getTime())
- ? String(value)
- : date.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
- }
-
- function fmtKanbanDate(value) {
- if (!value) return "-";
- const date = new Date(value);
- if (Number.isNaN(date.getTime())) return String(value);
- const dd = String(date.getDate()).padStart(2, "0");
- const mm = String(date.getMonth() + 1).padStart(2, "0");
- const yy = String(date.getFullYear()).slice(-2);
- const hh = String(date.getHours()).padStart(2, "0");
- const mi = String(date.getMinutes()).padStart(2, "0");
- return dd + ":" + mm + ":" + yy + " " + hh + ":" + mi;
- }
-
- function resolveDeadlineTone(value) {
- if (!value) return "ok";
- const time = new Date(value).getTime();
- if (!Number.isFinite(time)) return "ok";
- const delta = time - Date.now();
- const fourDaysMs = 4 * 24 * 60 * 60 * 1000;
- const oneDayMs = 24 * 60 * 60 * 1000;
- if (delta > fourDaysMs) return "ok";
- if (delta > oneDayMs) return "warn";
- return "danger";
- }
-
- function fmtAmount(value) {
- if (value === null || value === undefined || value === "") return "-";
- const numeric = Number(value);
- if (!Number.isFinite(numeric)) return String(value);
- return numeric.toLocaleString("ru-RU");
- }
-
- function fmtBytes(value) {
- const size = Number(value || 0);
- if (!Number.isFinite(size) || size <= 0) return "0 Б";
- const units = ["Б", "КБ", "МБ", "ГБ"];
- let index = 0;
- let normalized = size;
- while (normalized >= 1024 && index < units.length - 1) {
- normalized /= 1024;
- index += 1;
- }
- return normalized.toLocaleString("ru-RU", { maximumFractionDigits: index === 0 ? 0 : 1 }) + " " + units[index];
- }
-
- function normalizeStringList(value) {
- if (!Array.isArray(value)) return [];
- const out = [];
- const seen = new Set();
- value.forEach((item) => {
- const text = String(item || "").trim();
- if (!text) return;
- const key = text.toLowerCase();
- if (seen.has(key)) return;
- seen.add(key);
- out.push(text);
- });
- return out;
- }
-
- function listPreview(value, emptyLabel) {
- const items = normalizeStringList(value);
- return items.length ? items.join(", ") : emptyLabel;
- }
-
- function normalizeReferenceMeta(raw) {
- if (!raw || typeof raw !== "object") return null;
- const table = String(raw.table || "").trim();
- const valueField = String(raw.value_field || "id").trim() || "id";
- const labelField = String(raw.label_field || valueField).trim() || valueField;
- if (!table) return null;
- return { table, value_field: valueField, label_field: labelField };
- }
-
- function userInitials(name, email) {
- const source = String(name || "").trim();
- if (source) {
- const parts = source.split(/\s+/).filter(Boolean);
- if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
- return source.slice(0, 2).toUpperCase();
- }
- const mail = String(email || "").trim();
- return (mail.slice(0, 2) || "U").toUpperCase();
- }
-
- function avatarColor(seed) {
- const palette = ["#6f8fa9", "#568f7d", "#a07a5c", "#7d6ea9", "#8f6f8f", "#7f8c5a"];
- const text = String(seed || "");
- let hash = 0;
- for (let i = 0; i < text.length; i += 1) hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
- return palette[hash % palette.length];
- }
-
- function resolveAvatarSrc(avatarUrl, accessToken) {
- const raw = String(avatarUrl || "").trim();
- if (!raw) return "";
- if (raw.startsWith("s3://")) {
- const key = raw.slice("s3://".length);
- if (!key || !accessToken) return "";
- return "/api/admin/uploads/object/" + encodeURIComponent(key) + "?token=" + encodeURIComponent(accessToken);
- }
- return raw;
- }
-
- function resolveAdminObjectSrc(s3Key, accessToken) {
- const key = String(s3Key || "").trim();
- if (!key || !accessToken) return "";
- return "/api/admin/uploads/object/" + encodeURIComponent(key) + "?token=" + encodeURIComponent(accessToken);
- }
-
- function detectAttachmentPreviewKind(fileName, mimeType) {
- const name = String(fileName || "").toLowerCase();
- const mime = String(mimeType || "").toLowerCase();
- if (mime.startsWith("image/") || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(name)) return "image";
- if (mime.startsWith("video/") || /\.(mp4|webm|ogg|mov|m4v)$/.test(name)) return "video";
- if (mime === "application/pdf" || /\.pdf$/.test(name)) return "pdf";
- return "none";
- }
-
- function buildUniversalQuery(filters, sort, limit, offset) {
- return {
- filters: filters || [],
- sort: sort || [],
- page: { limit: limit ?? PAGE_SIZE, offset: offset ?? 0 },
- };
- }
-
- function canAccessSection(role, section) {
- const allowed = new Set(["dashboard", "kanban", "requests", "requestWorkspace", "invoices", "meta", "quotes", "config", "availableTables"]);
- if (!allowed.has(section)) return false;
- if (section === "quotes" || section === "config" || section === "availableTables") return role === "ADMIN";
- return true;
- }
-
- function translateApiError(message) {
- const direct = {
- "Missing auth token": "Отсутствует токен авторизации",
- "Missing bearer token": "Отсутствует токен авторизации",
- "Invalid token": "Некорректный токен",
- Forbidden: "Недостаточно прав",
- "Invalid credentials": "Неверный логин или пароль",
- "Request not found": "Заявка не найдена",
- "Quote not found": "Цитата не найдена",
- not_found: "Запись не найдена",
- };
- if (direct[message]) return direct[message];
- if (String(message).startsWith("HTTP ")) return "Ошибка сервера (" + message + ")";
- return message;
- }
-
- function getOperatorsForType(type) {
- if (type === "number" || type === "date" || type === "datetime") return ["=", "!=", ">", "<", ">=", "<="];
- if (type === "boolean" || type === "reference" || type === "enum") return ["=", "!="];
- return [...ALL_OPERATORS];
- }
-
- function localizeRequestDetails(row) {
- return {
- ID: row.id || null,
- "Номер заявки": row.track_number || null,
- Клиент: row.client_name || null,
- Телефон: row.client_phone || null,
- "Тема (код)": row.topic_code || null,
- Статус: statusLabel(row.status_code),
- Описание: row.description || null,
- "Дополнительные поля": row.extra_fields || {},
- "Назначенный юрист (ID)": row.assigned_lawyer_id || null,
- "Ставка (фикс.)": row.effective_rate ?? null,
- "Сумма счета": row.invoice_amount ?? null,
- "Оплачено": row.paid_at ? fmtDate(row.paid_at) : null,
- "Оплату подтвердил (ID)": row.paid_by_admin_id || null,
- "Непрочитано клиентом": boolLabel(Boolean(row.client_has_unread_updates)),
- "Тип обновления для клиента": row.client_unread_event_type ? (REQUEST_UPDATE_EVENT_LABELS[row.client_unread_event_type] || row.client_unread_event_type) : null,
- "Непрочитано юристом": boolLabel(Boolean(row.lawyer_has_unread_updates)),
- "Тип обновления для юриста": row.lawyer_unread_event_type ? (REQUEST_UPDATE_EVENT_LABELS[row.lawyer_unread_event_type] || row.lawyer_unread_event_type) : null,
- "Общий размер вложений (байт)": row.total_attachments_bytes ?? 0,
- Создано: fmtDate(row.created_at),
- Обновлено: fmtDate(row.updated_at),
- };
- }
-
- function renderRequestUpdatesCell(row, role) {
- if (role === "LAWYER") {
- const has = Boolean(row.lawyer_has_unread_updates);
- const eventType = String(row.lawyer_unread_event_type || "").toUpperCase();
- return has ? (
-
-
- {REQUEST_UPDATE_EVENT_LABELS[eventType] || "обновление"}
-
- ) : (
- нет
- );
- }
-
- const clientHas = Boolean(row.client_has_unread_updates);
- const clientType = String(row.client_unread_event_type || "").toUpperCase();
- const lawyerHas = Boolean(row.lawyer_has_unread_updates);
- const lawyerType = String(row.lawyer_unread_event_type || "").toUpperCase();
-
- if (!clientHas && !lawyerHas) return нет;
- return (
-
- {clientHas ? (
-
-
- {"Клиент: " + (REQUEST_UPDATE_EVENT_LABELS[clientType] || "обновление")}
-
- ) : null}
- {lawyerHas ? (
-
-
- {"Юрист: " + (REQUEST_UPDATE_EVENT_LABELS[lawyerType] || "обновление")}
-
- ) : null}
-
- );
- }
-
- function localizeMeta(data) {
- const fieldTypeMap = {
- string: "строка",
- text: "текст",
- boolean: "булево",
- number: "число",
- date: "дата",
- };
- return {
- Сущность: data.entity,
- Поля: (data.fields || []).map((field) => ({
- "Код поля": field.field_name,
- Название: field.label,
- Тип: fieldTypeMap[field.type] || field.type,
- Обязательное: boolLabel(field.required),
- "Только чтение": boolLabel(field.read_only),
- "Редактируемые роли": (field.editable_roles || []).map(roleLabel),
- })),
- };
- }
+const { useCallback, useEffect, useMemo, useRef, useState } = React;
+const LEGACY_HIDDEN_DICTIONARY_TABLES = new Set(["formFields", "topicRequiredFields", "statusTransitions"]);
+const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
function StatusLine({ status }) {
return
{status?.message || ""}
;
@@ -946,17 +493,41 @@
function AttachmentPreviewModal({ open, title, url, fileName, mimeType, onClose }) {
const [resolvedUrl, setResolvedUrl] = useState("");
+ const [resolvedText, setResolvedText] = useState("");
+ const [resolvedKind, setResolvedKind] = useState("");
+ const [hint, setHint] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
+ const decodeTextPreview = (arrayBuffer) => {
+ const bytes = new Uint8Array(arrayBuffer || new ArrayBuffer(0));
+ const sampleLength = Math.min(bytes.length, 4096);
+ let suspicious = 0;
+ for (let i = 0; i < sampleLength; i += 1) {
+ const byte = bytes[i];
+ if (byte === 0) suspicious += 4;
+ else if (byte < 9 || (byte > 13 && byte < 32)) suspicious += 1;
+ }
+ if (sampleLength && suspicious / sampleLength > 0.08) return null;
+ const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes).replace(/\u0000/g, "");
+ const normalized = text.length > 200000 ? text.slice(0, 200000) + "\n\n[Текст обрезан для предпросмотра]" : text;
+ return normalized;
+ };
+
useEffect(() => {
if (!open || !url) {
setResolvedUrl("");
+ setResolvedText("");
+ setResolvedKind("");
+ setHint("");
setLoading(false);
setError("");
return;
}
const kind = detectAttachmentPreviewKind(fileName, mimeType);
+ setResolvedKind(kind);
+ setResolvedText("");
+ setHint("");
if (kind === "none") {
setResolvedUrl("");
setLoading(false);
@@ -974,13 +545,54 @@
try {
const response = await fetch(url, { credentials: "same-origin" });
if (!response.ok) throw new Error("Не удалось загрузить файл для предпросмотра");
- const blob = await response.blob();
+ const buffer = await response.arrayBuffer();
+ if (cancelled) return;
+
+ if (kind === "pdf") {
+ const header = new Uint8Array(buffer.slice(0, 5));
+ const isPdf =
+ header.length >= 5 &&
+ header[0] === 0x25 &&
+ header[1] === 0x50 &&
+ header[2] === 0x44 &&
+ header[3] === 0x46 &&
+ header[4] === 0x2d;
+ if (isPdf) {
+ setResolvedUrl(String(url));
+ setResolvedKind("pdf");
+ setLoading(false);
+ return;
+ }
+ const textPreview = decodeTextPreview(buffer);
+ if (textPreview != null) {
+ setResolvedUrl("");
+ setResolvedText(textPreview);
+ setResolvedKind("text");
+ setHint("Файл помечен как PDF, но не является валидным PDF. Показан текстовый предпросмотр.");
+ setLoading(false);
+ return;
+ }
+ throw new Error("Файл помечен как PDF, но не является валидным PDF-документом.");
+ }
+
+ if (kind === "text") {
+ const textPreview = decodeTextPreview(buffer);
+ if (textPreview == null) throw new Error("Не удалось распознать текстовый файл для предпросмотра.");
+ setResolvedUrl("");
+ setResolvedText(textPreview);
+ setResolvedKind("text");
+ setLoading(false);
+ return;
+ }
+
+ const blob = new Blob([buffer], { type: response.headers.get("content-type") || mimeType || "application/octet-stream" });
objectUrl = URL.createObjectURL(blob);
if (cancelled) {
URL.revokeObjectURL(objectUrl);
return;
}
setResolvedUrl(objectUrl);
+ setResolvedKind(kind);
setLoading(false);
} catch (err) {
if (cancelled) return;
@@ -996,7 +608,7 @@
}, [fileName, mimeType, open, url]);
if (!open || !url) return null;
- const kind = detectAttachmentPreviewKind(fileName, mimeType);
+ const kind = resolvedKind || detectAttachmentPreviewKind(fileName, mimeType);
return (
event.target.id === "request-file-preview-overlay" && onClose()}>
event.stopPropagation()}>
@@ -1025,6 +637,7 @@
{loading ?
Загрузка предпросмотра...
: null}
+ {!loading && !error && hint ?
{hint}
: null}
{error ?
{error}
: null}
{!loading && !error && kind === "image" && resolvedUrl ? (

@@ -1035,6 +648,9 @@
{!loading && !error && kind === "pdf" && resolvedUrl ? (
) : null}
+ {!loading && !error && kind === "text" ? (
+
{resolvedText || "Файл пуст."}
+ ) : null}
{kind === "none" ?
Для этого типа файла доступно только открытие или скачивание.
: null}
@@ -1042,704 +658,22 @@
);
}
- function KanbanBoard({
- loading,
- columns,
- rows,
- role,
- actorId,
- filters,
- onRefresh,
- onOpenFilter,
- onRemoveFilter,
- onEditFilter,
- getFilterChipLabel,
- onOpenSort,
- sortActive,
- onOpenRequest,
- onClaimRequest,
- onMoveRequest,
- status,
- }) {
- const [draggingId, setDraggingId] = useState("");
- const [dragOverGroup, setDragOverGroup] = useState("");
-
- const safeColumns = Array.isArray(columns) && columns.length ? columns : KANBAN_GROUPS;
- const grouped = useMemo(() => {
- const map = {};
- safeColumns.forEach((column) => {
- map[String(column.key)] = [];
- });
- (rows || []).forEach((row) => {
- const group = String(row?.status_group || fallbackStatusGroup(row?.status_code));
- if (!map[group]) map[group] = [];
- map[group].push(row);
- });
- return map;
- }, [rows, safeColumns]);
-
- const rowMap = useMemo(() => {
- const map = new Map();
- (rows || []).forEach((row) => {
- if (!row?.id) return;
- map.set(String(row.id), row);
- });
- return map;
- }, [rows]);
-
- const onDropToGroup = (event, groupKey) => {
- event.preventDefault();
- const requestId = String(event.dataTransfer.getData("text/plain") || draggingId || "");
- setDragOverGroup("");
- setDraggingId("");
- if (!requestId) return;
- const row = rowMap.get(requestId);
- if (!row) return;
- onMoveRequest(row, String(groupKey || ""));
- };
-
- return (
-
-
-
-
Канбан заявок
-
Группировка по группам статусов и серверная фильтрация карточек.
-
-
-
-
-
-
-
-
- {safeColumns.map((column) => {
- const key = String(column.key || "");
- const cards = grouped[key] || [];
- const isOver = dragOverGroup === key;
- return (
-
{
- event.preventDefault();
- setDragOverGroup(key);
- }}
- onDragLeave={(event) => {
- if (event.currentTarget.contains(event.relatedTarget)) return;
- setDragOverGroup((prev) => (prev === key ? "" : prev));
- }}
- onDrop={(event) => onDropToGroup(event, key)}
- >
-
- {column.label || key}
- {Number(column.total ?? cards.length)}
-
-
- {cards.length ? (
- cards.map((row) => {
- const requestId = String(row.id || "");
- const isUnassigned = !String(row.assigned_lawyer_id || "").trim();
- const canClaim = role === "LAWYER" && isUnassigned;
- const canMove =
- role === "ADMIN" ||
- (!isUnassigned && String(row.assigned_lawyer_id || "").trim() === String(actorId || "").trim());
- const transitionOptions = Array.isArray(row.available_transitions) ? row.available_transitions : [];
- const deadline = row.sla_deadline_at || row.case_deadline_at || "";
- const deadlineTone = resolveDeadlineTone(deadline);
- const unreadTypes = new Set();
- if (role === "LAWYER") {
- if (row.lawyer_has_unread_updates && row.lawyer_unread_event_type) unreadTypes.add(String(row.lawyer_unread_event_type).toUpperCase());
- } else {
- if (row.client_has_unread_updates && row.client_unread_event_type) unreadTypes.add(String(row.client_unread_event_type).toUpperCase());
- if (row.lawyer_has_unread_updates && row.lawyer_unread_event_type) unreadTypes.add(String(row.lawyer_unread_event_type).toUpperCase());
- }
- const hasUnreadMessage = unreadTypes.has("MESSAGE");
- const hasUnreadAttachment = unreadTypes.has("ATTACHMENT");
- return (
-
onOpenRequest(requestId, event)}
- onKeyDown={(event) => {
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- onOpenRequest(requestId, event);
- }
- }}
- onDragStart={(event) => {
- if (!canMove) {
- event.preventDefault();
- return;
- }
- setDraggingId(requestId);
- event.dataTransfer.effectAllowed = "move";
- event.dataTransfer.setData("text/plain", requestId);
- }}
- onDragEnd={() => {
- setDraggingId("");
- setDragOverGroup("");
- }}
- >
-
- {row.track_number || "-"}
-
- {row.status_name || statusLabel(row.status_code)}
-
-
- {String(row.description || "Описание не заполнено")}
-
- {row.client_name || "-"}
- {fmtKanbanDate(row.created_at)}
-
-
- {row.topic_code || "-"}
- {row.assigned_lawyer_name || (isUnassigned ? "Не назначено" : row.assigned_lawyer_id || "-")}
-
-
-
-
- 💬
-
-
- 📎
-
-
-
{deadline ? fmtKanbanDate(deadline) : "—"}
-
- event.stopPropagation()}
- onMouseDown={(event) => event.stopPropagation()}
- >
- {canClaim ? (
-
- ) : null}
- {canMove && transitionOptions.length ? (
-
- ) : null}
-
-
- );
- })
- ) : (
-
Пусто
- )}
-
-
- );
- })}
-
-
-
- );
- }
-
- function RequestWorkspace({
- loading,
- trackNumber,
- requestData,
- statusRouteNodes,
- messages,
- attachments,
- messageDraft,
- selectedFiles,
- fileUploading,
- status,
- onMessageChange,
- onSendMessage,
- onFilesSelect,
- onRemoveSelectedFile,
- onClearSelectedFiles,
- }) {
- const [preview, setPreview] = useState({ open: false, url: "", fileName: "", mimeType: "" });
- const [chatTab, setChatTab] = useState("chat");
- const [dropActive, setDropActive] = useState(false);
- const fileInputRef = useRef(null);
-
- const openPreview = (item) => {
- if (!item?.download_url) return;
- setPreview({
- open: true,
- url: String(item.download_url),
- fileName: String(item.file_name || ""),
- mimeType: String(item.mime_type || ""),
- });
- };
-
- const closePreview = () => setPreview({ open: false, url: "", fileName: "", mimeType: "" });
- const pendingFiles = Array.isArray(selectedFiles) ? selectedFiles : [];
- const hasPendingFiles = pendingFiles.length > 0;
- const canSubmit = Boolean(String(messageDraft || "").trim() || hasPendingFiles);
-
- const onInputFiles = (event) => {
- const files = Array.from((event.target && event.target.files) || []);
- if (files.length && typeof onFilesSelect === "function") onFilesSelect(files);
- event.target.value = "";
- };
-
- const onDropFiles = (event) => {
- event.preventDefault();
- setDropActive(false);
- const files = Array.from((event.dataTransfer && event.dataTransfer.files) || []);
- if (files.length && typeof onFilesSelect === "function") onFilesSelect(files);
- };
-
- const row = requestData && typeof requestData === "object" ? requestData : null;
- const totalFilesBytes = (attachments || []).reduce((acc, item) => acc + Number(item?.size_bytes || 0), 0);
- const summaryFields = [
- { key: "track", label: "Номер заявки", value: row?.track_number || trackNumber || "-", code: true },
- { key: "status", label: "Статус", value: row ? statusLabel(row.status_code) : "-" },
- { key: "topic", label: "Тема", value: row?.topic_code || "-" },
- { key: "client", label: "Клиент", value: row?.client_name || "-" },
- { key: "phone", label: "Телефон", value: row?.client_phone || "-" },
- { key: "lawyer", label: "Назначенный юрист", value: row?.assigned_lawyer_name || row?.assigned_lawyer_id || "-" },
- { key: "rate", label: "Ставка (фикс.)", value: fmtAmount(row?.effective_rate) },
- { key: "invoice", label: "Сумма счета", value: fmtAmount(row?.invoice_amount) },
- { key: "paid", label: "Дата оплаты", value: fmtDate(row?.paid_at) },
- { key: "size", label: "Размер вложений", value: fmtBytes(row?.total_attachments_bytes) },
- { key: "created", label: "Создана", value: fmtDate(row?.created_at) },
- { key: "updated", label: "Обновлена", value: fmtDate(row?.updated_at) },
- ];
-
- const extraFields = row?.extra_fields && typeof row.extra_fields === "object" && !Array.isArray(row.extra_fields) ? Object.entries(row.extra_fields) : [];
- const attachmentsByMessageId = useMemo(() => {
- const map = new Map();
- (attachments || []).forEach((item) => {
- const messageId = String(item?.message_id || "").trim();
- if (!messageId) return;
- if (!map.has(messageId)) map.set(messageId, []);
- map.get(messageId).push(item);
- });
- return map;
- }, [attachments]);
-
- const openAttachmentFromMessage = (item) => {
- if (!item?.download_url) return;
- const kind = detectAttachmentPreviewKind(item.file_name, item.mime_type);
- if (kind === "none") {
- window.open(String(item.download_url), "_blank", "noopener,noreferrer");
- return;
- }
- openPreview(item);
- };
-
- const chatTimelineItems = [];
- let previousDate = "";
- const timelineSource = [];
- (messages || []).forEach((item) => {
- timelineSource.push({
- type: "message",
- key: "msg-" + String(item?.id || Math.random()),
- created_at: item?.created_at || null,
- payload: item,
- });
- });
- (attachments || [])
- .filter((item) => !String(item?.message_id || "").trim())
- .forEach((item) => {
- timelineSource.push({
- type: "file",
- key: "file-" + String(item?.id || Math.random()),
- created_at: item?.created_at || null,
- payload: item,
- });
- });
- timelineSource.sort((a, b) => {
- const aTime = new Date(a.created_at || 0).getTime();
- const bTime = new Date(b.created_at || 0).getTime();
- if (!Number.isFinite(aTime) && !Number.isFinite(bTime)) return 0;
- if (!Number.isFinite(aTime)) return 1;
- if (!Number.isFinite(bTime)) return -1;
- if (aTime !== bTime) return aTime - bTime;
- return String(a.key).localeCompare(String(b.key), "ru");
- });
- timelineSource.forEach((entry, index) => {
- const dateLabel = fmtDateOnly(entry.created_at);
- const normalizedDate = dateLabel && dateLabel !== "-" ? dateLabel : "Без даты";
- if (normalizedDate !== previousDate) {
- chatTimelineItems.push({ type: "date", key: "date-" + normalizedDate + "-" + index, label: normalizedDate });
- previousDate = normalizedDate;
- }
- chatTimelineItems.push(entry);
- });
-
- const routeNodes =
- Array.isArray(statusRouteNodes) && statusRouteNodes.length
- ? statusRouteNodes
- : row?.status_code
- ? [{ code: row.status_code, name: statusLabel(row.status_code), state: "current", note: "Текущий этап обработки заявки" }]
- : [];
-
- return (
-
-
-
-
-
Карточка
- {loading ? (
-
Загрузка...
- ) : row ? (
- <>
-
- {summaryFields.map((field) => (
-
- {field.label}
- {field.code ? {field.value} : field.value}
-
- ))}
-
-
-
Описание проблемы
-
{row.description ? String(row.description) : "Описание не заполнено"}
-
-
-
Дополнительные данные
- {extraFields.length ? (
-
- {extraFields.map(([key, value]) => (
- -
- {humanizeKey(key)}: {typeof value === "object" ? JSON.stringify(value) : String(value)}
-
- ))}
-
- ) : (
-
Дополнительные данные не заполнены
- )}
-
-
-
Маршрут статусов
- {routeNodes.length ? (
-
- {routeNodes.map((node, index) => {
- const state = String(node?.state || "pending");
- const name = String(node?.name || statusLabel(node?.code));
- const note = String(node?.note || "").trim();
- const changedAt = node?.changed_at ? fmtDate(node.changed_at) : "";
- const className = "route-item " + (state === "current" ? "current" : state === "completed" ? "completed" : "pending");
- return (
- -
-
-
-
{name}
- {note ?
{note}
: null}
- {changedAt && state !== "pending" ?
Изменен: {changedAt}
: null}
-
-
- );
- })}
-
- ) : (
-
Маршрут статусов для темы не настроен
- )}
-
- >
- ) : (
-
Нет данных по заявке
- )}
-
-
-
-
-
-
-
Коммуникация
-
-
-
-
-
-
-
-
- {chatTab === "chat" ? (
- <>
-
-
- >
- ) : (
-
-
-
-
- {"Сообщений: " + String((messages || []).length) + " • Общий размер файлов: " + fmtBytes(totalFilesBytes)}
-
-
-
- )}
-
-
-
-
-
- );
- }
-
function RecordModal({ open, title, fields, form, status, onClose, onChange, onSubmit, onUploadField }) {
if (!open) return null;
+ const visibleFields = (fields || []).filter((field) => {
+ if (typeof field.visibleWhen !== "function") return true;
+ try {
+ return Boolean(field.visibleWhen(form || {}));
+ } catch (_) {
+ return true;
+ }
+ });
const renderField = (field) => {
const value = form[field.key] ?? "";
const options = typeof field.options === "function" ? field.options() : [];
const id = "record-field-" + field.key;
- const disabled = Boolean(field.readOnly);
+ const disabled = Boolean(field.readOnly) || (typeof field.readOnlyWhen === "function" ? Boolean(field.readOnlyWhen(form || {})) : false);
if (field.type === "textarea" || field.type === "json") {
return (
@@ -1762,9 +696,15 @@
);
}
if (field.type === "reference" || field.type === "enum") {
+ const extraOptions = Array.isArray(field.extraOptions) ? field.extraOptions : [];
return (