From 3bff88b38a35996a02b71e74bea328430a33a53a Mon Sep 17 00:00:00 2001 From: TronoSfera <119615520+TronoSfera@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:41:34 +0300 Subject: [PATCH] fix UI 11 --- ...0033_add_message_delivery_read_receipts.py | 77 ++++++++++ app/api/admin/chat.py | 4 + app/api/admin/crud_modules/service.py | 68 ++++----- app/api/admin/requests_modules/service.py | 52 ++++--- app/api/public/chat.py | 4 + app/models/message.py | 8 +- app/services/chat_secure_service.py | 90 ++++++++++- app/services/request_assignment_events.py | 140 +++++++++++++++++ app/web/admin.css | 91 ++++++++--- app/web/admin.js | 120 ++++++++++++--- app/web/admin.jsx | 6 +- .../admin/features/config/ConfigSection.jsx | 56 +++---- .../features/requests/RequestWorkspace.jsx | 114 ++++++++++++-- app/web/client.js | 105 +++++++++++-- app/web/landing.css | 144 +++++++++++++++--- app/web/landing.html | 13 +- app/web/landing.js | 123 ++++++++++++++- tests/admin/test_assignment_users.py | 28 ++++ tests/admin/test_lawyer_chat.py | 58 +++++++ tests/test_migrations.py | 9 +- tests/test_notifications.py | 10 ++ tests/test_public_cabinet.py | 44 ++++++ 22 files changed, 1152 insertions(+), 212 deletions(-) create mode 100644 alembic/versions/0033_add_message_delivery_read_receipts.py create mode 100644 app/services/request_assignment_events.py diff --git a/alembic/versions/0033_add_message_delivery_read_receipts.py b/alembic/versions/0033_add_message_delivery_read_receipts.py new file mode 100644 index 0000000..cdb50e2 --- /dev/null +++ b/alembic/versions/0033_add_message_delivery_read_receipts.py @@ -0,0 +1,77 @@ +"""add per-message delivery and read receipts for chat + +Revision ID: 0033_message_receipts +Revises: 0032_email_cols_fix +Create Date: 2026-03-03 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0033_message_receipts" +down_revision = "0032_email_cols_fix" +branch_labels = None +depends_on = None + + +def _has_column(inspector: sa.Inspector, table: str, column: str) -> bool: + return any(str(col.get("name")) == column for col in inspector.get_columns(table)) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not _has_column(inspector, "messages", "delivered_to_client_at"): + op.add_column("messages", sa.Column("delivered_to_client_at", sa.DateTime(timezone=True), nullable=True)) + if not _has_column(inspector, "messages", "delivered_to_staff_at"): + op.add_column("messages", sa.Column("delivered_to_staff_at", sa.DateTime(timezone=True), nullable=True)) + if not _has_column(inspector, "messages", "read_by_client_at"): + op.add_column("messages", sa.Column("read_by_client_at", sa.DateTime(timezone=True), nullable=True)) + if not _has_column(inspector, "messages", "read_by_staff_at"): + op.add_column("messages", sa.Column("read_by_staff_at", sa.DateTime(timezone=True), nullable=True)) + + # Historical messages are considered already delivered/read by their counterparty + # so old chats do not show endless "sent only" state. + op.execute( + sa.text( + """ + UPDATE messages + SET delivered_to_staff_at = COALESCE(delivered_to_staff_at, created_at, NOW()), + read_by_staff_at = COALESCE(read_by_staff_at, created_at, NOW()), + updated_at = COALESCE(updated_at, NOW()) + WHERE author_type = 'CLIENT' + """ + ) + ) + op.execute( + sa.text( + """ + UPDATE messages + SET delivered_to_client_at = COALESCE(delivered_to_client_at, created_at, NOW()), + read_by_client_at = COALESCE(read_by_client_at, created_at, NOW()), + updated_at = COALESCE(updated_at, NOW()) + WHERE author_type <> 'CLIENT' OR author_type IS NULL + """ + ) + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if _has_column(inspector, "messages", "read_by_staff_at"): + op.drop_column("messages", "read_by_staff_at") + inspector = sa.inspect(bind) + if _has_column(inspector, "messages", "read_by_client_at"): + op.drop_column("messages", "read_by_client_at") + inspector = sa.inspect(bind) + if _has_column(inspector, "messages", "delivered_to_staff_at"): + op.drop_column("messages", "delivered_to_staff_at") + inspector = sa.inspect(bind) + if _has_column(inspector, "messages", "delivered_to_client_at"): + op.drop_column("messages", "delivered_to_client_at") diff --git a/app/api/admin/chat.py b/app/api/admin/chat.py index 33ffc23..784e26b 100644 --- a/app/api/admin/chat.py +++ b/app/api/admin/chat.py @@ -22,6 +22,8 @@ from app.services.chat_secure_service import ( create_admin_or_lawyer_message, get_chat_activity_summary, list_messages_for_request, + mark_messages_delivered_for_staff, + mark_messages_read_for_staff, serialize_message, serialize_messages_for_request, ) @@ -253,6 +255,7 @@ def list_request_messages( ): req = _request_for_id_or_404(db, request_id) _ensure_lawyer_can_view_request_or_403(admin, req) + mark_messages_read_for_staff(db, request_id=req.id) rows = list_messages_for_request(db, req.id) payload = {"rows": serialize_messages_for_request(db, req.id, rows), "total": len(rows)} _audit_admin_chat_read( @@ -309,6 +312,7 @@ def get_request_live_state( ): req = _request_for_id_or_404(db, request_id) _ensure_lawyer_can_view_request_or_403(admin, req) + mark_messages_delivered_for_staff(db, request_id=req.id) summary = get_chat_activity_summary(db, req.id) latest_activity_at = _as_utc_datetime(summary.get("latest_activity_at")) latest_activity_iso = _iso_or_none(latest_activity_at) diff --git a/app/api/admin/crud_modules/service.py b/app/api/admin/crud_modules/service.py index 09e4f26..06e44df 100644 --- a/app/api/admin/crud_modules/service.py +++ b/app/api/admin/crud_modules/service.py @@ -18,19 +18,16 @@ from app.models.table_availability import TableAvailability from app.schemas.universal import UniversalQuery from app.services.billing_flow import apply_billing_transition_effects from app.services.notifications import ( - EVENT_ASSIGNMENT as NOTIFICATION_EVENT_ASSIGNMENT, EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE, - EVENT_REASSIGNMENT as NOTIFICATION_EVENT_REASSIGNMENT, EVENT_STATUS as NOTIFICATION_EVENT_STATUS, mark_admin_notifications_read, notify_request_event, ) +from app.services.request_assignment_events import apply_assignment_change from app.services.request_read_markers import ( - EVENT_ASSIGNMENT, EVENT_ATTACHMENT, EVENT_MESSAGE, - EVENT_REASSIGNMENT, EVENT_STATUS, clear_unread_for_lawyer, mark_unread_for_client, @@ -139,26 +136,6 @@ def _apply_create_side_effects(db: Session, *, table_name: str, row: Any, admin: ) return - if table_name == "requests" and isinstance(row, Request): - assigned = str(row.assigned_lawyer_id or "").strip() - if not assigned: - return - mark_unread_for_client(row, EVENT_ASSIGNMENT) - mark_unread_for_lawyer(row, EVENT_ASSIGNMENT) - responsible = _resolve_responsible(admin) - row.responsible = responsible - db.add(row) - notify_request_event( - db, - request=row, - event_type=NOTIFICATION_EVENT_ASSIGNMENT, - actor_role=_actor_role(admin), - actor_admin_user_id=admin.get("sub"), - body=f"Назначен юрист: {assigned}", - responsible=responsible, - ) - - def list_tables_meta_service(db: Session, admin: dict) -> dict[str, Any]: role = str(admin.get("role") or "").upper() if role != "ADMIN": @@ -412,6 +389,23 @@ def create_row_service(table_name: str, payload: dict[str, Any], db: Session, ad db.rollback() raise _integrity_error() + if normalized == "requests" and isinstance(row, Request): + assigned_lawyer_id = str(row.assigned_lawyer_id or "").strip() + if assigned_lawyer_id: + apply_assignment_change( + db, + request=row, + old_lawyer_id=None, + new_lawyer_id=assigned_lawyer_id, + actor_role=_actor_role(admin), + actor_admin_user_id=admin.get("sub"), + responsible=responsible, + actor_name=str(admin.get("email") or "").strip() or "Администратор системы", + ) + db.add(row) + db.commit() + db.refresh(row) + return _strip_hidden_fields(normalized, _row_to_dict(row)) @@ -556,34 +550,26 @@ def update_row_service(table_name: str, row_id: str, payload: dict[str, Any], db ), responsible=responsible, ) - assignment_event_type = None - assignment_marker_type = None - assignment_event_body = None + assignment_old_lawyer_id = "" + assignment_new_lawyer_id = "" if normalized == "requests" and not _is_lawyer(admin): after_assigned_candidate = clean_payload.get("assigned_lawyer_id", before_assigned_lawyer_id or None) after_assigned_lawyer_id = str(after_assigned_candidate or "").strip() if after_assigned_lawyer_id and after_assigned_lawyer_id != before_assigned_lawyer_id: - if before_assigned_lawyer_id: - assignment_event_type = NOTIFICATION_EVENT_REASSIGNMENT - assignment_marker_type = EVENT_REASSIGNMENT - assignment_event_body = f"Переназначено: {before_assigned_lawyer_id} -> {after_assigned_lawyer_id}" - else: - assignment_event_type = NOTIFICATION_EVENT_ASSIGNMENT - assignment_marker_type = EVENT_ASSIGNMENT - assignment_event_body = f"Назначен юрист: {after_assigned_lawyer_id}" + assignment_old_lawyer_id = before_assigned_lawyer_id + assignment_new_lawyer_id = after_assigned_lawyer_id for key, value in clean_payload.items(): setattr(row, key, value) - if assignment_event_type and assignment_marker_type and isinstance(row, Request): - mark_unread_for_client(row, assignment_marker_type) - mark_unread_for_lawyer(row, assignment_marker_type) - notify_request_event( + if assignment_new_lawyer_id and isinstance(row, Request): + apply_assignment_change( db, request=row, - event_type=assignment_event_type, + old_lawyer_id=assignment_old_lawyer_id, + new_lawyer_id=assignment_new_lawyer_id, actor_role=_actor_role(admin), actor_admin_user_id=admin.get("sub"), - body=assignment_event_body, responsible=responsible, + actor_name=str(admin.get("email") or "").strip() or "Администратор системы", ) try: diff --git a/app/api/admin/requests_modules/service.py b/app/api/admin/requests_modules/service.py index cd2d488..f9fd4b0 100644 --- a/app/api/admin/requests_modules/service.py +++ b/app/api/admin/requests_modules/service.py @@ -18,19 +18,15 @@ from app.schemas.admin import RequestAdminCreate, RequestAdminPatch from app.schemas.universal import UniversalQuery from app.services.billing_flow import apply_billing_transition_effects from app.services.notifications import ( - EVENT_ASSIGNMENT as NOTIFICATION_EVENT_ASSIGNMENT, - EVENT_REASSIGNMENT as NOTIFICATION_EVENT_REASSIGNMENT, EVENT_STATUS as NOTIFICATION_EVENT_STATUS, mark_admin_notifications_read, notify_request_event, ) +from app.services.request_assignment_events import apply_assignment_change from app.services.request_read_markers import ( - EVENT_ASSIGNMENT, - EVENT_REASSIGNMENT, EVENT_STATUS, clear_unread_for_lawyer, mark_unread_for_client, - mark_unread_for_lawyer, ) from app.services.request_deadline import initial_important_date_at from app.services.request_status import apply_status_change_effects @@ -212,6 +208,20 @@ def create_request_service(payload: RequestAdminCreate, db: Session, admin: dict except IntegrityError as exc: db.rollback() raise HTTPException(status_code=400, detail="Заявка с таким номером уже существует") from exc + if assigned_lawyer_id: + apply_assignment_change( + db, + request=row, + old_lawyer_id=None, + new_lawyer_id=assigned_lawyer_id, + actor_role=str(admin.get("role") or "").upper() or "ADMIN", + actor_admin_user_id=admin.get("sub"), + responsible=responsible, + actor_name=str(admin.get("email") or "").strip() or "Администратор системы", + ) + db.add(row) + db.commit() + db.refresh(row) return {"id": str(row.id), "track_number": row.track_number} @@ -310,22 +320,15 @@ def update_request_service(request_id: str, payload: RequestAdminPatch, db: Sess responsible=responsible, ) if actor_role == "ADMIN" and assigned_changed and new_assigned_lawyer_id: - assignment_event_type = NOTIFICATION_EVENT_REASSIGNMENT if old_assigned_lawyer_id else NOTIFICATION_EVENT_ASSIGNMENT - marker_event_type = EVENT_REASSIGNMENT if old_assigned_lawyer_id else EVENT_ASSIGNMENT - mark_unread_for_client(row, marker_event_type) - mark_unread_for_lawyer(row, marker_event_type) - notify_request_event( + apply_assignment_change( db, request=row, - event_type=assignment_event_type, + old_lawyer_id=old_assigned_lawyer_id, + new_lawyer_id=new_assigned_lawyer_id, actor_role="ADMIN", actor_admin_user_id=admin.get("sub"), - body=( - f"Назначен юрист: {new_assigned_lawyer_id}" - if not old_assigned_lawyer_id - else f"Переназначено: {old_assigned_lawyer_id} -> {new_assigned_lawyer_id}" - ), responsible=responsible, + actor_name=str(admin.get("email") or "").strip() or "Администратор системы", ) try: db.add(row) @@ -455,15 +458,15 @@ def claim_request_service(request_id: str, db: Session, admin: dict) -> dict[str if row is None: raise HTTPException(status_code=404, detail="Заявка не найдена") - mark_unread_for_client(row, EVENT_ASSIGNMENT) - notify_request_event( + apply_assignment_change( db, request=row, - event_type=NOTIFICATION_EVENT_ASSIGNMENT, + old_lawyer_id=None, + new_lawyer_id=str(lawyer_uuid), actor_role="LAWYER", actor_admin_user_id=str(lawyer_uuid), - body=f"Юрист {str(lawyer.email or lawyer.name or lawyer_uuid)} взял заявку в работу", responsible=responsible, + actor_name=str(lawyer.name or lawyer.email or lawyer_uuid), ) db.add(row) db.commit() @@ -543,16 +546,15 @@ def reassign_request_service(request_id: str, lawyer_id: str, db: Session, admin if row is None: raise HTTPException(status_code=404, detail="Заявка не найдена") - mark_unread_for_client(row, EVENT_REASSIGNMENT) - mark_unread_for_lawyer(row, EVENT_REASSIGNMENT) - notify_request_event( + apply_assignment_change( db, request=row, - event_type=NOTIFICATION_EVENT_REASSIGNMENT, + old_lawyer_id=old_assigned, + new_lawyer_id=str(lawyer_uuid), actor_role="ADMIN", actor_admin_user_id=admin.get("sub"), - body=f"Переназначено: {old_assigned} -> {str(lawyer_uuid)}", responsible=responsible, + actor_name=str(admin.get("email") or "").strip() or "Администратор системы", ) db.add(row) db.commit() diff --git a/app/api/public/chat.py b/app/api/public/chat.py index 10c0a62..5028590 100644 --- a/app/api/public/chat.py +++ b/app/api/public/chat.py @@ -18,6 +18,8 @@ from app.services.chat_secure_service import ( create_client_message, get_chat_activity_summary, list_messages_for_request, + mark_messages_delivered_for_client, + mark_messages_read_for_client, serialize_message, serialize_messages_for_request, ) @@ -165,6 +167,7 @@ def list_messages_by_track( ): req = _request_for_track_or_404(db, track_number) _ensure_view_access_or_403(session, req) + mark_messages_read_for_client(db, request_id=req.id) rows = list_messages_for_request(db, req.id) payload = serialize_messages_for_request(db, req.id, rows) _audit_public_chat_read( @@ -203,6 +206,7 @@ def get_live_chat_state_by_track( ): req = _request_for_track_or_404(db, track_number) _ensure_view_access_or_403(session, req) + mark_messages_delivered_for_client(db, request_id=req.id) summary = get_chat_activity_summary(db, req.id) latest_activity_at = _as_utc_datetime(summary.get("latest_activity_at")) latest_activity_iso = _iso_or_none(latest_activity_at) diff --git a/app/models/message.py b/app/models/message.py index 18ff153..a2a456d 100644 --- a/app/models/message.py +++ b/app/models/message.py @@ -1,5 +1,7 @@ import uuid -from sqlalchemy import String, Boolean +from datetime import datetime + +from sqlalchemy import String, Boolean, DateTime from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.dialects.postgresql import UUID from app.db.session import Base @@ -13,3 +15,7 @@ class Message(Base, UUIDMixin, TimestampMixin): author_name: Mapped[str | None] = mapped_column(String(200), nullable=True) body: Mapped[str | None] = mapped_column(EncryptedChatText(), nullable=True) immutable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + delivered_to_client_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + delivered_to_staff_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + read_by_client_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + read_by_staff_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/app/services/chat_secure_service.py b/app/services/chat_secure_service.py index 7b01254..ab0f67f 100644 --- a/app/services/chat_secure_service.py +++ b/app/services/chat_secure_service.py @@ -1,6 +1,7 @@ from __future__ import annotations import uuid +from datetime import datetime, timezone from typing import Any from fastapi import HTTPException @@ -36,6 +37,87 @@ def list_messages_for_request(db: Session, request_id: Any) -> list[Message]: ) +def _iso_or_none(value: datetime | None) -> str | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc).isoformat() + return value.astimezone(timezone.utc).isoformat() + + +def _mark_counterparty_delivery( + db: Session, + *, + request_id: Any, + recipient: str, + mark_read: bool, +) -> bool: + side = str(recipient or "").strip().upper() + if side not in {"CLIENT", "STAFF"}: + return False + + now = datetime.now(timezone.utc) + changed = False + if side == "CLIENT": + sender_filter = Message.author_type != "CLIENT" + delivered_column = Message.delivered_to_client_at + read_column = Message.read_by_client_at + else: + sender_filter = Message.author_type == "CLIENT" + delivered_column = Message.delivered_to_staff_at + read_column = Message.read_by_staff_at + + delivered_count = ( + db.query(Message) + .filter(Message.request_id == request_id, sender_filter, delivered_column.is_(None)) + .update( + { + delivered_column: now, + Message.updated_at: now, + }, + synchronize_session=False, + ) + ) + if delivered_count: + changed = True + + if mark_read: + read_count = ( + db.query(Message) + .filter(Message.request_id == request_id, sender_filter, read_column.is_(None)) + .update( + { + read_column: now, + delivered_column: func.coalesce(delivered_column, now), + Message.updated_at: now, + }, + synchronize_session=False, + ) + ) + if read_count: + changed = True + + if changed: + db.commit() + return changed + + +def mark_messages_delivered_for_client(db: Session, *, request_id: Any) -> bool: + return _mark_counterparty_delivery(db, request_id=request_id, recipient="CLIENT", mark_read=False) + + +def mark_messages_read_for_client(db: Session, *, request_id: Any) -> bool: + return _mark_counterparty_delivery(db, request_id=request_id, recipient="CLIENT", mark_read=True) + + +def mark_messages_delivered_for_staff(db: Session, *, request_id: Any) -> bool: + return _mark_counterparty_delivery(db, request_id=request_id, recipient="STAFF", mark_read=False) + + +def mark_messages_read_for_staff(db: Session, *, request_id: Any) -> bool: + return _mark_counterparty_delivery(db, request_id=request_id, recipient="STAFF", mark_read=True) + + def serialize_message(row: Message) -> dict[str, Any]: return { "id": str(row.id), @@ -46,8 +128,12 @@ def serialize_message(row: Message) -> dict[str, Any]: "message_kind": "TEXT", "request_data_items": [], "request_data_all_filled": False, - "created_at": row.created_at.isoformat() if row.created_at else None, - "updated_at": row.updated_at.isoformat() if row.updated_at else None, + "created_at": _iso_or_none(row.created_at), + "updated_at": _iso_or_none(row.updated_at), + "delivered_to_client_at": _iso_or_none(row.delivered_to_client_at), + "delivered_to_staff_at": _iso_or_none(row.delivered_to_staff_at), + "read_by_client_at": _iso_or_none(row.read_by_client_at), + "read_by_staff_at": _iso_or_none(row.read_by_staff_at), } diff --git a/app/services/request_assignment_events.py b/app/services/request_assignment_events.py new file mode 100644 index 0000000..45797e8 --- /dev/null +++ b/app/services/request_assignment_events.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from sqlalchemy import inspect +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session + +from app.models.admin_user import AdminUser +from app.models.message import Message +from app.models.request import Request +from app.services.notifications import ( + EVENT_ASSIGNMENT as NOTIFICATION_EVENT_ASSIGNMENT, + EVENT_REASSIGNMENT as NOTIFICATION_EVENT_REASSIGNMENT, + notify_request_event, +) +from app.services.request_read_markers import ( + EVENT_ASSIGNMENT, + EVENT_REASSIGNMENT, + mark_unread_for_client, + mark_unread_for_lawyer, +) + + +def _normalize_uuid_text(value: Any) -> str: + raw = str(value or "").strip() + if not raw: + return "" + try: + return str(UUID(raw)) + except ValueError: + return raw + + +def _lawyer_label(db: Session, lawyer_id: str) -> str: + normalized_id = _normalize_uuid_text(lawyer_id) + if not normalized_id: + return "Не назначен" + try: + lawyer_uuid = UUID(normalized_id) + except ValueError: + return normalized_id + row = db.get(AdminUser, lawyer_uuid) + if row is None: + return normalized_id + return str(row.name or row.email or normalized_id).strip() or normalized_id + + +def _service_message_author(actor_role: str, actor_name: str | None) -> str: + normalized_role = str(actor_role or "").strip().upper() + explicit = str(actor_name or "").strip() + if explicit: + return explicit + if normalized_role in {"LAWYER", "CURATOR"}: + return "Юрист" + if normalized_role == "CLIENT": + return "Клиент" + return "Администратор системы" + + +def _can_write_messages(db: Session) -> bool: + try: + bind = db.get_bind() + if bind is None: + return False + return bool(inspect(bind).has_table(Message.__tablename__)) + except (SQLAlchemyError, ValueError, TypeError): + return False + + +def apply_assignment_change( + db: Session, + *, + request: Request, + old_lawyer_id: Any, + new_lawyer_id: Any, + actor_role: str, + actor_admin_user_id: str | None = None, + responsible: str = "Администратор системы", + actor_name: str | None = None, +) -> dict[str, str] | None: + old_id = _normalize_uuid_text(old_lawyer_id) + new_id = _normalize_uuid_text(new_lawyer_id) + if not new_id or old_id == new_id: + return None + + old_label = _lawyer_label(db, old_id) if old_id else "Не назначен" + new_label = _lawyer_label(db, new_id) + + if old_id: + notification_event = NOTIFICATION_EVENT_REASSIGNMENT + marker_event = EVENT_REASSIGNMENT + notification_body = f"Переназначено: {old_label} -> {new_label}" + chat_body = ( + f"Переназначено: {old_label} -> {new_label}\n" + f"Предыдущий юрист: {old_label}\n" + f"Новый юрист: {new_label}" + ) + else: + notification_event = NOTIFICATION_EVENT_ASSIGNMENT + marker_event = EVENT_ASSIGNMENT + notification_body = f"Назначен юрист: {new_label}" + chat_body = f"Назначен юрист: {new_label}\nЮрист: {new_label}" + + safe_responsible = str(responsible or "").strip() or "Администратор системы" + normalized_actor_role = str(actor_role or "").strip().upper() or "ADMIN" + + mark_unread_for_client(request, marker_event) + mark_unread_for_lawyer(request, marker_event) + request.responsible = safe_responsible + + notify_request_event( + db, + request=request, + event_type=notification_event, + actor_role=normalized_actor_role, + actor_admin_user_id=actor_admin_user_id, + body=notification_body, + responsible=safe_responsible, + ) + + if _can_write_messages(db): + db.add( + Message( + request_id=request.id, + author_type="SYSTEM", + author_name=_service_message_author(normalized_actor_role, actor_name), + body=chat_body, + immutable=True, + responsible=safe_responsible, + ) + ) + db.add(request) + return { + "notification_event": notification_event, + "marker_event": marker_event, + "notification_body": notification_body, + "chat_body": chat_body, + } diff --git a/app/web/admin.css b/app/web/admin.css index aeeff8a..fd963cf 100644 --- a/app/web/admin.css +++ b/app/web/admin.css @@ -237,6 +237,10 @@ font-weight: 700; font-size: 0.88rem; cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.36rem; } .btn:hover { filter: brightness(1.05); } @@ -1170,13 +1174,25 @@ cursor: pointer; width: 30px; height: 30px; - display: inline-grid; - place-items: center; + display: inline-flex; + align-items: center; + justify-content: center; font-size: 0.95rem; line-height: 1; position: relative; } + .btn svg, + .btn .ui-glyph, + .icon-btn svg, + .icon-btn .ui-glyph, + .table-control-btn svg, + .table-control-btn .ui-glyph { + display: block; + flex-shrink: 0; + margin: 0; + } + .icon-btn:hover { border-color: rgba(212, 168, 106, 0.42); background: rgba(212, 168, 106, 0.16); @@ -1262,8 +1278,9 @@ height: 40px; min-height: 40px; padding: 0; - display: inline-grid; - place-items: center; + display: inline-flex; + align-items: center; + justify-content: center; line-height: 1; } @@ -1328,16 +1345,6 @@ background: transparent; } - .config-floating-actions { - position: fixed; - right: 1.15rem; - top: 164px; - z-index: 35; - display: inline-flex; - align-items: center; - gap: 0.45rem; - } - .config-panel-flat .config-content .table-wrap table { min-width: 640px; } @@ -2086,7 +2093,7 @@ top: calc(100% + 0.3rem); z-index: 80; border: 1px solid rgba(95, 124, 163, 0.55); - background: linear-gradient(180deg, rgba(18, 28, 41, 0.98), rgba(13, 20, 31, 0.98)); + background: #0f1724; border-radius: 12px; box-shadow: 0 14px 30px rgba(0, 0, 0, 0.32); max-height: 240px; @@ -2099,7 +2106,7 @@ .request-data-suggest-item { width: 100%; border: 1px solid transparent; - background: rgba(255, 255, 255, 0.02); + background: #142030; color: #e7effc; border-radius: 10px; padding: 0.4rem 0.5rem; @@ -2117,7 +2124,7 @@ .request-data-suggest-item:hover { border-color: rgba(123, 159, 205, 0.45); - background: rgba(75, 111, 163, 0.18); + background: #1a2a42; } .request-data-rows { @@ -2664,6 +2671,50 @@ text-align: right; } + .chat-message-meta { + margin-top: 0.32rem; + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 0.34rem; + width: 100%; + } + + .chat-message-meta .chat-message-time { + margin-top: 0; + } + + .chat-message-status { + position: relative; + width: 1.02rem; + height: 0.72rem; + color: rgba(225, 235, 249, 0.78); + flex-shrink: 0; + } + + .chat-message-status.delivered { + color: rgba(225, 235, 249, 0.92); + } + + .chat-message-status.read { + color: #7ac5ff; + } + + .chat-message-status-check { + position: absolute; + top: -0.05rem; + left: 0; + font-size: 0.72rem; + line-height: 1; + font-weight: 700; + font-family: "Segoe UI Symbol", "Noto Sans Symbols", "Arial", sans-serif; + user-select: none; + } + + .chat-message-status-check.second { + left: 0.32rem; + } + .chat-request-data-bubble { cursor: pointer; border-color: rgba(221, 168, 87, 0.42); @@ -3229,12 +3280,6 @@ width: 100%; margin-left: 0; } - .config-floating-actions { - position: static; - justify-content: flex-end; - width: 100%; - margin-bottom: 0.5rem; - } .topbar { flex-direction: column; align-items: flex-start; diff --git a/app/web/admin.js b/app/web/admin.js index 0cd383d..598495e 100644 --- a/app/web/admin.js +++ b/app/web/admin.js @@ -2857,7 +2857,7 @@ const canLoadNext = Boolean( configActiveKey && !activeConfigTableState.showAll && activeConfigTableState.offset + PAGE_SIZE < activeConfigTableState.total ); - return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u0421\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A\u0438"), /* @__PURE__ */ React.createElement("p", { className: "breadcrumbs" }, configActiveKey ? getTableLabel(configActiveKey) : "\u0421\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D"), configActiveKey === "otp_sessions" ? /* @__PURE__ */ React.createElement("p", { className: "muted" }, smsBalanceSummary(smsProviderHealth), (smsProviderHealth == null ? void 0 : smsProviderHealth.loaded_at) ? " \u2022 \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u043E " + fmtDate(smsProviderHealth.loaded_at) : "") : null), /* @__PURE__ */ React.createElement("div", { className: "config-head-actions" }, configActiveKey === "otp_sessions" ? /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: onRefreshSmsProviderHealth }, "\u0411\u0430\u043B\u0430\u043D\u0441") : null)), /* @__PURE__ */ React.createElement("div", { className: "config-layout" }, /* @__PURE__ */ React.createElement("div", { className: "config-panel config-panel-flat" }, /* @__PURE__ */ React.createElement("div", { className: "config-content" }, /* @__PURE__ */ React.createElement("div", { className: "config-floating-actions" }, /* @__PURE__ */ React.createElement( + return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u0421\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A\u0438"), /* @__PURE__ */ React.createElement("p", { className: "breadcrumbs" }, configActiveKey ? getTableLabel(configActiveKey) : "\u0421\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D"), configActiveKey === "otp_sessions" ? /* @__PURE__ */ React.createElement("p", { className: "muted" }, smsBalanceSummary(smsProviderHealth), (smsProviderHealth == null ? void 0 : smsProviderHealth.loaded_at) ? " \u2022 \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u043E " + fmtDate(smsProviderHealth.loaded_at) : "") : null), /* @__PURE__ */ React.createElement("div", { className: "config-head-actions" }, /* @__PURE__ */ React.createElement( "button", { className: "btn secondary table-control-btn", @@ -2879,7 +2879,7 @@ "aria-label": "\u0424\u0438\u043B\u044C\u0442\u0440" }, /* @__PURE__ */ React.createElement(FilterIcon, null) - )), /* @__PURE__ */ React.createElement( + ), configActiveKey === "otp_sessions" ? /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: onRefreshSmsProviderHealth }, "\u0411\u0430\u043B\u0430\u043D\u0441") : null)), /* @__PURE__ */ React.createElement("div", { className: "config-layout" }, /* @__PURE__ */ React.createElement("div", { className: "config-panel config-panel-flat" }, /* @__PURE__ */ React.createElement("div", { className: "config-content" }, /* @__PURE__ */ React.createElement( FilterToolbar, { filters: activeConfigTableState.filters, @@ -2896,19 +2896,18 @@ DataTable, { headers: [ - { key: "code", label: "\u041A\u043E\u0434", sortable: true, field: "code" }, { key: "name", label: "\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435", sortable: true, field: "name" }, { key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u043D\u0430", sortable: true, field: "enabled" }, { key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", sortable: true, field: "sort_order" }, { key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" } ], rows: tables.topics.rows, - emptyColspan: 5, + emptyColspan: 4, onSort: (field) => toggleTableSort("topics", field), sortClause: tables.topics.sort && tables.topics.sort[0] || TABLE_SERVER_CONFIG.topics.sort[0], renderRow: (row) => { var _a2; - return /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("code", null, row.code || "-")), /* @__PURE__ */ React.createElement("td", null, row.name || "-"), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.enabled)), /* @__PURE__ */ React.createElement("td", null, String((_a2 = row.sort_order) != null ? _a2 : 0)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0442\u0435\u043C\u0443", onClick: () => openEditRecordModal("topics", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0442\u0435\u043C\u0443", onClick: () => deleteRecord("topics", row.id), tone: "danger" })))); + return /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, row.name || "-"), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.enabled)), /* @__PURE__ */ React.createElement("td", null, String((_a2 = row.sort_order) != null ? _a2 : 0)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0442\u0435\u043C\u0443", onClick: () => openEditRecordModal("topics", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0442\u0435\u043C\u0443", onClick: () => deleteRecord("topics", row.id), tone: "danger" })))); } } ) : null, configActiveKey === "quotes" ? /* @__PURE__ */ React.createElement( @@ -2936,7 +2935,6 @@ DataTable, { headers: [ - { key: "code", label: "\u041A\u043E\u0434", sortable: true, field: "code" }, { key: "name", label: "\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435", sortable: true, field: "name" }, { key: "status_group_id", label: "\u0413\u0440\u0443\u043F\u043F\u0430", sortable: true, field: "status_group_id" }, { key: "kind", label: "\u0422\u0438\u043F", sortable: true, field: "kind" }, @@ -2947,12 +2945,12 @@ { key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" } ], rows: tables.statuses.rows, - emptyColspan: 9, + emptyColspan: 8, onSort: (field) => toggleTableSort("statuses", field), sortClause: tables.statuses.sort && tables.statuses.sort[0] || TABLE_SERVER_CONFIG.statuses.sort[0], renderRow: (row) => { var _a2; - return /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("code", null, row.code || "-")), /* @__PURE__ */ React.createElement("td", null, row.name || "-"), /* @__PURE__ */ React.createElement("td", null, resolveReferenceLabel({ table: "status_groups", value_field: "id", label_field: "name" }, row.status_group_id)), /* @__PURE__ */ React.createElement("td", null, statusKindLabel(row.kind)), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.enabled)), /* @__PURE__ */ React.createElement("td", null, String((_a2 = row.sort_order) != null ? _a2 : 0)), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.is_terminal)), /* @__PURE__ */ React.createElement("td", null, row.invoice_template || "-"), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0441\u0442\u0430\u0442\u0443\u0441", onClick: () => openEditRecordModal("statuses", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0441\u0442\u0430\u0442\u0443\u0441", onClick: () => deleteRecord("statuses", row.id), tone: "danger" })))); + return /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, row.name || "-"), /* @__PURE__ */ React.createElement("td", null, resolveReferenceLabel({ table: "status_groups", value_field: "id", label_field: "name" }, row.status_group_id)), /* @__PURE__ */ React.createElement("td", null, statusKindLabel(row.kind)), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.enabled)), /* @__PURE__ */ React.createElement("td", null, String((_a2 = row.sort_order) != null ? _a2 : 0)), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.is_terminal)), /* @__PURE__ */ React.createElement("td", null, row.invoice_template || "-"), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0441\u0442\u0430\u0442\u0443\u0441", onClick: () => openEditRecordModal("statuses", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0441\u0442\u0430\u0442\u0443\u0441", onClick: () => deleteRecord("statuses", row.id), tone: "danger" })))); } } ) : null, configActiveKey === "formFields" ? /* @__PURE__ */ React.createElement( @@ -3140,7 +3138,7 @@ emptyColspan: Math.max(1, genericConfigHeaders.length), onSort: (field) => toggleTableSort(configActiveKey, field), sortClause: activeConfigTableState.sort && activeConfigTableState.sort[0] || (((_a = resolveTableConfig(configActiveKey)) == null ? void 0 : _a.sort) || [])[0], - renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id || JSON.stringify(row) }, ((activeConfigMeta == null ? void 0 : activeConfigMeta.columns) || []).map((column) => { + renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id || JSON.stringify(row) }, ((activeConfigMeta == null ? void 0 : activeConfigMeta.columns) || []).filter((column) => String((column == null ? void 0 : column.name) || "") !== "id").map((column) => { const key = String(column.name || ""); const value = row[key]; if (column.kind === "boolean") return /* @__PURE__ */ React.createElement("td", { key }, boolLabel(Boolean(value))); @@ -4872,6 +4870,27 @@ }, [routeNodes]); const AttachmentPreviewModal = AttachmentPreviewModalComponent; const StatusLine = StatusLineComponent; + const resolveMessageReceiptState = (payload) => { + const authorType = String((payload == null ? void 0 : payload.author_type) || "").trim().toUpperCase(); + const isClientAuthor = authorType === "CLIENT"; + const deliveredAt = isClientAuthor ? payload == null ? void 0 : payload.delivered_to_staff_at : payload == null ? void 0 : payload.delivered_to_client_at; + const readAt = isClientAuthor ? payload == null ? void 0 : payload.read_by_staff_at : payload == null ? void 0 : payload.read_by_client_at; + if (readAt) return { state: "read", label: "\u041F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043E" }; + if (deliveredAt) return { state: "delivered", label: "\u0414\u043E\u0441\u0442\u0430\u0432\u043B\u0435\u043D\u043E" }; + return { state: "sent", label: "\u041E\u0442\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u043E" }; + }; + const isOutgoingForViewer = (payload) => { + const authorType = String((payload == null ? void 0 : payload.author_type) || "").trim().toUpperCase(); + if (!authorType) return false; + if (viewerRoleCode === "CLIENT") return authorType === "CLIENT"; + return authorType !== "CLIENT"; + }; + const renderMessageMeta = (payload) => { + const timeLabel = fmtTimeOnly(payload == null ? void 0 : payload.created_at); + if (!isOutgoingForViewer(payload)) return /* @__PURE__ */ React.createElement("div", { className: "chat-message-time" }, timeLabel); + const receipt = resolveMessageReceiptState(payload); + return /* @__PURE__ */ React.createElement("div", { className: "chat-message-meta" }, /* @__PURE__ */ React.createElement("div", { className: "chat-message-time" }, timeLabel), /* @__PURE__ */ React.createElement("span", { className: "chat-message-status " + receipt.state, title: receipt.label, "aria-label": receipt.label }, /* @__PURE__ */ React.createElement("span", { className: "chat-message-status-check first", "aria-hidden": "true" }, "\u2713"), receipt.state !== "sent" ? /* @__PURE__ */ React.createElement("span", { className: "chat-message-status-check second", "aria-hidden": "true" }, "\u2713") : null)); + }; const renderRequestDataMessageItems = (payload) => { var _a2; const items = Array.isArray(payload == null ? void 0 : payload.request_data_items) ? payload.request_data_items : []; @@ -4909,11 +4928,40 @@ text: withTail(detail) }; } - if (firstLine.startsWith("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D \u044E\u0440\u0438\u0441\u0442:") || firstLine.startsWith("\u041F\u0435\u0440\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043E:")) { - const detail = firstLine.startsWith("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D \u044E\u0440\u0438\u0441\u0442:") ? firstLine.slice("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D \u044E\u0440\u0438\u0441\u0442:".length) : firstLine.slice("\u041F\u0435\u0440\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043E:".length); + if (firstLine.startsWith("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D \u044E\u0440\u0438\u0441\u0442:")) { return { title: "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D \u044E\u0440\u0438\u0441\u0442", - text: withTail(detail) + text: withTail(firstLine.slice("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D \u044E\u0440\u0438\u0441\u0442:".length)) + }; + } + if (firstLine.startsWith("\u041F\u0435\u0440\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043E:")) { + return { + title: "\u0421\u043C\u0435\u043D\u0430 \u044E\u0440\u0438\u0441\u0442\u0430", + text: withTail(firstLine.slice("\u041F\u0435\u0440\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043E:".length)) + }; + } + if (firstLine.startsWith("\u0421\u043C\u0435\u043D\u0430 \u044E\u0440\u0438\u0441\u0442\u0430:")) { + return { + title: "\u0421\u043C\u0435\u043D\u0430 \u044E\u0440\u0438\u0441\u0442\u0430", + text: withTail(firstLine.slice("\u0421\u043C\u0435\u043D\u0430 \u044E\u0440\u0438\u0441\u0442\u0430:".length)) + }; + } + if (firstLine.startsWith("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435 \u044E\u0440\u0438\u0441\u0442\u0430:")) { + return { + title: "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D \u044E\u0440\u0438\u0441\u0442", + text: withTail(firstLine.slice("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435 \u044E\u0440\u0438\u0441\u0442\u0430:".length)) + }; + } + if (firstLine.startsWith("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435:")) { + return { + title: "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D \u044E\u0440\u0438\u0441\u0442", + text: withTail(firstLine.slice("\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435:".length)) + }; + } + if (firstLine.startsWith("\u041F\u0435\u0440\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435:")) { + return { + title: "\u0421\u043C\u0435\u043D\u0430 \u044E\u0440\u0438\u0441\u0442\u0430", + text: withTail(firstLine.slice("\u041F\u0435\u0440\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435:".length)) }; } return null; @@ -5085,7 +5133,7 @@ /* @__PURE__ */ React.createElement("span", { className: "chat-message-file-name" }, String(((_d = entry.payload) == null ? void 0 : _d.file_name) || "\u0424\u0430\u0439\u043B")) )), /* @__PURE__ */ React.createElement("div", { className: "chat-message-time" }, fmtTimeOnly((_e = entry.payload) == null ? void 0 : _e.created_at))) ) : (() => { - var _a3, _b3, _c3, _d2, _e2, _f, _g, _h, _i; + var _a3, _b3, _c3, _d2, _e2, _f, _g, _h; const messageKind = String(((_a3 = entry.payload) == null ? void 0 : _a3.message_kind) || ""); const isRequestDataMessage = messageKind === "REQUEST_DATA"; const serviceMessageContent = resolveServiceMessageContent(entry.payload); @@ -5132,7 +5180,7 @@ /* @__PURE__ */ React.createElement("span", { className: "chat-message-file-name" }, String(file.file_name || "\u0424\u0430\u0439\u043B")) ))); })(), - /* @__PURE__ */ React.createElement("div", { className: "chat-message-time" }, fmtTimeOnly((_i = entry.payload) == null ? void 0 : _i.created_at)) + renderMessageMeta(entry.payload) )); })(); } @@ -5408,7 +5456,7 @@ const statusCode = String((item == null ? void 0 : item.to_status) || ""); const statusMeta = statusByCode.get(statusCode); const itemClass = "route-item request-status-history-route-item " + (index === 0 ? "current" : "completed"); - return /* @__PURE__ */ React.createElement("li", { key: String((item == null ? void 0 : item.id) || index), className: itemClass }, /* @__PURE__ */ React.createElement("span", { className: "route-dot" }), /* @__PURE__ */ React.createElement("div", { className: "route-body" }, /* @__PURE__ */ React.createElement("div", { className: "request-status-history-row" }, /* @__PURE__ */ React.createElement("b", null, resolveStatusDisplayName(statusCode, (item == null ? void 0 : item.to_status_name) || (statusMeta == null ? void 0 : statusMeta.name) || "")), (statusMeta == null ? void 0 : statusMeta.isTerminal) ? /* @__PURE__ */ React.createElement("span", { className: "request-status-history-chip" }, "\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u044C\u043D\u044B\u0439") : null), /* @__PURE__ */ React.createElement("div", { className: "muted route-time" }, fmtShortDateTime(item == null ? void 0 : item.changed_at)), /* @__PURE__ */ React.createElement("div", { className: "request-status-history-meta" }, /* @__PURE__ */ React.createElement("span", null, "\u0412\u0430\u0436\u043D\u0430\u044F \u0434\u0430\u0442\u0430: " + fmtShortDateTime(item == null ? void 0 : item.important_date_at)), /* @__PURE__ */ React.createElement("span", null, "\u0414\u043B\u0438\u0442\u0435\u043B\u044C\u043D\u043E\u0441\u0442\u044C: " + formatDuration(item == null ? void 0 : item.duration_seconds))), (item == null ? void 0 : item.from_status) ? /* @__PURE__ */ React.createElement("div", { className: "request-status-history-meta" }, /* @__PURE__ */ React.createElement("span", null, "\u0418\u0437: " + resolveStatusDisplayName(item.from_status, ""))) : null, String((item == null ? void 0 : item.comment) || "").trim() ? /* @__PURE__ */ React.createElement("div", { className: "request-status-history-comment" }, String(item.comment)) : null)); + return /* @__PURE__ */ React.createElement("li", { key: String((item == null ? void 0 : item.id) || index), className: itemClass }, /* @__PURE__ */ React.createElement("span", { className: "route-dot" }), /* @__PURE__ */ React.createElement("div", { className: "route-body" }, /* @__PURE__ */ React.createElement("div", { className: "request-status-history-row" }, /* @__PURE__ */ React.createElement("b", null, resolveStatusDisplayName(statusCode, (item == null ? void 0 : item.to_status_name) || (statusMeta == null ? void 0 : statusMeta.name) || "")), (statusMeta == null ? void 0 : statusMeta.isTerminal) ? /* @__PURE__ */ React.createElement("span", { className: "request-status-history-chip" }, "\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u044C\u043D\u044B\u0439") : null), /* @__PURE__ */ React.createElement("div", { className: "muted route-time" }, fmtShortDateTime(item == null ? void 0 : item.changed_at)), /* @__PURE__ */ React.createElement("div", { className: "request-status-history-meta" }, /* @__PURE__ */ React.createElement("span", null, "\u0412\u0430\u0436\u043D\u0430\u044F \u0434\u0430\u0442\u0430: " + fmtShortDateTime(item == null ? void 0 : item.important_date_at)), /* @__PURE__ */ React.createElement("span", null, "\u0414\u043B\u0438\u0442\u0435\u043B\u044C\u043D\u043E\u0441\u0442\u044C: " + formatDuration(item == null ? void 0 : item.duration_seconds))), String((item == null ? void 0 : item.comment) || "").trim() ? /* @__PURE__ */ React.createElement("div", { className: "request-status-history-comment" }, String(item.comment)) : null)); }) : /* @__PURE__ */ React.createElement("li", { className: "muted" }, "\u0418\u0441\u0442\u043E\u0440\u0438\u044F \u0438\u0437\u043C\u0435\u043D\u0435\u043D\u0438\u0439 \u0441\u0442\u0430\u0442\u0443\u0441\u043E\u0432 \u043F\u043E\u043A\u0430 \u043F\u0443\u0441\u0442\u0430\u044F"))), statusChangeModal.error ? /* @__PURE__ */ React.createElement("div", { className: "status error" }, statusChangeModal.error) : null, /* @__PURE__ */ React.createElement("div", { className: "modal-actions modal-actions-right" }, /* @__PURE__ */ React.createElement( "button", { @@ -5511,6 +5559,8 @@ "input", { id: "request-data-request-template-select", + name: "request_template_search_nohistory", + type: "text", value: dataRequestModal.requestTemplateQuery, onChange: (event) => setDataRequestModal((prev) => ({ ...prev, @@ -5519,14 +5569,24 @@ templateStatus: "", error: "" })), - onFocus: () => setRequestTemplateSuggestOpen(true), - onBlur: () => window.setTimeout(() => setRequestTemplateSuggestOpen(false), 120), + onFocus: (event) => { + event.currentTarget.removeAttribute("readonly"); + setRequestTemplateSuggestOpen(true); + }, + onBlur: (event) => { + event.currentTarget.setAttribute("readonly", "readonly"); + window.setTimeout(() => setRequestTemplateSuggestOpen(false), 120); + }, disabled: dataRequestModal.loading || dataRequestModal.saving || dataRequestModal.savingTemplate, placeholder: "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435 \u0448\u0430\u0431\u043B\u043E\u043D\u0430", + readOnly: true, autoComplete: "new-password", autoCorrect: "off", autoCapitalize: "none", - spellCheck: false + spellCheck: false, + "aria-autocomplete": "list", + "data-1p-ignore": "true", + "data-lpignore": "true" } ), requestTemplateBadge ? /* @__PURE__ */ React.createElement("span", { className: "request-data-template-badge " + requestTemplateBadge.kind }, requestTemplateBadge.label) : null, requestTemplateSuggestOpen && filteredRequestTemplates.length ? /* @__PURE__ */ React.createElement("div", { className: "request-data-suggest-list", role: "listbox", "aria-label": "\u0428\u0430\u0431\u043B\u043E\u043D\u044B \u0437\u0430\u043F\u0440\u043E\u0441\u0430" }, filteredRequestTemplates.map((tpl) => /* @__PURE__ */ React.createElement( "button", @@ -5562,6 +5622,8 @@ "input", { id: "request-data-template-select", + name: "request_field_search_nohistory", + type: "text", value: dataRequestModal.catalogFieldQuery, onChange: (event) => setDataRequestModal((prev) => ({ ...prev, @@ -5570,14 +5632,24 @@ templateStatus: "", error: "" })), - onFocus: () => setCatalogFieldSuggestOpen(true), - onBlur: () => window.setTimeout(() => setCatalogFieldSuggestOpen(false), 120), + onFocus: (event) => { + event.currentTarget.removeAttribute("readonly"); + setCatalogFieldSuggestOpen(true); + }, + onBlur: (event) => { + event.currentTarget.setAttribute("readonly", "readonly"); + window.setTimeout(() => setCatalogFieldSuggestOpen(false), 120); + }, disabled: dataRequestModal.loading || dataRequestModal.saving || dataRequestModal.savingTemplate, placeholder: "\u041D\u0430\u0447\u043D\u0438\u0442\u0435 \u0432\u0432\u043E\u0434\u0438\u0442\u044C \u043D\u0430\u0438\u043C\u0435\u043D\u043E\u0432\u0430\u043D\u0438\u0435 \u043F\u043E\u043B\u044F", + readOnly: true, autoComplete: "new-password", autoCorrect: "off", autoCapitalize: "none", - spellCheck: false + spellCheck: false, + "aria-autocomplete": "list", + "data-1p-ignore": "true", + "data-lpignore": "true" } ), catalogFieldSuggestOpen && filteredCatalogFields.length ? /* @__PURE__ */ React.createElement("div", { className: "request-data-suggest-list", role: "listbox", "aria-label": "\u041F\u043E\u043B\u044F \u0434\u0430\u043D\u043D\u044B\u0445" }, filteredCatalogFields.map((tpl) => /* @__PURE__ */ React.createElement( "button", @@ -7828,7 +7900,6 @@ } if (tableKey === "topics") { return [ - { field: "code", label: "\u041A\u043E\u0434", type: "text" }, { field: "name", label: "\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435", type: "text" }, { field: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u043D\u0430", type: "boolean" }, { field: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number" } @@ -7836,7 +7907,6 @@ } if (tableKey === "statuses") { return [ - { field: "code", label: "\u041A\u043E\u0434", type: "text" }, { field: "name", label: "\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435", type: "text" }, { field: "status_group_id", label: "\u0413\u0440\u0443\u043F\u043F\u0430", type: "reference", options: getStatusGroupOptions }, { field: "kind", label: "\u0422\u0438\u043F", type: "enum", options: getStatusKindOptions }, @@ -7911,7 +7981,7 @@ } const meta = tableCatalogMap[tableKey]; if (!meta || !Array.isArray(meta.columns)) return []; - return (meta.columns || []).filter((column) => column && column.name && column.filterable !== false).map((column) => { + return (meta.columns || []).filter((column) => column && column.name && column.filterable !== false && String(column.name) !== "id").map((column) => { const name = String(column.name); const label = String(column.label || humanizeKey(name)); if (name === "topic_code") return { field: name, label, type: "reference", options: getTopicOptions }; @@ -9751,7 +9821,7 @@ const canDeleteInConfig = activeConfigActions.includes("delete"); const genericConfigHeaders = useMemo(() => { if (!activeConfigMeta || !Array.isArray(activeConfigMeta.columns)) return []; - const headers = (activeConfigMeta.columns || []).filter((column) => column && column.name).map((column) => { + const headers = (activeConfigMeta.columns || []).filter((column) => column && column.name && String(column.name) !== "id").map((column) => { const name = String(column.name); return { key: name, diff --git a/app/web/admin.jsx b/app/web/admin.jsx index f70e0cb..d301859 100644 --- a/app/web/admin.jsx +++ b/app/web/admin.jsx @@ -1588,7 +1588,6 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; } if (tableKey === "topics") { return [ - { field: "code", label: "Код", type: "text" }, { field: "name", label: "Название", type: "text" }, { field: "enabled", label: "Активна", type: "boolean" }, { field: "sort_order", label: "Порядок", type: "number" }, @@ -1596,7 +1595,6 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; } if (tableKey === "statuses") { return [ - { field: "code", label: "Код", type: "text" }, { field: "name", label: "Название", type: "text" }, { field: "status_group_id", label: "Группа", type: "reference", options: getStatusGroupOptions }, { field: "kind", label: "Тип", type: "enum", options: getStatusKindOptions }, @@ -1672,7 +1670,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; const meta = tableCatalogMap[tableKey]; if (!meta || !Array.isArray(meta.columns)) return []; return (meta.columns || []) - .filter((column) => column && column.name && column.filterable !== false) + .filter((column) => column && column.name && column.filterable !== false && String(column.name) !== "id") .map((column) => { const name = String(column.name); const label = String(column.label || humanizeKey(name)); @@ -3655,7 +3653,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; const genericConfigHeaders = useMemo(() => { if (!activeConfigMeta || !Array.isArray(activeConfigMeta.columns)) return []; const headers = (activeConfigMeta.columns || []) - .filter((column) => column && column.name) + .filter((column) => column && column.name && String(column.name) !== "id") .map((column) => { const name = String(column.name); return { diff --git a/app/web/admin/features/config/ConfigSection.jsx b/app/web/admin/features/config/ConfigSection.jsx index e385b75..9026a2e 100644 --- a/app/web/admin/features/config/ConfigSection.jsx +++ b/app/web/admin/features/config/ConfigSection.jsx @@ -99,6 +99,26 @@ export function ConfigSection(props) { ) : null}