From 3bff88b38a35996a02b71e74bea328430a33a53a Mon Sep 17 00:00:00 2001 From: TronoSfera <119615520+TronoSfera@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:41:34 +0300 Subject: [PATCH] fix UI 11 --- ...0033_add_message_delivery_read_receipts.py | 77 ++++++++++ app/api/admin/chat.py | 4 + app/api/admin/crud_modules/service.py | 68 ++++----- app/api/admin/requests_modules/service.py | 52 ++++--- app/api/public/chat.py | 4 + app/models/message.py | 8 +- app/services/chat_secure_service.py | 90 ++++++++++- app/services/request_assignment_events.py | 140 +++++++++++++++++ app/web/admin.css | 91 ++++++++--- app/web/admin.js | 120 ++++++++++++--- app/web/admin.jsx | 6 +- .../admin/features/config/ConfigSection.jsx | 56 +++---- .../features/requests/RequestWorkspace.jsx | 114 ++++++++++++-- app/web/client.js | 105 +++++++++++-- app/web/landing.css | 144 +++++++++++++++--- app/web/landing.html | 13 +- app/web/landing.js | 123 ++++++++++++++- tests/admin/test_assignment_users.py | 28 ++++ tests/admin/test_lawyer_chat.py | 58 +++++++ tests/test_migrations.py | 9 +- tests/test_notifications.py | 10 ++ tests/test_public_cabinet.py | 44 ++++++ 22 files changed, 1152 insertions(+), 212 deletions(-) create mode 100644 alembic/versions/0033_add_message_delivery_read_receipts.py create mode 100644 app/services/request_assignment_events.py diff --git a/alembic/versions/0033_add_message_delivery_read_receipts.py b/alembic/versions/0033_add_message_delivery_read_receipts.py new file mode 100644 index 0000000..cdb50e2 --- /dev/null +++ b/alembic/versions/0033_add_message_delivery_read_receipts.py @@ -0,0 +1,77 @@ +"""add per-message delivery and read receipts for chat + +Revision ID: 0033_message_receipts +Revises: 0032_email_cols_fix +Create Date: 2026-03-03 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0033_message_receipts" +down_revision = "0032_email_cols_fix" +branch_labels = None +depends_on = None + + +def _has_column(inspector: sa.Inspector, table: str, column: str) -> bool: + return any(str(col.get("name")) == column for col in inspector.get_columns(table)) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not _has_column(inspector, "messages", "delivered_to_client_at"): + op.add_column("messages", sa.Column("delivered_to_client_at", sa.DateTime(timezone=True), nullable=True)) + if not _has_column(inspector, "messages", "delivered_to_staff_at"): + op.add_column("messages", sa.Column("delivered_to_staff_at", sa.DateTime(timezone=True), nullable=True)) + if not _has_column(inspector, "messages", "read_by_client_at"): + op.add_column("messages", sa.Column("read_by_client_at", sa.DateTime(timezone=True), nullable=True)) + if not _has_column(inspector, "messages", "read_by_staff_at"): + op.add_column("messages", sa.Column("read_by_staff_at", sa.DateTime(timezone=True), nullable=True)) + + # Historical messages are considered already delivered/read by their counterparty + # so old chats do not show endless "sent only" state. + op.execute( + sa.text( + """ + UPDATE messages + SET delivered_to_staff_at = COALESCE(delivered_to_staff_at, created_at, NOW()), + read_by_staff_at = COALESCE(read_by_staff_at, created_at, NOW()), + updated_at = COALESCE(updated_at, NOW()) + WHERE author_type = 'CLIENT' + """ + ) + ) + op.execute( + sa.text( + """ + UPDATE messages + SET delivered_to_client_at = COALESCE(delivered_to_client_at, created_at, NOW()), + read_by_client_at = COALESCE(read_by_client_at, created_at, NOW()), + updated_at = COALESCE(updated_at, NOW()) + WHERE author_type <> 'CLIENT' OR author_type IS NULL + """ + ) + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if _has_column(inspector, "messages", "read_by_staff_at"): + op.drop_column("messages", "read_by_staff_at") + inspector = sa.inspect(bind) + if _has_column(inspector, "messages", "read_by_client_at"): + op.drop_column("messages", "read_by_client_at") + inspector = sa.inspect(bind) + if _has_column(inspector, "messages", "delivered_to_staff_at"): + op.drop_column("messages", "delivered_to_staff_at") + inspector = sa.inspect(bind) + if _has_column(inspector, "messages", "delivered_to_client_at"): + op.drop_column("messages", "delivered_to_client_at") diff --git a/app/api/admin/chat.py b/app/api/admin/chat.py index 33ffc23..784e26b 100644 --- a/app/api/admin/chat.py +++ b/app/api/admin/chat.py @@ -22,6 +22,8 @@ from app.services.chat_secure_service import ( create_admin_or_lawyer_message, get_chat_activity_summary, list_messages_for_request, + mark_messages_delivered_for_staff, + mark_messages_read_for_staff, serialize_message, serialize_messages_for_request, ) @@ -253,6 +255,7 @@ def list_request_messages( ): req = _request_for_id_or_404(db, request_id) _ensure_lawyer_can_view_request_or_403(admin, req) + mark_messages_read_for_staff(db, request_id=req.id) rows = list_messages_for_request(db, req.id) payload = {"rows": serialize_messages_for_request(db, req.id, rows), "total": len(rows)} _audit_admin_chat_read( @@ -309,6 +312,7 @@ def get_request_live_state( ): req = _request_for_id_or_404(db, request_id) _ensure_lawyer_can_view_request_or_403(admin, req) + mark_messages_delivered_for_staff(db, request_id=req.id) summary = get_chat_activity_summary(db, req.id) latest_activity_at = _as_utc_datetime(summary.get("latest_activity_at")) latest_activity_iso = _iso_or_none(latest_activity_at) diff --git a/app/api/admin/crud_modules/service.py b/app/api/admin/crud_modules/service.py index 09e4f26..06e44df 100644 --- a/app/api/admin/crud_modules/service.py +++ b/app/api/admin/crud_modules/service.py @@ -18,19 +18,16 @@ from app.models.table_availability import TableAvailability from app.schemas.universal import UniversalQuery from app.services.billing_flow import apply_billing_transition_effects from app.services.notifications import ( - EVENT_ASSIGNMENT as NOTIFICATION_EVENT_ASSIGNMENT, EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE, - EVENT_REASSIGNMENT as NOTIFICATION_EVENT_REASSIGNMENT, EVENT_STATUS as NOTIFICATION_EVENT_STATUS, mark_admin_notifications_read, notify_request_event, ) +from app.services.request_assignment_events import apply_assignment_change from app.services.request_read_markers import ( - EVENT_ASSIGNMENT, EVENT_ATTACHMENT, EVENT_MESSAGE, - EVENT_REASSIGNMENT, EVENT_STATUS, clear_unread_for_lawyer, mark_unread_for_client, @@ -139,26 +136,6 @@ def _apply_create_side_effects(db: Session, *, table_name: str, row: Any, admin: ) return - if table_name == "requests" and isinstance(row, Request): - assigned = str(row.assigned_lawyer_id or "").strip() - if not assigned: - return - mark_unread_for_client(row, EVENT_ASSIGNMENT) - mark_unread_for_lawyer(row, EVENT_ASSIGNMENT) - responsible = _resolve_responsible(admin) - row.responsible = responsible - db.add(row) - notify_request_event( - db, - request=row, - event_type=NOTIFICATION_EVENT_ASSIGNMENT, - actor_role=_actor_role(admin), - actor_admin_user_id=admin.get("sub"), - body=f"Назначен юрист: {assigned}", - responsible=responsible, - ) - - def list_tables_meta_service(db: Session, admin: dict) -> dict[str, Any]: role = str(admin.get("role") or "").upper() if role != "ADMIN": @@ -412,6 +389,23 @@ def create_row_service(table_name: str, payload: dict[str, Any], db: Session, ad db.rollback() raise _integrity_error() + if normalized == "requests" and isinstance(row, Request): + assigned_lawyer_id = str(row.assigned_lawyer_id or "").strip() + if assigned_lawyer_id: + apply_assignment_change( + db, + request=row, + old_lawyer_id=None, + new_lawyer_id=assigned_lawyer_id, + actor_role=_actor_role(admin), + actor_admin_user_id=admin.get("sub"), + responsible=responsible, + actor_name=str(admin.get("email") or "").strip() or "Администратор системы", + ) + db.add(row) + db.commit() + db.refresh(row) + return _strip_hidden_fields(normalized, _row_to_dict(row)) @@ -556,34 +550,26 @@ def update_row_service(table_name: str, row_id: str, payload: dict[str, Any], db ), responsible=responsible, ) - assignment_event_type = None - assignment_marker_type = None - assignment_event_body = None + assignment_old_lawyer_id = "" + assignment_new_lawyer_id = "" if normalized == "requests" and not _is_lawyer(admin): after_assigned_candidate = clean_payload.get("assigned_lawyer_id", before_assigned_lawyer_id or None) after_assigned_lawyer_id = str(after_assigned_candidate or "").strip() if after_assigned_lawyer_id and after_assigned_lawyer_id != before_assigned_lawyer_id: - if before_assigned_lawyer_id: - assignment_event_type = NOTIFICATION_EVENT_REASSIGNMENT - assignment_marker_type = EVENT_REASSIGNMENT - assignment_event_body = f"Переназначено: {before_assigned_lawyer_id} -> {after_assigned_lawyer_id}" - else: - assignment_event_type = NOTIFICATION_EVENT_ASSIGNMENT - assignment_marker_type = EVENT_ASSIGNMENT - assignment_event_body = f"Назначен юрист: {after_assigned_lawyer_id}" + assignment_old_lawyer_id = before_assigned_lawyer_id + assignment_new_lawyer_id = after_assigned_lawyer_id for key, value in clean_payload.items(): setattr(row, key, value) - if assignment_event_type and assignment_marker_type and isinstance(row, Request): - mark_unread_for_client(row, assignment_marker_type) - mark_unread_for_lawyer(row, assignment_marker_type) - notify_request_event( + if assignment_new_lawyer_id and isinstance(row, Request): + apply_assignment_change( db, request=row, - event_type=assignment_event_type, + old_lawyer_id=assignment_old_lawyer_id, + new_lawyer_id=assignment_new_lawyer_id, actor_role=_actor_role(admin), actor_admin_user_id=admin.get("sub"), - body=assignment_event_body, responsible=responsible, + actor_name=str(admin.get("email") or "").strip() or "Администратор системы", ) try: diff --git a/app/api/admin/requests_modules/service.py b/app/api/admin/requests_modules/service.py index cd2d488..f9fd4b0 100644 --- a/app/api/admin/requests_modules/service.py +++ b/app/api/admin/requests_modules/service.py @@ -18,19 +18,15 @@ from app.schemas.admin import RequestAdminCreate, RequestAdminPatch from app.schemas.universal import UniversalQuery from app.services.billing_flow import apply_billing_transition_effects from app.services.notifications import ( - EVENT_ASSIGNMENT as NOTIFICATION_EVENT_ASSIGNMENT, - EVENT_REASSIGNMENT as NOTIFICATION_EVENT_REASSIGNMENT, EVENT_STATUS as NOTIFICATION_EVENT_STATUS, mark_admin_notifications_read, notify_request_event, ) +from app.services.request_assignment_events import apply_assignment_change from app.services.request_read_markers import ( - EVENT_ASSIGNMENT, - EVENT_REASSIGNMENT, EVENT_STATUS, clear_unread_for_lawyer, mark_unread_for_client, - mark_unread_for_lawyer, ) from app.services.request_deadline import initial_important_date_at from app.services.request_status import apply_status_change_effects @@ -212,6 +208,20 @@ def create_request_service(payload: RequestAdminCreate, db: Session, admin: dict except IntegrityError as exc: db.rollback() raise HTTPException(status_code=400, detail="Заявка с таким номером уже существует") from exc + if assigned_lawyer_id: + apply_assignment_change( + db, + request=row, + old_lawyer_id=None, + new_lawyer_id=assigned_lawyer_id, + actor_role=str(admin.get("role") or "").upper() or "ADMIN", + actor_admin_user_id=admin.get("sub"), + responsible=responsible, + actor_name=str(admin.get("email") or "").strip() or "Администратор системы", + ) + db.add(row) + db.commit() + db.refresh(row) return {"id": str(row.id), "track_number": row.track_number} @@ -310,22 +320,15 @@ def update_request_service(request_id: str, payload: RequestAdminPatch, db: Sess responsible=responsible, ) if actor_role == "ADMIN" and assigned_changed and new_assigned_lawyer_id: - assignment_event_type = NOTIFICATION_EVENT_REASSIGNMENT if old_assigned_lawyer_id else NOTIFICATION_EVENT_ASSIGNMENT - marker_event_type = EVENT_REASSIGNMENT if old_assigned_lawyer_id else EVENT_ASSIGNMENT - mark_unread_for_client(row, marker_event_type) - mark_unread_for_lawyer(row, marker_event_type) - notify_request_event( + apply_assignment_change( db, request=row, - event_type=assignment_event_type, + old_lawyer_id=old_assigned_lawyer_id, + new_lawyer_id=new_assigned_lawyer_id, actor_role="ADMIN", actor_admin_user_id=admin.get("sub"), - body=( - f"Назначен юрист: {new_assigned_lawyer_id}" - if not old_assigned_lawyer_id - else f"Переназначено: {old_assigned_lawyer_id} -> {new_assigned_lawyer_id}" - ), responsible=responsible, + actor_name=str(admin.get("email") or "").strip() or "Администратор системы", ) try: db.add(row) @@ -455,15 +458,15 @@ def claim_request_service(request_id: str, db: Session, admin: dict) -> dict[str if row is None: raise HTTPException(status_code=404, detail="Заявка не найдена") - mark_unread_for_client(row, EVENT_ASSIGNMENT) - notify_request_event( + apply_assignment_change( db, request=row, - event_type=NOTIFICATION_EVENT_ASSIGNMENT, + old_lawyer_id=None, + new_lawyer_id=str(lawyer_uuid), actor_role="LAWYER", actor_admin_user_id=str(lawyer_uuid), - body=f"Юрист {str(lawyer.email or lawyer.name or lawyer_uuid)} взял заявку в работу", responsible=responsible, + actor_name=str(lawyer.name or lawyer.email or lawyer_uuid), ) db.add(row) db.commit() @@ -543,16 +546,15 @@ def reassign_request_service(request_id: str, lawyer_id: str, db: Session, admin if row is None: raise HTTPException(status_code=404, detail="Заявка не найдена") - mark_unread_for_client(row, EVENT_REASSIGNMENT) - mark_unread_for_lawyer(row, EVENT_REASSIGNMENT) - notify_request_event( + apply_assignment_change( db, request=row, - event_type=NOTIFICATION_EVENT_REASSIGNMENT, + old_lawyer_id=old_assigned, + new_lawyer_id=str(lawyer_uuid), actor_role="ADMIN", actor_admin_user_id=admin.get("sub"), - body=f"Переназначено: {old_assigned} -> {str(lawyer_uuid)}", responsible=responsible, + actor_name=str(admin.get("email") or "").strip() or "Администратор системы", ) db.add(row) db.commit() diff --git a/app/api/public/chat.py b/app/api/public/chat.py index 10c0a62..5028590 100644 --- a/app/api/public/chat.py +++ b/app/api/public/chat.py @@ -18,6 +18,8 @@ from app.services.chat_secure_service import ( create_client_message, get_chat_activity_summary, list_messages_for_request, + mark_messages_delivered_for_client, + mark_messages_read_for_client, serialize_message, serialize_messages_for_request, ) @@ -165,6 +167,7 @@ def list_messages_by_track( ): req = _request_for_track_or_404(db, track_number) _ensure_view_access_or_403(session, req) + mark_messages_read_for_client(db, request_id=req.id) rows = list_messages_for_request(db, req.id) payload = serialize_messages_for_request(db, req.id, rows) _audit_public_chat_read( @@ -203,6 +206,7 @@ def get_live_chat_state_by_track( ): req = _request_for_track_or_404(db, track_number) _ensure_view_access_or_403(session, req) + mark_messages_delivered_for_client(db, request_id=req.id) summary = get_chat_activity_summary(db, req.id) latest_activity_at = _as_utc_datetime(summary.get("latest_activity_at")) latest_activity_iso = _iso_or_none(latest_activity_at) diff --git a/app/models/message.py b/app/models/message.py index 18ff153..a2a456d 100644 --- a/app/models/message.py +++ b/app/models/message.py @@ -1,5 +1,7 @@ import uuid -from sqlalchemy import String, Boolean +from datetime import datetime + +from sqlalchemy import String, Boolean, DateTime from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.dialects.postgresql import UUID from app.db.session import Base @@ -13,3 +15,7 @@ class Message(Base, UUIDMixin, TimestampMixin): author_name: Mapped[str | None] = mapped_column(String(200), nullable=True) body: Mapped[str | None] = mapped_column(EncryptedChatText(), nullable=True) immutable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + delivered_to_client_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + delivered_to_staff_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + read_by_client_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + read_by_staff_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/app/services/chat_secure_service.py b/app/services/chat_secure_service.py index 7b01254..ab0f67f 100644 --- a/app/services/chat_secure_service.py +++ b/app/services/chat_secure_service.py @@ -1,6 +1,7 @@ from __future__ import annotations import uuid +from datetime import datetime, timezone from typing import Any from fastapi import HTTPException @@ -36,6 +37,87 @@ def list_messages_for_request(db: Session, request_id: Any) -> list[Message]: ) +def _iso_or_none(value: datetime | None) -> str | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc).isoformat() + return value.astimezone(timezone.utc).isoformat() + + +def _mark_counterparty_delivery( + db: Session, + *, + request_id: Any, + recipient: str, + mark_read: bool, +) -> bool: + side = str(recipient or "").strip().upper() + if side not in {"CLIENT", "STAFF"}: + return False + + now = datetime.now(timezone.utc) + changed = False + if side == "CLIENT": + sender_filter = Message.author_type != "CLIENT" + delivered_column = Message.delivered_to_client_at + read_column = Message.read_by_client_at + else: + sender_filter = Message.author_type == "CLIENT" + delivered_column = Message.delivered_to_staff_at + read_column = Message.read_by_staff_at + + delivered_count = ( + db.query(Message) + .filter(Message.request_id == request_id, sender_filter, delivered_column.is_(None)) + .update( + { + delivered_column: now, + Message.updated_at: now, + }, + synchronize_session=False, + ) + ) + if delivered_count: + changed = True + + if mark_read: + read_count = ( + db.query(Message) + .filter(Message.request_id == request_id, sender_filter, read_column.is_(None)) + .update( + { + read_column: now, + delivered_column: func.coalesce(delivered_column, now), + Message.updated_at: now, + }, + synchronize_session=False, + ) + ) + if read_count: + changed = True + + if changed: + db.commit() + return changed + + +def mark_messages_delivered_for_client(db: Session, *, request_id: Any) -> bool: + return _mark_counterparty_delivery(db, request_id=request_id, recipient="CLIENT", mark_read=False) + + +def mark_messages_read_for_client(db: Session, *, request_id: Any) -> bool: + return _mark_counterparty_delivery(db, request_id=request_id, recipient="CLIENT", mark_read=True) + + +def mark_messages_delivered_for_staff(db: Session, *, request_id: Any) -> bool: + return _mark_counterparty_delivery(db, request_id=request_id, recipient="STAFF", mark_read=False) + + +def mark_messages_read_for_staff(db: Session, *, request_id: Any) -> bool: + return _mark_counterparty_delivery(db, request_id=request_id, recipient="STAFF", mark_read=True) + + def serialize_message(row: Message) -> dict[str, Any]: return { "id": str(row.id), @@ -46,8 +128,12 @@ def serialize_message(row: Message) -> dict[str, Any]: "message_kind": "TEXT", "request_data_items": [], "request_data_all_filled": False, - "created_at": row.created_at.isoformat() if row.created_at else None, - "updated_at": row.updated_at.isoformat() if row.updated_at else None, + "created_at": _iso_or_none(row.created_at), + "updated_at": _iso_or_none(row.updated_at), + "delivered_to_client_at": _iso_or_none(row.delivered_to_client_at), + "delivered_to_staff_at": _iso_or_none(row.delivered_to_staff_at), + "read_by_client_at": _iso_or_none(row.read_by_client_at), + "read_by_staff_at": _iso_or_none(row.read_by_staff_at), } diff --git a/app/services/request_assignment_events.py b/app/services/request_assignment_events.py new file mode 100644 index 0000000..45797e8 --- /dev/null +++ b/app/services/request_assignment_events.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from sqlalchemy import inspect +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session + +from app.models.admin_user import AdminUser +from app.models.message import Message +from app.models.request import Request +from app.services.notifications import ( + EVENT_ASSIGNMENT as NOTIFICATION_EVENT_ASSIGNMENT, + EVENT_REASSIGNMENT as NOTIFICATION_EVENT_REASSIGNMENT, + notify_request_event, +) +from app.services.request_read_markers import ( + EVENT_ASSIGNMENT, + EVENT_REASSIGNMENT, + mark_unread_for_client, + mark_unread_for_lawyer, +) + + +def _normalize_uuid_text(value: Any) -> str: + raw = str(value or "").strip() + if not raw: + return "" + try: + return str(UUID(raw)) + except ValueError: + return raw + + +def _lawyer_label(db: Session, lawyer_id: str) -> str: + normalized_id = _normalize_uuid_text(lawyer_id) + if not normalized_id: + return "Не назначен" + try: + lawyer_uuid = UUID(normalized_id) + except ValueError: + return normalized_id + row = db.get(AdminUser, lawyer_uuid) + if row is None: + return normalized_id + return str(row.name or row.email or normalized_id).strip() or normalized_id + + +def _service_message_author(actor_role: str, actor_name: str | None) -> str: + normalized_role = str(actor_role or "").strip().upper() + explicit = str(actor_name or "").strip() + if explicit: + return explicit + if normalized_role in {"LAWYER", "CURATOR"}: + return "Юрист" + if normalized_role == "CLIENT": + return "Клиент" + return "Администратор системы" + + +def _can_write_messages(db: Session) -> bool: + try: + bind = db.get_bind() + if bind is None: + return False + return bool(inspect(bind).has_table(Message.__tablename__)) + except (SQLAlchemyError, ValueError, TypeError): + return False + + +def apply_assignment_change( + db: Session, + *, + request: Request, + old_lawyer_id: Any, + new_lawyer_id: Any, + actor_role: str, + actor_admin_user_id: str | None = None, + responsible: str = "Администратор системы", + actor_name: str | None = None, +) -> dict[str, str] | None: + old_id = _normalize_uuid_text(old_lawyer_id) + new_id = _normalize_uuid_text(new_lawyer_id) + if not new_id or old_id == new_id: + return None + + old_label = _lawyer_label(db, old_id) if old_id else "Не назначен" + new_label = _lawyer_label(db, new_id) + + if old_id: + notification_event = NOTIFICATION_EVENT_REASSIGNMENT + marker_event = EVENT_REASSIGNMENT + notification_body = f"Переназначено: {old_label} -> {new_label}" + chat_body = ( + f"Переназначено: {old_label} -> {new_label}\n" + f"Предыдущий юрист: {old_label}\n" + f"Новый юрист: {new_label}" + ) + else: + notification_event = NOTIFICATION_EVENT_ASSIGNMENT + marker_event = EVENT_ASSIGNMENT + notification_body = f"Назначен юрист: {new_label}" + chat_body = f"Назначен юрист: {new_label}\nЮрист: {new_label}" + + safe_responsible = str(responsible or "").strip() or "Администратор системы" + normalized_actor_role = str(actor_role or "").strip().upper() or "ADMIN" + + mark_unread_for_client(request, marker_event) + mark_unread_for_lawyer(request, marker_event) + request.responsible = safe_responsible + + notify_request_event( + db, + request=request, + event_type=notification_event, + actor_role=normalized_actor_role, + actor_admin_user_id=actor_admin_user_id, + body=notification_body, + responsible=safe_responsible, + ) + + if _can_write_messages(db): + db.add( + Message( + request_id=request.id, + author_type="SYSTEM", + author_name=_service_message_author(normalized_actor_role, actor_name), + body=chat_body, + immutable=True, + responsible=safe_responsible, + ) + ) + db.add(request) + return { + "notification_event": notification_event, + "marker_event": marker_event, + "notification_body": notification_body, + "chat_body": chat_body, + } diff --git a/app/web/admin.css b/app/web/admin.css index aeeff8a..fd963cf 100644 --- a/app/web/admin.css +++ b/app/web/admin.css @@ -237,6 +237,10 @@ font-weight: 700; font-size: 0.88rem; cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.36rem; } .btn:hover { filter: brightness(1.05); } @@ -1170,13 +1174,25 @@ cursor: pointer; width: 30px; height: 30px; - display: inline-grid; - place-items: center; + display: inline-flex; + align-items: center; + justify-content: center; font-size: 0.95rem; line-height: 1; position: relative; } + .btn svg, + .btn .ui-glyph, + .icon-btn svg, + .icon-btn .ui-glyph, + .table-control-btn svg, + .table-control-btn .ui-glyph { + display: block; + flex-shrink: 0; + margin: 0; + } + .icon-btn:hover { border-color: rgba(212, 168, 106, 0.42); background: rgba(212, 168, 106, 0.16); @@ -1262,8 +1278,9 @@ height: 40px; min-height: 40px; padding: 0; - display: inline-grid; - place-items: center; + display: inline-flex; + align-items: center; + justify-content: center; line-height: 1; } @@ -1328,16 +1345,6 @@ background: transparent; } - .config-floating-actions { - position: fixed; - right: 1.15rem; - top: 164px; - z-index: 35; - display: inline-flex; - align-items: center; - gap: 0.45rem; - } - .config-panel-flat .config-content .table-wrap table { min-width: 640px; } @@ -2086,7 +2093,7 @@ 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)); + background: #0f1724; border-radius: 12px; box-shadow: 0 14px 30px rgba(0, 0, 0, 0.32); max-height: 240px; @@ -2099,7 +2106,7 @@ .request-data-suggest-item { width: 100%; border: 1px solid transparent; - background: rgba(255, 255, 255, 0.02); + background: #142030; color: #e7effc; border-radius: 10px; padding: 0.4rem 0.5rem; @@ -2117,7 +2124,7 @@ .request-data-suggest-item:hover { border-color: rgba(123, 159, 205, 0.45); - background: rgba(75, 111, 163, 0.18); + background: #1a2a42; } .request-data-rows { @@ -2664,6 +2671,50 @@ text-align: right; } + .chat-message-meta { + margin-top: 0.32rem; + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 0.34rem; + width: 100%; + } + + .chat-message-meta .chat-message-time { + margin-top: 0; + } + + .chat-message-status { + position: relative; + width: 1.02rem; + height: 0.72rem; + color: rgba(225, 235, 249, 0.78); + flex-shrink: 0; + } + + .chat-message-status.delivered { + color: rgba(225, 235, 249, 0.92); + } + + .chat-message-status.read { + color: #7ac5ff; + } + + .chat-message-status-check { + position: absolute; + top: -0.05rem; + left: 0; + font-size: 0.72rem; + line-height: 1; + font-weight: 700; + font-family: "Segoe UI Symbol", "Noto Sans Symbols", "Arial", sans-serif; + user-select: none; + } + + .chat-message-status-check.second { + left: 0.32rem; + } + .chat-request-data-bubble { cursor: pointer; border-color: rgba(221, 168, 87, 0.42); @@ -3229,12 +3280,6 @@ width: 100%; margin-left: 0; } - .config-floating-actions { - position: static; - justify-content: flex-end; - width: 100%; - margin-bottom: 0.5rem; - } .topbar { flex-direction: column; align-items: flex-start; diff --git a/app/web/admin.js b/app/web/admin.js index 0cd383d..598495e 100644 --- a/app/web/admin.js +++ b/app/web/admin.js @@ -2857,7 +2857,7 @@ const canLoadNext = Boolean( configActiveKey && !activeConfigTableState.showAll && activeConfigTableState.offset + PAGE_SIZE < activeConfigTableState.total ); - return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u0421\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A\u0438"), /* @__PURE__ */ React.createElement("p", { className: "breadcrumbs" }, configActiveKey ? getTableLabel(configActiveKey) : "\u0421\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D"), configActiveKey === "otp_sessions" ? /* @__PURE__ */ React.createElement("p", { className: "muted" }, smsBalanceSummary(smsProviderHealth), (smsProviderHealth == null ? void 0 : smsProviderHealth.loaded_at) ? " \u2022 \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u043E " + fmtDate(smsProviderHealth.loaded_at) : "") : null), /* @__PURE__ */ React.createElement("div", { className: "config-head-actions" }, configActiveKey === "otp_sessions" ? /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: onRefreshSmsProviderHealth }, "\u0411\u0430\u043B\u0430\u043D\u0441") : null)), /* @__PURE__ */ React.createElement("div", { className: "config-layout" }, /* @__PURE__ */ React.createElement("div", { className: "config-panel config-panel-flat" }, /* @__PURE__ */ React.createElement("div", { className: "config-content" }, /* @__PURE__ */ React.createElement("div", { className: "config-floating-actions" }, /* @__PURE__ */ React.createElement( + return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u0421\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A\u0438"), /* @__PURE__ */ React.createElement("p", { className: "breadcrumbs" }, configActiveKey ? getTableLabel(configActiveKey) : "\u0421\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D"), configActiveKey === "otp_sessions" ? /* @__PURE__ */ React.createElement("p", { className: "muted" }, smsBalanceSummary(smsProviderHealth), (smsProviderHealth == null ? void 0 : smsProviderHealth.loaded_at) ? " \u2022 \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u043E " + fmtDate(smsProviderHealth.loaded_at) : "") : null), /* @__PURE__ */ React.createElement("div", { className: "config-head-actions" }, /* @__PURE__ */ React.createElement( "button", { className: "btn secondary table-control-btn", @@ -2879,7 +2879,7 @@ "aria-label": "\u0424\u0438\u043B\u044C\u0442\u0440" }, /* @__PURE__ */ React.createElement(FilterIcon, null) - )), /* @__PURE__ */ React.createElement( + ), configActiveKey === "otp_sessions" ? /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: onRefreshSmsProviderHealth }, "\u0411\u0430\u043B\u0430\u043D\u0441") : null)), /* @__PURE__ */ React.createElement("div", { className: "config-layout" }, /* @__PURE__ */ React.createElement("div", { className: "config-panel config-panel-flat" }, /* @__PURE__ */ React.createElement("div", { className: "config-content" }, /* @__PURE__ */ React.createElement( FilterToolbar, { filters: activeConfigTableState.filters, @@ -2896,19 +2896,18 @@ DataTable, { headers: [ - { key: "code", label: "\u041A\u043E\u0434", sortable: true, field: "code" }, { key: "name", label: "\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435", sortable: true, field: "name" }, { key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u043D\u0430", sortable: true, field: "enabled" }, { key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", sortable: true, field: "sort_order" }, { key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" } ], rows: tables.topics.rows, - emptyColspan: 5, + emptyColspan: 4, onSort: (field) => toggleTableSort("topics", field), sortClause: tables.topics.sort && tables.topics.sort[0] || TABLE_SERVER_CONFIG.topics.sort[0], renderRow: (row) => { var _a2; - return /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("code", null, row.code || "-")), /* @__PURE__ */ React.createElement("td", null, row.name || "-"), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.enabled)), /* @__PURE__ */ React.createElement("td", null, String((_a2 = row.sort_order) != null ? _a2 : 0)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0442\u0435\u043C\u0443", onClick: () => openEditRecordModal("topics", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0442\u0435\u043C\u0443", onClick: () => deleteRecord("topics", row.id), tone: "danger" })))); + return /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, row.name || "-"), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.enabled)), /* @__PURE__ */ React.createElement("td", null, String((_a2 = row.sort_order) != null ? _a2 : 0)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0442\u0435\u043C\u0443", onClick: () => openEditRecordModal("topics", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0442\u0435\u043C\u0443", onClick: () => deleteRecord("topics", row.id), tone: "danger" })))); } } ) : null, configActiveKey === "quotes" ? /* @__PURE__ */ React.createElement( @@ -2936,7 +2935,6 @@ DataTable, { headers: [ - { key: "code", label: "\u041A\u043E\u0434", sortable: true, field: "code" }, { key: "name", label: "\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435", sortable: true, field: "name" }, { key: "status_group_id", label: "\u0413\u0440\u0443\u043F\u043F\u0430", sortable: true, field: "status_group_id" }, { key: "kind", label: "\u0422\u0438\u043F", sortable: true, field: "kind" }, @@ -2947,12 +2945,12 @@ { key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" } ], rows: tables.statuses.rows, - emptyColspan: 9, + emptyColspan: 8, onSort: (field) => toggleTableSort("statuses", field), sortClause: tables.statuses.sort && tables.statuses.sort[0] || TABLE_SERVER_CONFIG.statuses.sort[0], renderRow: (row) => { var _a2; - return /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("code", null, row.code || "-")), /* @__PURE__ */ React.createElement("td", null, row.name || "-"), /* @__PURE__ */ React.createElement("td", null, resolveReferenceLabel({ table: "status_groups", value_field: "id", label_field: "name" }, row.status_group_id)), /* @__PURE__ */ React.createElement("td", null, statusKindLabel(row.kind)), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.enabled)), /* @__PURE__ */ React.createElement("td", null, String((_a2 = row.sort_order) != null ? _a2 : 0)), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.is_terminal)), /* @__PURE__ */ React.createElement("td", null, row.invoice_template || "-"), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0441\u0442\u0430\u0442\u0443\u0441", onClick: () => openEditRecordModal("statuses", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0441\u0442\u0430\u0442\u0443\u0441", onClick: () => deleteRecord("statuses", row.id), tone: "danger" })))); + return /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, row.name || "-"), /* @__PURE__ */ React.createElement("td", null, resolveReferenceLabel({ table: "status_groups", value_field: "id", label_field: "name" }, row.status_group_id)), /* @__PURE__ */ React.createElement("td", null, statusKindLabel(row.kind)), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.enabled)), /* @__PURE__ */ React.createElement("td", null, String((_a2 = row.sort_order) != null ? _a2 : 0)), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.is_terminal)), /* @__PURE__ */ React.createElement("td", null, row.invoice_template || "-"), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0441\u0442\u0430\u0442\u0443\u0441", onClick: () => openEditRecordModal("statuses", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0441\u0442\u0430\u0442\u0443\u0441", onClick: () => deleteRecord("statuses", row.id), tone: "danger" })))); } } ) : null, configActiveKey === "formFields" ? /* @__PURE__ */ React.createElement( @@ -3140,7 +3138,7 @@ emptyColspan: Math.max(1, genericConfigHeaders.length), onSort: (field) => toggleTableSort(configActiveKey, field), sortClause: activeConfigTableState.sort && activeConfigTableState.sort[0] || (((_a = resolveTableConfig(configActiveKey)) == null ? void 0 : _a.sort) || [])[0], - renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id || JSON.stringify(row) }, ((activeConfigMeta == null ? void 0 : activeConfigMeta.columns) || []).map((column) => { + renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id || JSON.stringify(row) }, ((activeConfigMeta == null ? void 0 : activeConfigMeta.columns) || []).filter((column) => String((column == null ? void 0 : column.name) || "") !== "id").map((column) => { const key = String(column.name || ""); const value = row[key]; if (column.kind === "boolean") return /* @__PURE__ */ React.createElement("td", { key }, boolLabel(Boolean(value))); @@ -4872,6 +4870,27 @@ }, [routeNodes]); const AttachmentPreviewModal = AttachmentPreviewModalComponent; const StatusLine = StatusLineComponent; + const resolveMessageReceiptState = (payload) => { + const authorType = String((payload == null ? void 0 : payload.author_type) || "").trim().toUpperCase(); + const isClientAuthor = authorType === "CLIENT"; + const deliveredAt = isClientAuthor ? payload == null ? void 0 : payload.delivered_to_staff_at : payload == null ? void 0 : payload.delivered_to_client_at; + const readAt = isClientAuthor ? payload == null ? void 0 : payload.read_by_staff_at : payload == null ? void 0 : payload.read_by_client_at; + if (readAt) return { state: "read", label: "\u041F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043E" }; + if (deliveredAt) return { state: "delivered", label: "\u0414\u043E\u0441\u0442\u0430\u0432\u043B\u0435\u043D\u043E" }; + return { state: "sent", label: "\u041E\u0442\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u043E" }; + }; + const isOutgoingForViewer = (payload) => { + const authorType = String((payload == null ? void 0 : payload.author_type) || "").trim().toUpperCase(); + if (!authorType) return false; + if (viewerRoleCode === "CLIENT") return authorType === "CLIENT"; + return authorType !== "CLIENT"; + }; + const renderMessageMeta = (payload) => { + const timeLabel = fmtTimeOnly(payload == null ? void 0 : payload.created_at); + if (!isOutgoingForViewer(payload)) return /* @__PURE__ */ React.createElement("div", { className: "chat-message-time" }, timeLabel); + const receipt = resolveMessageReceiptState(payload); + return /* @__PURE__ */ React.createElement("div", { className: "chat-message-meta" }, /* @__PURE__ */ React.createElement("div", { className: "chat-message-time" }, timeLabel), /* @__PURE__ */ React.createElement("span", { className: "chat-message-status " + receipt.state, title: receipt.label, "aria-label": receipt.label }, /* @__PURE__ */ React.createElement("span", { className: "chat-message-status-check first", "aria-hidden": "true" }, "\u2713"), receipt.state !== "sent" ? /* @__PURE__ */ React.createElement("span", { className: "chat-message-status-check second", "aria-hidden": "true" }, "\u2713") : null)); + }; const renderRequestDataMessageItems = (payload) => { var _a2; const items = Array.isArray(payload == null ? void 0 : payload.request_data_items) ? payload.request_data_items : []; @@ -4909,11 +4928,40 @@ text: withTail(detail) }; } - if (firstLine.startsWith("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D \u044E\u0440\u0438\u0441\u0442:") || firstLine.startsWith("\u041F\u0435\u0440\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043E:")) { - const detail = firstLine.startsWith("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D \u044E\u0440\u0438\u0441\u0442:") ? firstLine.slice("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D \u044E\u0440\u0438\u0441\u0442:".length) : firstLine.slice("\u041F\u0435\u0440\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043E:".length); + if (firstLine.startsWith("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D \u044E\u0440\u0438\u0441\u0442:")) { return { title: "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D \u044E\u0440\u0438\u0441\u0442", - text: withTail(detail) + text: withTail(firstLine.slice("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D \u044E\u0440\u0438\u0441\u0442:".length)) + }; + } + if (firstLine.startsWith("\u041F\u0435\u0440\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043E:")) { + return { + title: "\u0421\u043C\u0435\u043D\u0430 \u044E\u0440\u0438\u0441\u0442\u0430", + text: withTail(firstLine.slice("\u041F\u0435\u0440\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043E:".length)) + }; + } + if (firstLine.startsWith("\u0421\u043C\u0435\u043D\u0430 \u044E\u0440\u0438\u0441\u0442\u0430:")) { + return { + title: "\u0421\u043C\u0435\u043D\u0430 \u044E\u0440\u0438\u0441\u0442\u0430", + text: withTail(firstLine.slice("\u0421\u043C\u0435\u043D\u0430 \u044E\u0440\u0438\u0441\u0442\u0430:".length)) + }; + } + if (firstLine.startsWith("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435 \u044E\u0440\u0438\u0441\u0442\u0430:")) { + return { + title: "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D \u044E\u0440\u0438\u0441\u0442", + text: withTail(firstLine.slice("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435 \u044E\u0440\u0438\u0441\u0442\u0430:".length)) + }; + } + if (firstLine.startsWith("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435:")) { + return { + title: "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D \u044E\u0440\u0438\u0441\u0442", + text: withTail(firstLine.slice("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435:".length)) + }; + } + if (firstLine.startsWith("\u041F\u0435\u0440\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435:")) { + return { + title: "\u0421\u043C\u0435\u043D\u0430 \u044E\u0440\u0438\u0441\u0442\u0430", + text: withTail(firstLine.slice("\u041F\u0435\u0440\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435:".length)) }; } return null; @@ -5085,7 +5133,7 @@ /* @__PURE__ */ React.createElement("span", { className: "chat-message-file-name" }, String(((_d = entry.payload) == null ? void 0 : _d.file_name) || "\u0424\u0430\u0439\u043B")) )), /* @__PURE__ */ React.createElement("div", { className: "chat-message-time" }, fmtTimeOnly((_e = entry.payload) == null ? void 0 : _e.created_at))) ) : (() => { - var _a3, _b3, _c3, _d2, _e2, _f, _g, _h, _i; + var _a3, _b3, _c3, _d2, _e2, _f, _g, _h; const messageKind = String(((_a3 = entry.payload) == null ? void 0 : _a3.message_kind) || ""); const isRequestDataMessage = messageKind === "REQUEST_DATA"; const serviceMessageContent = resolveServiceMessageContent(entry.payload); @@ -5132,7 +5180,7 @@ /* @__PURE__ */ React.createElement("span", { className: "chat-message-file-name" }, String(file.file_name || "\u0424\u0430\u0439\u043B")) ))); })(), - /* @__PURE__ */ React.createElement("div", { className: "chat-message-time" }, fmtTimeOnly((_i = entry.payload) == null ? void 0 : _i.created_at)) + renderMessageMeta(entry.payload) )); })(); } @@ -5408,7 +5456,7 @@ const statusCode = String((item == null ? void 0 : item.to_status) || ""); const statusMeta = statusByCode.get(statusCode); const itemClass = "route-item request-status-history-route-item " + (index === 0 ? "current" : "completed"); - return /* @__PURE__ */ React.createElement("li", { key: String((item == null ? void 0 : item.id) || index), className: itemClass }, /* @__PURE__ */ React.createElement("span", { className: "route-dot" }), /* @__PURE__ */ React.createElement("div", { className: "route-body" }, /* @__PURE__ */ React.createElement("div", { className: "request-status-history-row" }, /* @__PURE__ */ React.createElement("b", null, resolveStatusDisplayName(statusCode, (item == null ? void 0 : item.to_status_name) || (statusMeta == null ? void 0 : statusMeta.name) || "")), (statusMeta == null ? void 0 : statusMeta.isTerminal) ? /* @__PURE__ */ React.createElement("span", { className: "request-status-history-chip" }, "\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u044C\u043D\u044B\u0439") : null), /* @__PURE__ */ React.createElement("div", { className: "muted route-time" }, fmtShortDateTime(item == null ? void 0 : item.changed_at)), /* @__PURE__ */ React.createElement("div", { className: "request-status-history-meta" }, /* @__PURE__ */ React.createElement("span", null, "\u0412\u0430\u0436\u043D\u0430\u044F \u0434\u0430\u0442\u0430: " + fmtShortDateTime(item == null ? void 0 : item.important_date_at)), /* @__PURE__ */ React.createElement("span", null, "\u0414\u043B\u0438\u0442\u0435\u043B\u044C\u043D\u043E\u0441\u0442\u044C: " + formatDuration(item == null ? void 0 : item.duration_seconds))), (item == null ? void 0 : item.from_status) ? /* @__PURE__ */ React.createElement("div", { className: "request-status-history-meta" }, /* @__PURE__ */ React.createElement("span", null, "\u0418\u0437: " + resolveStatusDisplayName(item.from_status, ""))) : null, String((item == null ? void 0 : item.comment) || "").trim() ? /* @__PURE__ */ React.createElement("div", { className: "request-status-history-comment" }, String(item.comment)) : null)); + return /* @__PURE__ */ React.createElement("li", { key: String((item == null ? void 0 : item.id) || index), className: itemClass }, /* @__PURE__ */ React.createElement("span", { className: "route-dot" }), /* @__PURE__ */ React.createElement("div", { className: "route-body" }, /* @__PURE__ */ React.createElement("div", { className: "request-status-history-row" }, /* @__PURE__ */ React.createElement("b", null, resolveStatusDisplayName(statusCode, (item == null ? void 0 : item.to_status_name) || (statusMeta == null ? void 0 : statusMeta.name) || "")), (statusMeta == null ? void 0 : statusMeta.isTerminal) ? /* @__PURE__ */ React.createElement("span", { className: "request-status-history-chip" }, "\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u044C\u043D\u044B\u0439") : null), /* @__PURE__ */ React.createElement("div", { className: "muted route-time" }, fmtShortDateTime(item == null ? void 0 : item.changed_at)), /* @__PURE__ */ React.createElement("div", { className: "request-status-history-meta" }, /* @__PURE__ */ React.createElement("span", null, "\u0412\u0430\u0436\u043D\u0430\u044F \u0434\u0430\u0442\u0430: " + fmtShortDateTime(item == null ? void 0 : item.important_date_at)), /* @__PURE__ */ React.createElement("span", null, "\u0414\u043B\u0438\u0442\u0435\u043B\u044C\u043D\u043E\u0441\u0442\u044C: " + formatDuration(item == null ? void 0 : item.duration_seconds))), String((item == null ? void 0 : item.comment) || "").trim() ? /* @__PURE__ */ React.createElement("div", { className: "request-status-history-comment" }, String(item.comment)) : null)); }) : /* @__PURE__ */ React.createElement("li", { className: "muted" }, "\u0418\u0441\u0442\u043E\u0440\u0438\u044F \u0438\u0437\u043C\u0435\u043D\u0435\u043D\u0438\u0439 \u0441\u0442\u0430\u0442\u0443\u0441\u043E\u0432 \u043F\u043E\u043A\u0430 \u043F\u0443\u0441\u0442\u0430\u044F"))), statusChangeModal.error ? /* @__PURE__ */ React.createElement("div", { className: "status error" }, statusChangeModal.error) : null, /* @__PURE__ */ React.createElement("div", { className: "modal-actions modal-actions-right" }, /* @__PURE__ */ React.createElement( "button", { @@ -5511,6 +5559,8 @@ "input", { id: "request-data-request-template-select", + name: "request_template_search_nohistory", + type: "text", value: dataRequestModal.requestTemplateQuery, onChange: (event) => setDataRequestModal((prev) => ({ ...prev, @@ -5519,14 +5569,24 @@ templateStatus: "", error: "" })), - onFocus: () => setRequestTemplateSuggestOpen(true), - onBlur: () => window.setTimeout(() => setRequestTemplateSuggestOpen(false), 120), + onFocus: (event) => { + event.currentTarget.removeAttribute("readonly"); + setRequestTemplateSuggestOpen(true); + }, + onBlur: (event) => { + event.currentTarget.setAttribute("readonly", "readonly"); + window.setTimeout(() => setRequestTemplateSuggestOpen(false), 120); + }, disabled: dataRequestModal.loading || dataRequestModal.saving || dataRequestModal.savingTemplate, placeholder: "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435 \u0448\u0430\u0431\u043B\u043E\u043D\u0430", + readOnly: true, autoComplete: "new-password", autoCorrect: "off", autoCapitalize: "none", - spellCheck: false + spellCheck: false, + "aria-autocomplete": "list", + "data-1p-ignore": "true", + "data-lpignore": "true" } ), requestTemplateBadge ? /* @__PURE__ */ React.createElement("span", { className: "request-data-template-badge " + requestTemplateBadge.kind }, requestTemplateBadge.label) : null, requestTemplateSuggestOpen && filteredRequestTemplates.length ? /* @__PURE__ */ React.createElement("div", { className: "request-data-suggest-list", role: "listbox", "aria-label": "\u0428\u0430\u0431\u043B\u043E\u043D\u044B \u0437\u0430\u043F\u0440\u043E\u0441\u0430" }, filteredRequestTemplates.map((tpl) => /* @__PURE__ */ React.createElement( "button", @@ -5562,6 +5622,8 @@ "input", { id: "request-data-template-select", + name: "request_field_search_nohistory", + type: "text", value: dataRequestModal.catalogFieldQuery, onChange: (event) => setDataRequestModal((prev) => ({ ...prev, @@ -5570,14 +5632,24 @@ templateStatus: "", error: "" })), - onFocus: () => setCatalogFieldSuggestOpen(true), - onBlur: () => window.setTimeout(() => setCatalogFieldSuggestOpen(false), 120), + onFocus: (event) => { + event.currentTarget.removeAttribute("readonly"); + setCatalogFieldSuggestOpen(true); + }, + onBlur: (event) => { + event.currentTarget.setAttribute("readonly", "readonly"); + window.setTimeout(() => setCatalogFieldSuggestOpen(false), 120); + }, disabled: dataRequestModal.loading || dataRequestModal.saving || dataRequestModal.savingTemplate, placeholder: "\u041D\u0430\u0447\u043D\u0438\u0442\u0435 \u0432\u0432\u043E\u0434\u0438\u0442\u044C \u043D\u0430\u0438\u043C\u0435\u043D\u043E\u0432\u0430\u043D\u0438\u0435 \u043F\u043E\u043B\u044F", + readOnly: true, autoComplete: "new-password", autoCorrect: "off", autoCapitalize: "none", - spellCheck: false + spellCheck: false, + "aria-autocomplete": "list", + "data-1p-ignore": "true", + "data-lpignore": "true" } ), catalogFieldSuggestOpen && filteredCatalogFields.length ? /* @__PURE__ */ React.createElement("div", { className: "request-data-suggest-list", role: "listbox", "aria-label": "\u041F\u043E\u043B\u044F \u0434\u0430\u043D\u043D\u044B\u0445" }, filteredCatalogFields.map((tpl) => /* @__PURE__ */ React.createElement( "button", @@ -7828,7 +7900,6 @@ } if (tableKey === "topics") { return [ - { field: "code", label: "\u041A\u043E\u0434", type: "text" }, { field: "name", label: "\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435", type: "text" }, { field: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u043D\u0430", type: "boolean" }, { field: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number" } @@ -7836,7 +7907,6 @@ } if (tableKey === "statuses") { return [ - { field: "code", label: "\u041A\u043E\u0434", type: "text" }, { field: "name", label: "\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435", type: "text" }, { field: "status_group_id", label: "\u0413\u0440\u0443\u043F\u043F\u0430", type: "reference", options: getStatusGroupOptions }, { field: "kind", label: "\u0422\u0438\u043F", type: "enum", options: getStatusKindOptions }, @@ -7911,7 +7981,7 @@ } const meta = tableCatalogMap[tableKey]; if (!meta || !Array.isArray(meta.columns)) return []; - return (meta.columns || []).filter((column) => column && column.name && column.filterable !== false).map((column) => { + return (meta.columns || []).filter((column) => column && column.name && column.filterable !== false && String(column.name) !== "id").map((column) => { const name = String(column.name); const label = String(column.label || humanizeKey(name)); if (name === "topic_code") return { field: name, label, type: "reference", options: getTopicOptions }; @@ -9751,7 +9821,7 @@ const canDeleteInConfig = activeConfigActions.includes("delete"); const genericConfigHeaders = useMemo(() => { if (!activeConfigMeta || !Array.isArray(activeConfigMeta.columns)) return []; - const headers = (activeConfigMeta.columns || []).filter((column) => column && column.name).map((column) => { + const headers = (activeConfigMeta.columns || []).filter((column) => column && column.name && String(column.name) !== "id").map((column) => { const name = String(column.name); return { key: name, diff --git a/app/web/admin.jsx b/app/web/admin.jsx index f70e0cb..d301859 100644 --- a/app/web/admin.jsx +++ b/app/web/admin.jsx @@ -1588,7 +1588,6 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; } if (tableKey === "topics") { return [ - { field: "code", label: "Код", type: "text" }, { field: "name", label: "Название", type: "text" }, { field: "enabled", label: "Активна", type: "boolean" }, { field: "sort_order", label: "Порядок", type: "number" }, @@ -1596,7 +1595,6 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; } if (tableKey === "statuses") { return [ - { field: "code", label: "Код", type: "text" }, { field: "name", label: "Название", type: "text" }, { field: "status_group_id", label: "Группа", type: "reference", options: getStatusGroupOptions }, { field: "kind", label: "Тип", type: "enum", options: getStatusKindOptions }, @@ -1672,7 +1670,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; const meta = tableCatalogMap[tableKey]; if (!meta || !Array.isArray(meta.columns)) return []; return (meta.columns || []) - .filter((column) => column && column.name && column.filterable !== false) + .filter((column) => column && column.name && column.filterable !== false && String(column.name) !== "id") .map((column) => { const name = String(column.name); const label = String(column.label || humanizeKey(name)); @@ -3655,7 +3653,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; const genericConfigHeaders = useMemo(() => { if (!activeConfigMeta || !Array.isArray(activeConfigMeta.columns)) return []; const headers = (activeConfigMeta.columns || []) - .filter((column) => column && column.name) + .filter((column) => column && column.name && String(column.name) !== "id") .map((column) => { const name = String(column.name); return { diff --git a/app/web/admin/features/config/ConfigSection.jsx b/app/web/admin/features/config/ConfigSection.jsx index e385b75..9026a2e 100644 --- a/app/web/admin/features/config/ConfigSection.jsx +++ b/app/web/admin/features/config/ConfigSection.jsx @@ -99,6 +99,26 @@ export function ConfigSection(props) { ) : null}
+ + {configActiveKey === "otp_sessions" ? ( - -
openFilterModal(configActiveKey)} @@ -151,21 +149,17 @@ export function ConfigSection(props) { {configActiveKey === "topics" ? ( toggleTableSort("topics", field)} sortClause={(tables.topics.sort && tables.topics.sort[0]) || TABLE_SERVER_CONFIG.topics.sort[0]} renderRow={(row) => ( - - {row.code || "-"} - {row.name || "-"} {boolLabel(row.enabled)} {String(row.sort_order ?? 0)} @@ -215,7 +209,6 @@ export function ConfigSection(props) { {configActiveKey === "statuses" ? ( toggleTableSort("statuses", field)} sortClause={(tables.statuses.sort && tables.statuses.sort[0]) || TABLE_SERVER_CONFIG.statuses.sort[0]} renderRow={(row) => ( - - {row.code || "-"} - {row.name || "-"} {resolveReferenceLabel({ table: "status_groups", value_field: "id", label_field: "name" }, row.status_group_id)} {statusKindLabel(row.kind)} @@ -586,7 +576,7 @@ export function ConfigSection(props) { } renderRow={(row) => ( - {(activeConfigMeta?.columns || []).map((column) => { + {(activeConfigMeta?.columns || []).filter((column) => String(column?.name || "") !== "id").map((column) => { const key = String(column.name || ""); const value = row[key]; if (column.kind === "boolean") return {boolLabel(Boolean(value))}; diff --git a/app/web/admin/features/requests/RequestWorkspace.jsx b/app/web/admin/features/requests/RequestWorkspace.jsx index ee3476a..b33444d 100644 --- a/app/web/admin/features/requests/RequestWorkspace.jsx +++ b/app/web/admin/features/requests/RequestWorkspace.jsx @@ -1280,6 +1280,44 @@ export function RequestWorkspace({ const AttachmentPreviewModal = AttachmentPreviewModalComponent; const StatusLine = StatusLineComponent; + const resolveMessageReceiptState = (payload) => { + const authorType = String(payload?.author_type || "").trim().toUpperCase(); + const isClientAuthor = authorType === "CLIENT"; + const deliveredAt = isClientAuthor ? payload?.delivered_to_staff_at : payload?.delivered_to_client_at; + const readAt = isClientAuthor ? payload?.read_by_staff_at : payload?.read_by_client_at; + if (readAt) return { state: "read", label: "Прочитано" }; + if (deliveredAt) return { state: "delivered", label: "Доставлено" }; + return { state: "sent", label: "Отправлено" }; + }; + + const isOutgoingForViewer = (payload) => { + const authorType = String(payload?.author_type || "").trim().toUpperCase(); + if (!authorType) return false; + if (viewerRoleCode === "CLIENT") return authorType === "CLIENT"; + return authorType !== "CLIENT"; + }; + + const renderMessageMeta = (payload) => { + const timeLabel = fmtTimeOnly(payload?.created_at); + if (!isOutgoingForViewer(payload)) return
{timeLabel}
; + const receipt = resolveMessageReceiptState(payload); + return ( +
+
{timeLabel}
+ + + {receipt.state !== "sent" ? ( + + ) : null} + +
+ ); + }; + const renderRequestDataMessageItems = (payload) => { const items = Array.isArray(payload?.request_data_items) ? payload.request_data_items : []; const allFilled = Boolean(payload?.request_data_all_filled); @@ -1332,13 +1370,40 @@ export function RequestWorkspace({ text: withTail(detail), }; } - if (firstLine.startsWith("Назначен юрист:") || firstLine.startsWith("Переназначено:")) { - const detail = firstLine.startsWith("Назначен юрист:") - ? firstLine.slice("Назначен юрист:".length) - : firstLine.slice("Переназначено:".length); + if (firstLine.startsWith("Назначен юрист:")) { return { title: "Назначен юрист", - text: withTail(detail), + text: withTail(firstLine.slice("Назначен юрист:".length)), + }; + } + if (firstLine.startsWith("Переназначено:")) { + return { + title: "Смена юриста", + text: withTail(firstLine.slice("Переназначено:".length)), + }; + } + if (firstLine.startsWith("Смена юриста:")) { + return { + title: "Смена юриста", + text: withTail(firstLine.slice("Смена юриста:".length)), + }; + } + if (firstLine.startsWith("Назначение юриста:")) { + return { + title: "Назначен юрист", + text: withTail(firstLine.slice("Назначение юриста:".length)), + }; + } + if (firstLine.startsWith("Назначение:")) { + return { + title: "Назначен юрист", + text: withTail(firstLine.slice("Назначение:".length)), + }; + } + if (firstLine.startsWith("Переназначение:")) { + return { + title: "Смена юриста", + text: withTail(firstLine.slice("Переназначение:".length)), }; } return null; @@ -1710,7 +1775,7 @@ export function RequestWorkspace({ ); })()} -
{fmtTimeOnly(entry.payload?.created_at)}
+ {renderMessageMeta(entry.payload)} ); @@ -2134,11 +2199,6 @@ export function RequestWorkspace({ {"Важная дата: " + fmtShortDateTime(item?.important_date_at)} {"Длительность: " + formatDuration(item?.duration_seconds)} - {item?.from_status ? ( -
- {"Из: " + resolveStatusDisplayName(item.from_status, "")} -
- ) : null} {String(item?.comment || "").trim() ? (
{String(item.comment)}
) : null} @@ -2395,6 +2455,8 @@ export function RequestWorkspace({
setDataRequestModal((prev) => ({ @@ -2405,14 +2467,24 @@ export function RequestWorkspace({ error: "", })) } - onFocus={() => setRequestTemplateSuggestOpen(true)} - onBlur={() => window.setTimeout(() => setRequestTemplateSuggestOpen(false), 120)} + onFocus={(event) => { + event.currentTarget.removeAttribute("readonly"); + setRequestTemplateSuggestOpen(true); + }} + onBlur={(event) => { + event.currentTarget.setAttribute("readonly", "readonly"); + window.setTimeout(() => setRequestTemplateSuggestOpen(false), 120); + }} disabled={dataRequestModal.loading || dataRequestModal.saving || dataRequestModal.savingTemplate} placeholder="Введите название шаблона" + readOnly autoComplete="new-password" autoCorrect="off" autoCapitalize="none" spellCheck={false} + aria-autocomplete="list" + data-1p-ignore="true" + data-lpignore="true" /> {requestTemplateBadge ? ( {requestTemplateBadge.label} @@ -2484,6 +2556,8 @@ export function RequestWorkspace({
setDataRequestModal((prev) => ({ @@ -2494,14 +2568,24 @@ export function RequestWorkspace({ error: "", })) } - onFocus={() => setCatalogFieldSuggestOpen(true)} - onBlur={() => window.setTimeout(() => setCatalogFieldSuggestOpen(false), 120)} + onFocus={(event) => { + event.currentTarget.removeAttribute("readonly"); + setCatalogFieldSuggestOpen(true); + }} + onBlur={(event) => { + event.currentTarget.setAttribute("readonly", "readonly"); + window.setTimeout(() => setCatalogFieldSuggestOpen(false), 120); + }} disabled={dataRequestModal.loading || dataRequestModal.saving || dataRequestModal.savingTemplate} placeholder="Начните вводить наименование поля" + readOnly autoComplete="new-password" autoCorrect="off" autoCapitalize="none" spellCheck={false} + aria-autocomplete="list" + data-1p-ignore="true" + data-lpignore="true" /> {catalogFieldSuggestOpen && filteredCatalogFields.length ? (
diff --git a/app/web/client.js b/app/web/client.js index 5a5bd1f..78a868c 100644 --- a/app/web/client.js +++ b/app/web/client.js @@ -1340,6 +1340,27 @@ }, [routeNodes]); const AttachmentPreviewModal = AttachmentPreviewModalComponent; const StatusLine = StatusLineComponent; + const resolveMessageReceiptState = (payload) => { + const authorType = String((payload == null ? void 0 : payload.author_type) || "").trim().toUpperCase(); + const isClientAuthor = authorType === "CLIENT"; + const deliveredAt = isClientAuthor ? payload == null ? void 0 : payload.delivered_to_staff_at : payload == null ? void 0 : payload.delivered_to_client_at; + const readAt = isClientAuthor ? payload == null ? void 0 : payload.read_by_staff_at : payload == null ? void 0 : payload.read_by_client_at; + if (readAt) return { state: "read", label: "\u041F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043E" }; + if (deliveredAt) return { state: "delivered", label: "\u0414\u043E\u0441\u0442\u0430\u0432\u043B\u0435\u043D\u043E" }; + return { state: "sent", label: "\u041E\u0442\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u043E" }; + }; + const isOutgoingForViewer = (payload) => { + const authorType = String((payload == null ? void 0 : payload.author_type) || "").trim().toUpperCase(); + if (!authorType) return false; + if (viewerRoleCode === "CLIENT") return authorType === "CLIENT"; + return authorType !== "CLIENT"; + }; + const renderMessageMeta = (payload) => { + const timeLabel = fmtTimeOnly(payload == null ? void 0 : payload.created_at); + if (!isOutgoingForViewer(payload)) return /* @__PURE__ */ React.createElement("div", { className: "chat-message-time" }, timeLabel); + const receipt = resolveMessageReceiptState(payload); + return /* @__PURE__ */ React.createElement("div", { className: "chat-message-meta" }, /* @__PURE__ */ React.createElement("div", { className: "chat-message-time" }, timeLabel), /* @__PURE__ */ React.createElement("span", { className: "chat-message-status " + receipt.state, title: receipt.label, "aria-label": receipt.label }, /* @__PURE__ */ React.createElement("span", { className: "chat-message-status-check first", "aria-hidden": "true" }, "\u2713"), receipt.state !== "sent" ? /* @__PURE__ */ React.createElement("span", { className: "chat-message-status-check second", "aria-hidden": "true" }, "\u2713") : null)); + }; const renderRequestDataMessageItems = (payload) => { var _a2; const items = Array.isArray(payload == null ? void 0 : payload.request_data_items) ? payload.request_data_items : []; @@ -1377,11 +1398,40 @@ text: withTail(detail) }; } - if (firstLine.startsWith("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D \u044E\u0440\u0438\u0441\u0442:") || firstLine.startsWith("\u041F\u0435\u0440\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043E:")) { - const detail = firstLine.startsWith("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D \u044E\u0440\u0438\u0441\u0442:") ? firstLine.slice("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D \u044E\u0440\u0438\u0441\u0442:".length) : firstLine.slice("\u041F\u0435\u0440\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043E:".length); + if (firstLine.startsWith("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D \u044E\u0440\u0438\u0441\u0442:")) { return { title: "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D \u044E\u0440\u0438\u0441\u0442", - text: withTail(detail) + text: withTail(firstLine.slice("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D \u044E\u0440\u0438\u0441\u0442:".length)) + }; + } + if (firstLine.startsWith("\u041F\u0435\u0440\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043E:")) { + return { + title: "\u0421\u043C\u0435\u043D\u0430 \u044E\u0440\u0438\u0441\u0442\u0430", + text: withTail(firstLine.slice("\u041F\u0435\u0440\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043E:".length)) + }; + } + if (firstLine.startsWith("\u0421\u043C\u0435\u043D\u0430 \u044E\u0440\u0438\u0441\u0442\u0430:")) { + return { + title: "\u0421\u043C\u0435\u043D\u0430 \u044E\u0440\u0438\u0441\u0442\u0430", + text: withTail(firstLine.slice("\u0421\u043C\u0435\u043D\u0430 \u044E\u0440\u0438\u0441\u0442\u0430:".length)) + }; + } + if (firstLine.startsWith("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435 \u044E\u0440\u0438\u0441\u0442\u0430:")) { + return { + title: "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D \u044E\u0440\u0438\u0441\u0442", + text: withTail(firstLine.slice("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435 \u044E\u0440\u0438\u0441\u0442\u0430:".length)) + }; + } + if (firstLine.startsWith("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435:")) { + return { + title: "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D \u044E\u0440\u0438\u0441\u0442", + text: withTail(firstLine.slice("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435:".length)) + }; + } + if (firstLine.startsWith("\u041F\u0435\u0440\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435:")) { + return { + title: "\u0421\u043C\u0435\u043D\u0430 \u044E\u0440\u0438\u0441\u0442\u0430", + text: withTail(firstLine.slice("\u041F\u0435\u0440\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435:".length)) }; } return null; @@ -1553,7 +1603,7 @@ /* @__PURE__ */ React.createElement("span", { className: "chat-message-file-name" }, String(((_d = entry.payload) == null ? void 0 : _d.file_name) || "\u0424\u0430\u0439\u043B")) )), /* @__PURE__ */ React.createElement("div", { className: "chat-message-time" }, fmtTimeOnly((_e = entry.payload) == null ? void 0 : _e.created_at))) ) : (() => { - var _a3, _b3, _c3, _d2, _e2, _f, _g, _h, _i; + var _a3, _b3, _c3, _d2, _e2, _f, _g, _h; const messageKind = String(((_a3 = entry.payload) == null ? void 0 : _a3.message_kind) || ""); const isRequestDataMessage = messageKind === "REQUEST_DATA"; const serviceMessageContent = resolveServiceMessageContent(entry.payload); @@ -1600,7 +1650,7 @@ /* @__PURE__ */ React.createElement("span", { className: "chat-message-file-name" }, String(file.file_name || "\u0424\u0430\u0439\u043B")) ))); })(), - /* @__PURE__ */ React.createElement("div", { className: "chat-message-time" }, fmtTimeOnly((_i = entry.payload) == null ? void 0 : _i.created_at)) + renderMessageMeta(entry.payload) )); })(); } @@ -1876,7 +1926,7 @@ const statusCode = String((item == null ? void 0 : item.to_status) || ""); const statusMeta = statusByCode.get(statusCode); const itemClass = "route-item request-status-history-route-item " + (index === 0 ? "current" : "completed"); - return /* @__PURE__ */ React.createElement("li", { key: String((item == null ? void 0 : item.id) || index), className: itemClass }, /* @__PURE__ */ React.createElement("span", { className: "route-dot" }), /* @__PURE__ */ React.createElement("div", { className: "route-body" }, /* @__PURE__ */ React.createElement("div", { className: "request-status-history-row" }, /* @__PURE__ */ React.createElement("b", null, resolveStatusDisplayName(statusCode, (item == null ? void 0 : item.to_status_name) || (statusMeta == null ? void 0 : statusMeta.name) || "")), (statusMeta == null ? void 0 : statusMeta.isTerminal) ? /* @__PURE__ */ React.createElement("span", { className: "request-status-history-chip" }, "\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u044C\u043D\u044B\u0439") : null), /* @__PURE__ */ React.createElement("div", { className: "muted route-time" }, fmtShortDateTime(item == null ? void 0 : item.changed_at)), /* @__PURE__ */ React.createElement("div", { className: "request-status-history-meta" }, /* @__PURE__ */ React.createElement("span", null, "\u0412\u0430\u0436\u043D\u0430\u044F \u0434\u0430\u0442\u0430: " + fmtShortDateTime(item == null ? void 0 : item.important_date_at)), /* @__PURE__ */ React.createElement("span", null, "\u0414\u043B\u0438\u0442\u0435\u043B\u044C\u043D\u043E\u0441\u0442\u044C: " + formatDuration(item == null ? void 0 : item.duration_seconds))), (item == null ? void 0 : item.from_status) ? /* @__PURE__ */ React.createElement("div", { className: "request-status-history-meta" }, /* @__PURE__ */ React.createElement("span", null, "\u0418\u0437: " + resolveStatusDisplayName(item.from_status, ""))) : null, String((item == null ? void 0 : item.comment) || "").trim() ? /* @__PURE__ */ React.createElement("div", { className: "request-status-history-comment" }, String(item.comment)) : null)); + return /* @__PURE__ */ React.createElement("li", { key: String((item == null ? void 0 : item.id) || index), className: itemClass }, /* @__PURE__ */ React.createElement("span", { className: "route-dot" }), /* @__PURE__ */ React.createElement("div", { className: "route-body" }, /* @__PURE__ */ React.createElement("div", { className: "request-status-history-row" }, /* @__PURE__ */ React.createElement("b", null, resolveStatusDisplayName(statusCode, (item == null ? void 0 : item.to_status_name) || (statusMeta == null ? void 0 : statusMeta.name) || "")), (statusMeta == null ? void 0 : statusMeta.isTerminal) ? /* @__PURE__ */ React.createElement("span", { className: "request-status-history-chip" }, "\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u044C\u043D\u044B\u0439") : null), /* @__PURE__ */ React.createElement("div", { className: "muted route-time" }, fmtShortDateTime(item == null ? void 0 : item.changed_at)), /* @__PURE__ */ React.createElement("div", { className: "request-status-history-meta" }, /* @__PURE__ */ React.createElement("span", null, "\u0412\u0430\u0436\u043D\u0430\u044F \u0434\u0430\u0442\u0430: " + fmtShortDateTime(item == null ? void 0 : item.important_date_at)), /* @__PURE__ */ React.createElement("span", null, "\u0414\u043B\u0438\u0442\u0435\u043B\u044C\u043D\u043E\u0441\u0442\u044C: " + formatDuration(item == null ? void 0 : item.duration_seconds))), String((item == null ? void 0 : item.comment) || "").trim() ? /* @__PURE__ */ React.createElement("div", { className: "request-status-history-comment" }, String(item.comment)) : null)); }) : /* @__PURE__ */ React.createElement("li", { className: "muted" }, "\u0418\u0441\u0442\u043E\u0440\u0438\u044F \u0438\u0437\u043C\u0435\u043D\u0435\u043D\u0438\u0439 \u0441\u0442\u0430\u0442\u0443\u0441\u043E\u0432 \u043F\u043E\u043A\u0430 \u043F\u0443\u0441\u0442\u0430\u044F"))), statusChangeModal.error ? /* @__PURE__ */ React.createElement("div", { className: "status error" }, statusChangeModal.error) : null, /* @__PURE__ */ React.createElement("div", { className: "modal-actions modal-actions-right" }, /* @__PURE__ */ React.createElement( "button", { @@ -1979,6 +2029,8 @@ "input", { id: "request-data-request-template-select", + name: "request_template_search_nohistory", + type: "text", value: dataRequestModal.requestTemplateQuery, onChange: (event) => setDataRequestModal((prev) => ({ ...prev, @@ -1987,10 +2039,24 @@ templateStatus: "", error: "" })), - onFocus: () => setRequestTemplateSuggestOpen(true), - onBlur: () => window.setTimeout(() => setRequestTemplateSuggestOpen(false), 120), + onFocus: (event) => { + event.currentTarget.removeAttribute("readonly"); + setRequestTemplateSuggestOpen(true); + }, + onBlur: (event) => { + event.currentTarget.setAttribute("readonly", "readonly"); + window.setTimeout(() => setRequestTemplateSuggestOpen(false), 120); + }, disabled: dataRequestModal.loading || dataRequestModal.saving || dataRequestModal.savingTemplate, - placeholder: "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435 \u0448\u0430\u0431\u043B\u043E\u043D\u0430" + placeholder: "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435 \u0448\u0430\u0431\u043B\u043E\u043D\u0430", + readOnly: true, + autoComplete: "new-password", + autoCorrect: "off", + autoCapitalize: "none", + spellCheck: false, + "aria-autocomplete": "list", + "data-1p-ignore": "true", + "data-lpignore": "true" } ), requestTemplateBadge ? /* @__PURE__ */ React.createElement("span", { className: "request-data-template-badge " + requestTemplateBadge.kind }, requestTemplateBadge.label) : null, requestTemplateSuggestOpen && filteredRequestTemplates.length ? /* @__PURE__ */ React.createElement("div", { className: "request-data-suggest-list", role: "listbox", "aria-label": "\u0428\u0430\u0431\u043B\u043E\u043D\u044B \u0437\u0430\u043F\u0440\u043E\u0441\u0430" }, filteredRequestTemplates.map((tpl) => /* @__PURE__ */ React.createElement( "button", @@ -2026,6 +2092,8 @@ "input", { id: "request-data-template-select", + name: "request_field_search_nohistory", + type: "text", value: dataRequestModal.catalogFieldQuery, onChange: (event) => setDataRequestModal((prev) => ({ ...prev, @@ -2034,11 +2102,24 @@ templateStatus: "", error: "" })), - onFocus: () => setCatalogFieldSuggestOpen(true), - onBlur: () => window.setTimeout(() => setCatalogFieldSuggestOpen(false), 120), + onFocus: (event) => { + event.currentTarget.removeAttribute("readonly"); + setCatalogFieldSuggestOpen(true); + }, + onBlur: (event) => { + event.currentTarget.setAttribute("readonly", "readonly"); + window.setTimeout(() => setCatalogFieldSuggestOpen(false), 120); + }, disabled: dataRequestModal.loading || dataRequestModal.saving || dataRequestModal.savingTemplate, placeholder: "\u041D\u0430\u0447\u043D\u0438\u0442\u0435 \u0432\u0432\u043E\u0434\u0438\u0442\u044C \u043D\u0430\u0438\u043C\u0435\u043D\u043E\u0432\u0430\u043D\u0438\u0435 \u043F\u043E\u043B\u044F", - autoComplete: "off" + readOnly: true, + autoComplete: "new-password", + autoCorrect: "off", + autoCapitalize: "none", + spellCheck: false, + "aria-autocomplete": "list", + "data-1p-ignore": "true", + "data-lpignore": "true" } ), catalogFieldSuggestOpen && filteredCatalogFields.length ? /* @__PURE__ */ React.createElement("div", { className: "request-data-suggest-list", role: "listbox", "aria-label": "\u041F\u043E\u043B\u044F \u0434\u0430\u043D\u043D\u044B\u0445" }, filteredCatalogFields.map((tpl) => /* @__PURE__ */ React.createElement( "button", diff --git a/app/web/landing.css b/app/web/landing.css index 9762728..7456b2a 100644 --- a/app/web/landing.css +++ b/app/web/landing.css @@ -62,6 +62,7 @@ align-items: center; justify-content: space-between; gap: 1rem; + position: relative; } .brand { @@ -98,6 +99,42 @@ opacity: 0.92; } + .nav-toggle { + display: none; + width: 42px; + height: 42px; + border-radius: 12px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.04); + color: #e2ebf8; + padding: 0; + cursor: pointer; + align-items: center; + justify-content: center; + gap: 4px; + flex-direction: column; + } + + .nav-toggle span { + width: 18px; + height: 2px; + border-radius: 999px; + background: currentColor; + transition: transform 0.2s ease, opacity 0.2s ease; + } + + .topbar.nav-open .nav-toggle span:nth-child(1) { + transform: translateY(6px) rotate(45deg); + } + + .topbar.nav-open .nav-toggle span:nth-child(2) { + opacity: 0; + } + + .topbar.nav-open .nav-toggle span:nth-child(3) { + transform: translateY(-6px) rotate(-45deg); + } + .btn { border: 1px solid transparent; border-radius: 999px; @@ -770,10 +807,10 @@ border-radius: 16px; background: linear-gradient(165deg, rgba(32, 43, 57, 0.95), rgba(17, 24, 32, 0.96)); display: grid; - grid-template-columns: 88px 1fr; - gap: 0.8rem; + grid-template-columns: 150px 1fr; + gap: 0.95rem; padding: 0.85rem; - min-height: 146px; + min-height: 188px; box-shadow: 0 18px 34px rgba(0, 0, 0, 0.18); animation: rise 0.45s ease forwards; opacity: 0; @@ -781,13 +818,14 @@ } .featured-avatar { - width: 88px; - height: 88px; + width: 146px; + height: 146px; border-radius: 50%; object-fit: cover; - border: 1px solid rgba(212, 169, 104, 0.35); + border: 1px solid rgba(255, 255, 255, 0.24); + box-shadow: 0 0 0 1px rgba(212, 169, 104, 0.42), 0 0 0 5px rgba(212, 169, 104, 0.08); background: rgba(255, 255, 255, 0.04); - align-self: start; + align-self: center; } .featured-card-body { @@ -840,6 +878,41 @@ overflow-wrap: anywhere; } + .featured-caption > :first-child { + margin-top: 0; + } + + .featured-caption > :last-child { + margin-bottom: 0; + } + + .featured-caption p { + margin: 0.14rem 0; + } + + .featured-caption ul { + margin: 0.2rem 0; + padding-left: 1.1rem; + display: grid; + gap: 0.15rem; + } + + .featured-caption code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + font-size: 0.83em; + padding: 0.08em 0.35em; + border-radius: 6px; + border: 1px solid rgba(154, 176, 206, 0.24); + background: rgba(33, 47, 66, 0.72); + color: #f3dfbf; + } + + .featured-caption a { + color: #f1ca8f; + text-decoration: underline; + text-underline-offset: 0.14em; + } + .carousel-nav { width: 40px; height: 40px; @@ -912,26 +985,54 @@ @media (max-width: 740px) { .topbar-inner { - flex-direction: column; - align-items: flex-start; - padding: 0.72rem 0; + min-height: 66px; + padding: 0.62rem 0; + } + + .brand { + max-width: calc(100% - 56px); + } + + .nav-toggle { + display: inline-flex; } .nav { - width: 100%; + position: absolute; + top: calc(100% - 1px); + left: 0; + right: 0; display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.5rem; + grid-template-columns: 1fr; + gap: 0.45rem; + padding: 0.62rem; + border-radius: 16px; + border: 1px solid var(--line); + background: linear-gradient(155deg, rgba(27, 38, 52, 0.95), rgba(17, 24, 33, 0.96)); + box-shadow: 0 18px 30px rgba(4, 8, 12, 0.45); + opacity: 0; + transform: translateY(-7px); + visibility: hidden; + pointer-events: none; + transition: opacity 0.18s ease, transform 0.18s ease, visibility 0.18s ease; + } + + .topbar.nav-open .nav { + opacity: 1; + transform: translateY(0); + visibility: visible; + pointer-events: auto; } .nav a, .nav .btn { width: 100%; text-align: center; + justify-content: center; } .hero { - padding-top: 2.7rem; + padding-top: 2.4rem; } .stats { @@ -976,12 +1077,8 @@ width: calc(100% - 1rem); } - .topbar { - position: static; - } - section { - scroll-margin-top: 0; + scroll-margin-top: 76px; } .brand { @@ -990,16 +1087,17 @@ } .nav { - grid-template-columns: 1fr; + top: calc(100% - 3px); } .featured-card { - grid-template-columns: 1fr; + grid-template-columns: 114px 1fr; + min-height: 156px; } .featured-avatar { - width: 76px; - height: 76px; + width: 106px; + height: 106px; } .hero { diff --git a/app/web/landing.html b/app/web/landing.html index c6584d0..202e156 100644 --- a/app/web/landing.html +++ b/app/web/landing.html @@ -15,6 +15,17 @@ Аудиторы корпоративной безопасности
+
- + diff --git a/app/web/landing.js b/app/web/landing.js index 861f472..aaab48f 100644 --- a/app/web/landing.js +++ b/app/web/landing.js @@ -36,11 +36,51 @@ const featuredTeamNext = document.getElementById("featured-team-next"); const requestEmailInput = document.getElementById("email"); const requestHpInput = document.getElementById("request-hp-field"); + const topbar = document.querySelector(".topbar"); + const topbarNav = document.querySelector(".nav"); + const navToggle = document.querySelector("[data-nav-toggle]"); + const mobileNavMql = window.matchMedia("(max-width: 740px)"); let otpModalResolver = null; let lastAccessOtpChannel = "SMS"; let lastCreateOtpChannel = "SMS"; let authConfig = { public_auth_mode: "sms", available_channels: ["SMS"] }; + function setTopbarNavOpen(open) { + if (!topbar || !navToggle) return; + topbar.classList.toggle("nav-open", Boolean(open)); + navToggle.setAttribute("aria-expanded", open ? "true" : "false"); + navToggle.setAttribute("aria-label", open ? "Закрыть навигацию" : "Открыть навигацию"); + } + + function closeTopbarNav() { + setTopbarNavOpen(false); + } + + function initTopbarNav() { + if (!topbar || !topbarNav || !navToggle) return; + navToggle.addEventListener("click", (event) => { + event.stopPropagation(); + setTopbarNavOpen(!topbar.classList.contains("nav-open")); + }); + + topbarNav.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) return; + const action = event.target.closest("a, button"); + if (!action) return; + if (mobileNavMql.matches) closeTopbarNav(); + }); + + document.addEventListener("click", (event) => { + if (!mobileNavMql.matches || !topbar.classList.contains("nav-open")) return; + if (topbar.contains(event.target)) return; + closeTopbarNav(); + }); + + window.addEventListener("resize", () => { + if (!mobileNavMql.matches) closeTopbarNav(); + }); + } + function setStatus(el, message, kind) { if (!el) return; el.className = "status"; @@ -62,6 +102,71 @@ return fallbackMessage; } + function escapeHtml(value) { + return String(value || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + function markdownInlineToHtml(escapedText) { + const tokens = []; + const makeToken = (html) => { + const id = tokens.length; + tokens.push(html); + return "\u0001" + String(id) + "\u0001"; + }; + let out = String(escapedText || ""); + out = out.replace(/`([^`\n]+)`/g, (_, code) => makeToken("" + code + "")); + out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/gi, (_, label, url) => + makeToken('' + label + "") + ); + out = out.replace(/\*\*([^*\n]+)\*\*/g, "$1"); + out = out.replace(/__([^_\n]+)__/g, "$1"); + out = out.replace(/(^|[\s(])\*([^*\n]+)\*(?=[\s).,!?;:]|$)/g, "$1$2"); + out = out.replace(/(^|[\s(])_([^_\n]+)_(?=[\s).,!?;:]|$)/g, "$1$2"); + return out.replace(/\u0001(\d+)\u0001/g, (_, indexRaw) => { + const index = Number(indexRaw); + return Number.isInteger(index) && tokens[index] ? tokens[index] : ""; + }); + } + + function markdownToHtml(value) { + const normalized = String(value || "").replace(/\r\n?/g, "\n").trim(); + if (!normalized) return ""; + const blocks = []; + const listItems = []; + const flushList = () => { + if (!listItems.length) return; + blocks.push("
    " + listItems.map((item) => "
  • " + item + "
  • ").join("") + "
"); + listItems.length = 0; + }; + normalized.split("\n").forEach((lineRaw) => { + const line = String(lineRaw || "").trim(); + if (!line) { + flushList(); + return; + } + const listMatch = line.match(/^[-*]\s+(.+)$/); + if (listMatch) { + listItems.push(markdownInlineToHtml(escapeHtml(listMatch[1]))); + return; + } + flushList(); + const headingMatch = line.match(/^(#{1,3})\s+(.+)$/); + if (headingMatch) { + const level = Math.min(3, headingMatch[1].length); + blocks.push("" + markdownInlineToHtml(escapeHtml(headingMatch[2])) + ""); + return; + } + blocks.push("

" + markdownInlineToHtml(escapeHtml(line)) + "

"); + }); + flushList(); + return blocks.join(""); + } + function normalizeEmail(value) { return String(value || "").trim().toLowerCase(); } @@ -278,6 +383,7 @@ closeModal(otpModal); closeModal(requestModal); closeModal(accessModal); + closeTopbarNav(); }); async function loadTopics() { @@ -443,14 +549,18 @@ } body.appendChild(top); - const meta = document.createElement("p"); - meta.className = "featured-meta"; - meta.textContent = [item.role_label, item.primary_topic_name].filter(Boolean).join(" • "); - body.appendChild(meta); + const metaText = String(item.primary_topic_name || "").trim(); + if (metaText) { + const meta = document.createElement("p"); + meta.className = "featured-meta"; + meta.textContent = metaText; + body.appendChild(meta); + } - const caption = document.createElement("p"); + const caption = document.createElement("div"); caption.className = "featured-caption"; - caption.textContent = String(item.caption || "Практический опыт в сложных юридических делах и сопровождении споров."); + const captionText = String(item.caption || "").trim() || "Практический опыт в сложных юридических делах и сопровождении споров."; + caption.innerHTML = markdownToHtml(captionText); body.appendChild(caption); card.appendChild(body); @@ -664,4 +774,5 @@ loadTopics(); loadQuotes(); loadFeaturedStaff(); + initTopbarNav(); })(); diff --git a/tests/admin/test_assignment_users.py b/tests/admin/test_assignment_users.py index 4854ef6..8b45016 100644 --- a/tests/admin/test_assignment_users.py +++ b/tests/admin/test_assignment_users.py @@ -51,6 +51,20 @@ class AdminAssignmentAndUsersTests(AdminUniversalCrudBase): row = db.get(Request, UUID(request_id)) self.assertIsNotNone(row) self.assertEqual(row.assigned_lawyer_id, lawyer1_id) + self.assertTrue(bool(row.client_has_unread_updates)) + self.assertEqual(str(row.client_unread_event_type or "").upper(), "ASSIGNMENT") + self.assertTrue(bool(row.lawyer_has_unread_updates)) + self.assertEqual(str(row.lawyer_unread_event_type or "").upper(), "ASSIGNMENT") + messages = ( + db.query(Message) + .filter(Message.request_id == UUID(request_id)) + .order_by(Message.created_at.asc(), Message.id.asc()) + .all() + ) + self.assertEqual(len(messages), 1) + self.assertEqual(str(messages[0].author_type or "").upper(), "SYSTEM") + self.assertTrue(bool(messages[0].immutable)) + self.assertIn("Назначен юрист:", str(messages[0].body or "")) claim_audits = db.query(AuditLog).filter(AuditLog.entity == "requests", AuditLog.entity_id == request_id, AuditLog.action == "MANUAL_CLAIM").all() self.assertEqual(len(claim_audits), 1) @@ -168,6 +182,20 @@ class AdminAssignmentAndUsersTests(AdminUniversalCrudBase): row = db.get(Request, UUID(request_id)) self.assertIsNotNone(row) self.assertEqual(row.assigned_lawyer_id, lawyer_to_id) + self.assertTrue(bool(row.client_has_unread_updates)) + self.assertEqual(str(row.client_unread_event_type or "").upper(), "REASSIGNMENT") + self.assertTrue(bool(row.lawyer_has_unread_updates)) + self.assertEqual(str(row.lawyer_unread_event_type or "").upper(), "REASSIGNMENT") + messages = ( + db.query(Message) + .filter(Message.request_id == UUID(request_id)) + .order_by(Message.created_at.asc(), Message.id.asc()) + .all() + ) + self.assertGreaterEqual(len(messages), 2) + self.assertEqual(str(messages[-1].author_type or "").upper(), "SYSTEM") + self.assertTrue(bool(messages[-1].immutable)) + self.assertIn("Переназначено:", str(messages[-1].body or "")) events = db.query(AuditLog).filter(AuditLog.entity == "requests", AuditLog.entity_id == request_id).all() actions = [event.action for event in events] self.assertIn("MANUAL_REASSIGN", actions) diff --git a/tests/admin/test_lawyer_chat.py b/tests/admin/test_lawyer_chat.py index 13dfc23..0a1827d 100644 --- a/tests/admin/test_lawyer_chat.py +++ b/tests/admin/test_lawyer_chat.py @@ -539,6 +539,64 @@ class AdminLawyerChatTests(AdminUniversalCrudBase): typing_rows = own_live_with_typing.json().get("typing") or [] self.assertTrue(any(str(item.get("actor_role")) == "ADMIN" for item in typing_rows)) + def test_admin_chat_marks_delivery_and_read_receipts_for_client_messages(self): + with self.SessionLocal() as db: + lawyer_self = AdminUser( + role="LAWYER", + name="Юрист Receipt", + email="lawyer.receipt@example.com", + password_hash="hash", + is_active=True, + ) + db.add(lawyer_self) + db.flush() + self_id = str(lawyer_self.id) + + own = Request( + track_number="TRK-CHAT-RECEIPTS-STAFF", + client_name="Клиент Receipt", + client_phone="+79995550777", + status_code="IN_PROGRESS", + description="staff receipts", + extra_fields={}, + assigned_lawyer_id=self_id, + ) + db.add(own) + db.flush() + msg = Message( + request_id=own.id, + author_type="CLIENT", + author_name="Клиент", + body="Сообщение клиента", + ) + db.add(msg) + db.commit() + own_id = str(own.id) + message_id = msg.id + + lawyer_headers = self._auth_headers("LAWYER", email="lawyer.receipt@example.com", sub=self_id) + + live = self.chat_client.get(f"/api/admin/chat/requests/{own_id}/live", headers=lawyer_headers) + self.assertEqual(live.status_code, 200) + + with self.SessionLocal() as db: + delivered_row = db.get(Message, message_id) + self.assertIsNotNone(delivered_row) + self.assertIsNotNone(delivered_row.delivered_to_staff_at) + self.assertIsNone(delivered_row.read_by_staff_at) + + listed = self.chat_client.get(f"/api/admin/chat/requests/{own_id}/messages", headers=lawyer_headers) + self.assertEqual(listed.status_code, 200) + self.assertEqual(int(listed.json().get("total") or 0), 1) + first = (listed.json().get("rows") or [{}])[0] + self.assertTrue(bool(first.get("delivered_to_staff_at"))) + self.assertTrue(bool(first.get("read_by_staff_at"))) + + with self.SessionLocal() as db: + read_row = db.get(Message, message_id) + self.assertIsNotNone(read_row) + self.assertIsNotNone(read_row.read_by_staff_at) + def test_admin_live_detects_client_filled_request_data_updates(self): with self.SessionLocal() as db: now = datetime.now(timezone.utc) diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 8db8535..5fbb441 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -114,7 +114,7 @@ class MigrationTests(unittest.TestCase): def test_alembic_version_is_set(self): with self.engine.connect() as conn: version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one() - self.assertEqual(version, "0032_email_cols_fix") + self.assertEqual(version, "0033_message_receipts") def test_responsible_column_exists_in_all_domain_tables(self): tables = { @@ -276,6 +276,13 @@ class MigrationTests(unittest.TestCase): self.assertIn("value_text", columns) self.assertIn("sort_order", columns) + def test_messages_contains_delivery_and_read_receipt_columns(self): + columns = {column["name"] for column in self.inspector.get_columns("messages")} + self.assertIn("delivered_to_client_at", columns) + self.assertIn("delivered_to_staff_at", columns) + self.assertIn("read_by_client_at", columns) + self.assertIn("read_by_staff_at", columns) + def test_request_data_template_tables_contain_core_columns(self): templates = {column["name"] for column in self.inspector.get_columns("request_data_templates")} self.assertIn("topic_code", templates) diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 0b4b3cf..7783b87 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -353,6 +353,16 @@ class NotificationFlowTests(unittest.TestCase): self.assertEqual(str(req.client_unread_event_type or "").upper(), "REASSIGNMENT") self.assertTrue(bool(req.lawyer_has_unread_updates)) self.assertEqual(str(req.lawyer_unread_event_type or "").upper(), "REASSIGNMENT") + messages = ( + db.query(Message) + .filter(Message.request_id == UUID(request_id)) + .order_by(Message.created_at.asc(), Message.id.asc()) + .all() + ) + self.assertEqual(len(messages), 1) + self.assertEqual(str(messages[0].author_type or "").upper(), "SYSTEM") + self.assertTrue(bool(messages[0].immutable)) + self.assertIn("Переназначено:", str(messages[0].body or "")) def test_request_data_event_from_client_notifies_lawyer_and_admin(self): with self.SessionLocal() as db: diff --git a/tests/test_public_cabinet.py b/tests/test_public_cabinet.py index 9997afb..3b772b8 100644 --- a/tests/test_public_cabinet.py +++ b/tests/test_public_cabinet.py @@ -280,6 +280,50 @@ class PublicCabinetTests(unittest.TestCase): denied = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-001/messages", cookies=self._public_cookies("TRK-OTHER")) self.assertEqual(denied.status_code, 404) + def test_public_chat_marks_delivery_and_read_receipts_for_staff_messages(self): + with self.SessionLocal() as db: + req = Request( + track_number="TRK-CHAT-RECEIPTS-CLIENT", + client_name="Клиент Чат Receipt", + client_phone="+79997774411", + topic_code="consulting", + status_code="IN_PROGRESS", + description="Проверка delivered/read для клиента", + extra_fields={}, + ) + db.add(req) + db.flush() + msg = Message( + request_id=req.id, + author_type="LAWYER", + author_name="Юрист", + body="Проверка receipt", + ) + db.add(msg) + db.commit() + message_id = msg.id + + cookies = self._public_cookies("TRK-CHAT-RECEIPTS-CLIENT") + live = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-RECEIPTS-CLIENT/live", cookies=cookies) + self.assertEqual(live.status_code, 200) + + with self.SessionLocal() as db: + delivered_row = db.get(Message, message_id) + self.assertIsNotNone(delivered_row) + self.assertIsNotNone(delivered_row.delivered_to_client_at) + self.assertIsNone(delivered_row.read_by_client_at) + + listed = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-RECEIPTS-CLIENT/messages", cookies=cookies) + self.assertEqual(listed.status_code, 200) + self.assertEqual(len(listed.json()), 1) + self.assertTrue(bool(listed.json()[0].get("delivered_to_client_at"))) + self.assertTrue(bool(listed.json()[0].get("read_by_client_at"))) + + with self.SessionLocal() as db: + read_row = db.get(Message, message_id) + self.assertIsNotNone(read_row) + self.assertIsNotNone(read_row.read_by_client_at) + def test_chat_message_is_encrypted_at_rest(self): with self.SessionLocal() as db: req = Request(