fix UI 11

This commit is contained in:
TronoSfera 2026-03-04 19:41:34 +03:00
parent 73b8c5d49f
commit 3bff88b38a
22 changed files with 1152 additions and 212 deletions

View file

@ -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")

View file

@ -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)

View file

@ -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:

View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

@ -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),
}

View file

@ -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,
}

View file

@ -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;

View file

@ -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,

View file

@ -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 {

View file

@ -99,17 +99,6 @@ export function ConfigSection(props) {
) : null}
</div>
<div className="config-head-actions">
{configActiveKey === "otp_sessions" ? (
<button className="btn secondary" type="button" onClick={onRefreshSmsProviderHealth}>
Баланс
</button>
) : null}
</div>
</div>
<div className="config-layout">
<div className="config-panel config-panel-flat">
<div className="config-content">
<div className="config-floating-actions">
<button
className="btn secondary table-control-btn"
type="button"
@ -130,7 +119,16 @@ export function ConfigSection(props) {
>
<FilterIcon />
</button>
{configActiveKey === "otp_sessions" ? (
<button className="btn secondary" type="button" onClick={onRefreshSmsProviderHealth}>
Баланс
</button>
) : null}
</div>
</div>
<div className="config-layout">
<div className="config-panel config-panel-flat">
<div className="config-content">
<FilterToolbar
filters={activeConfigTableState.filters}
onOpen={() => openFilterModal(configActiveKey)}
@ -151,21 +149,17 @@ export function ConfigSection(props) {
{configActiveKey === "topics" ? (
<DataTable
headers={[
{ key: "code", label: "Код", sortable: true, field: "code" },
{ key: "name", label: "Название", sortable: true, field: "name" },
{ key: "enabled", label: "Активна", sortable: true, field: "enabled" },
{ key: "sort_order", label: "Порядок", sortable: true, field: "sort_order" },
{ key: "actions", label: "Действия" },
]}
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) => (
<tr key={row.id}>
<td>
<code>{row.code || "-"}</code>
</td>
<td>{row.name || "-"}</td>
<td>{boolLabel(row.enabled)}</td>
<td>{String(row.sort_order ?? 0)}</td>
@ -215,7 +209,6 @@ export function ConfigSection(props) {
{configActiveKey === "statuses" ? (
<DataTable
headers={[
{ key: "code", label: "Код", sortable: true, field: "code" },
{ key: "name", label: "Название", sortable: true, field: "name" },
{ key: "status_group_id", label: "Группа", sortable: true, field: "status_group_id" },
{ key: "kind", label: "Тип", sortable: true, field: "kind" },
@ -226,14 +219,11 @@ export function ConfigSection(props) {
{ key: "actions", label: "Действия" },
]}
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) => (
<tr key={row.id}>
<td>
<code>{row.code || "-"}</code>
</td>
<td>{row.name || "-"}</td>
<td>{resolveReferenceLabel({ table: "status_groups", value_field: "id", label_field: "name" }, row.status_group_id)}</td>
<td>{statusKindLabel(row.kind)}</td>
@ -586,7 +576,7 @@ export function ConfigSection(props) {
}
renderRow={(row) => (
<tr key={row.id || JSON.stringify(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 <td key={key}>{boolLabel(Boolean(value))}</td>;

View file

@ -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 <div className="chat-message-time">{timeLabel}</div>;
const receipt = resolveMessageReceiptState(payload);
return (
<div className="chat-message-meta">
<div className="chat-message-time">{timeLabel}</div>
<span className={"chat-message-status " + receipt.state} title={receipt.label} aria-label={receipt.label}>
<span className="chat-message-status-check first" aria-hidden="true">
</span>
{receipt.state !== "sent" ? (
<span className="chat-message-status-check second" aria-hidden="true">
</span>
) : null}
</span>
</div>
);
};
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({
</div>
);
})()}
<div className="chat-message-time">{fmtTimeOnly(entry.payload?.created_at)}</div>
{renderMessageMeta(entry.payload)}
</div>
</li>
);
@ -2134,11 +2199,6 @@ export function RequestWorkspace({
<span>{"Важная дата: " + fmtShortDateTime(item?.important_date_at)}</span>
<span>{"Длительность: " + formatDuration(item?.duration_seconds)}</span>
</div>
{item?.from_status ? (
<div className="request-status-history-meta">
<span>{"Из: " + resolveStatusDisplayName(item.from_status, "")}</span>
</div>
) : null}
{String(item?.comment || "").trim() ? (
<div className="request-status-history-comment">{String(item.comment)}</div>
) : null}
@ -2395,6 +2455,8 @@ export function RequestWorkspace({
<div className="request-data-combobox">
<input
id="request-data-request-template-select"
name="request_template_search_nohistory"
type="text"
value={dataRequestModal.requestTemplateQuery}
onChange={(event) =>
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 ? (
<span className={"request-data-template-badge " + requestTemplateBadge.kind}>{requestTemplateBadge.label}</span>
@ -2484,6 +2556,8 @@ export function RequestWorkspace({
<div className="request-data-combobox">
<input
id="request-data-template-select"
name="request_field_search_nohistory"
type="text"
value={dataRequestModal.catalogFieldQuery}
onChange={(event) =>
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 ? (
<div className="request-data-suggest-list" role="listbox" aria-label="Поля данных">

View file

@ -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",

View file

@ -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 {

View file

@ -15,6 +15,17 @@
<img class="brand-mark" src="/brand-mark.svg" alt="" width="28" height="28">
<span>Аудиторы корпоративной безопасности</span>
</div>
<button
class="nav-toggle"
type="button"
aria-label="Открыть навигацию"
aria-expanded="false"
data-nav-toggle
>
<span></span>
<span></span>
<span></span>
</button>
<nav class="nav">
<a href="#practices">Компетенции</a>
<a href="#approach">Подход</a>
@ -285,6 +296,6 @@
</div>
</div>
<script src="/landing.js?v=20260302-02"></script>
<script src="/landing.js?v=20260303-01"></script>
</body>
</html>

View file

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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>" + code + "</code>"));
out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/gi, (_, label, url) =>
makeToken('<a href="' + url + '" target="_blank" rel="noreferrer noopener">' + label + "</a>")
);
out = out.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
out = out.replace(/__([^_\n]+)__/g, "<strong>$1</strong>");
out = out.replace(/(^|[\s(])\*([^*\n]+)\*(?=[\s).,!?;:]|$)/g, "$1<em>$2</em>");
out = out.replace(/(^|[\s(])_([^_\n]+)_(?=[\s).,!?;:]|$)/g, "$1<em>$2</em>");
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("<ul>" + listItems.map((item) => "<li>" + item + "</li>").join("") + "</ul>");
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("<h" + String(level) + ">" + markdownInlineToHtml(escapeHtml(headingMatch[2])) + "</h" + String(level) + ">");
return;
}
blocks.push("<p>" + markdownInlineToHtml(escapeHtml(line)) + "</p>");
});
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 metaText = String(item.primary_topic_name || "").trim();
if (metaText) {
const meta = document.createElement("p");
meta.className = "featured-meta";
meta.textContent = [item.role_label, item.primary_topic_name].filter(Boolean).join(" • ");
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();
})();

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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:

View file

@ -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(