mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
fix UI 11
This commit is contained in:
parent
73b8c5d49f
commit
3bff88b38a
22 changed files with 1152 additions and 212 deletions
77
alembic/versions/0033_add_message_delivery_read_receipts.py
Normal file
77
alembic/versions/0033_add_message_delivery_read_receipts.py
Normal 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")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
140
app/services/request_assignment_events.py
Normal file
140
app/services/request_assignment_events.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
120
app/web/admin.js
120
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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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="Поля данных">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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, """)
|
||||
.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>" + 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();
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue