test new design 06

This commit is contained in:
TronoSfera 2026-04-07 17:28:27 +03:00
parent 9a53d92377
commit 1221bcc684
24 changed files with 2973 additions and 429 deletions

View file

@ -0,0 +1,98 @@
"""fix orphaned status_group_id references in statuses table
When a StatusGroup is deleted while statuses still reference it,
the status ends up with a status_group_id pointing to a non-existent row.
This causes the Kanban to render a phantom UUID column instead of a named group.
This migration reassigns any such orphaned statuses to the most appropriate
existing group using the same heuristic as fallback_group_for_status().
Revision ID: 0037_fix_orphaned_status_group_ids
Revises: 0036_message_author_admin_id
Create Date: 2026-04-06
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0037_fix_status_groups"
down_revision = "0036_message_author_admin_id"
branch_labels = None
depends_on = None
# Priority-ordered list of group names to use as fallback.
# The migration tries each in order and uses the first one found in the DB.
_WORK_GROUP_CANDIDATES = [
"Юридический процесс",
"В работе",
"In Progress",
]
_NEW_GROUP_CANDIDATES = [
"Новые",
"New",
"Входящие",
]
_DONE_GROUP_CANDIDATES = [
"Завершены",
"Closed",
"Done",
]
def _first_group_id(conn, candidates):
"""Return the id of the first found StatusGroup whose name is in candidates."""
for name in candidates:
row = conn.execute(
sa.text("SELECT id FROM status_groups WHERE name = :n LIMIT 1"),
{"n": name},
).fetchone()
if row:
return str(row[0])
return None
def upgrade() -> None:
conn = op.get_bind()
# Count orphaned statuses before fix
orphaned = conn.execute(sa.text("""
SELECT COUNT(*) FROM statuses
WHERE status_group_id IS NOT NULL
AND status_group_id NOT IN (SELECT id FROM status_groups)
""")).scalar() or 0
if orphaned == 0:
return # nothing to fix
# Prefer the "В работе / Юридический процесс" group as the universal fallback
work_id = _first_group_id(conn, _WORK_GROUP_CANDIDATES)
if not work_id:
# Last resort: use any existing group
row = conn.execute(
sa.text("SELECT id FROM status_groups ORDER BY sort_order ASC LIMIT 1")
).fetchone()
work_id = str(row[0]) if row else None
if not work_id:
# No status groups at all — skip (shouldn't happen on a live system)
return
result = conn.execute(
sa.text("""
UPDATE statuses
SET status_group_id = :gid
WHERE status_group_id IS NOT NULL
AND status_group_id NOT IN (SELECT id FROM status_groups)
"""),
{"gid": work_id},
)
fixed = result.rowcount
if fixed:
print(f"\n[0037] Fixed {fixed} status(es) with orphaned status_group_id → reassigned to group {work_id}")
def downgrade() -> None:
# Intentionally a no-op: we cannot restore the original deleted group ids.
pass

View file

@ -0,0 +1,29 @@
"""add avatar_original_key and avatar_crop_json to admin_users
Stores the S3 key of the unmodified original photo and the crop parameters
so the avatar can be re-cropped without re-uploading the source image.
Revision ID: 0038_avatar_crop_fields
Revises: 0037_fix_status_groups
Create Date: 2026-04-06
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0038_avatar_crop_fields"
down_revision = "0037_fix_status_groups"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("admin_users", sa.Column("avatar_original_key", sa.String(500), nullable=True))
op.add_column("admin_users", sa.Column("avatar_crop_json", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("admin_users", "avatar_crop_json")
op.drop_column("admin_users", "avatar_original_key")

View file

@ -524,12 +524,15 @@ def get_requests_kanban_service(
columns_by_key[fallback_key] = {"key": fallback_key, "label": fallback_label, "sort_order": fallback_order} columns_by_key[fallback_key] = {"key": fallback_key, "label": fallback_label, "sort_order": fallback_order}
columns_catalog.append(columns_by_key[fallback_key]) columns_catalog.append(columns_by_key[fallback_key])
elif status_group not in columns_by_key: elif status_group not in columns_by_key:
columns_by_key[status_group] = { # status_group_id references a deleted/non-existent StatusGroup —
"key": status_group, # remap to a heuristic fallback column instead of creating a phantom UUID column
"label": status_group_name or status_group, fallback_key, fallback_label, fallback_order = fallback_group_for_status(status_code, status_meta)
"sort_order": int(status_group_order or 999), status_group = fallback_key
} status_group_name = fallback_label
columns_catalog.append(columns_by_key[status_group]) status_group_order = fallback_order
if fallback_key not in columns_by_key:
columns_by_key[fallback_key] = {"key": fallback_key, "label": fallback_label, "sort_order": fallback_order}
columns_catalog.append(columns_by_key[fallback_key])
available_transitions = [] available_transitions = []
topic_rules = transitions_by_topic.get(topic_code) or [] topic_rules = transitions_by_topic.get(topic_code) or []
@ -541,7 +544,7 @@ def get_requests_kanban_service(
continue continue
to_meta = status_meta_or_default(status_meta_map, to_status) to_meta = status_meta_or_default(status_meta_map, to_status)
target_group = str(to_meta.get("status_group_id") or "").strip() target_group = str(to_meta.get("status_group_id") or "").strip()
if not target_group: if not target_group or target_group not in columns_by_key:
target_group, fallback_label, fallback_order = fallback_group_for_status(to_status, to_meta) target_group, fallback_label, fallback_order = fallback_group_for_status(to_status, to_meta)
if target_group not in columns_by_key: if target_group not in columns_by_key:
columns_by_key[target_group] = {"key": target_group, "label": fallback_label, "sort_order": fallback_order} columns_by_key[target_group] = {"key": target_group, "label": fallback_label, "sort_order": fallback_order}
@ -563,7 +566,7 @@ def get_requests_kanban_service(
continue continue
to_meta = status_meta_or_default(status_meta_map, to_status) to_meta = status_meta_or_default(status_meta_map, to_status)
target_group = str(to_meta.get("status_group_id") or "").strip() target_group = str(to_meta.get("status_group_id") or "").strip()
if not target_group: if not target_group or target_group not in columns_by_key:
target_group, fallback_label, fallback_order = fallback_group_for_status(to_status, to_meta) target_group, fallback_label, fallback_order = fallback_group_for_status(to_status, to_meta)
if target_group not in columns_by_key: if target_group not in columns_by_key:
columns_by_key[target_group] = {"key": target_group, "label": fallback_label, "sort_order": fallback_order} columns_by_key[target_group] = {"key": target_group, "label": fallback_label, "sort_order": fallback_order}

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import io import io
import json
import uuid import uuid
from typing import Tuple from typing import Tuple
@ -18,7 +19,15 @@ from app.models.admin_user import AdminUser
from app.models.attachment import Attachment from app.models.attachment import Attachment
from app.models.message import Message from app.models.message import Message
from app.models.request import Request from app.models.request import Request
from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, UploadInitPayload, UploadInitResponse, UploadScope from app.schemas.uploads import (
RecropPayload,
RecropResponse,
UploadCompletePayload,
UploadCompleteResponse,
UploadInitPayload,
UploadInitResponse,
UploadScope,
)
from app.api.admin.requests_modules.permissions import ensure_lawyer_can_view_request_or_403 from app.api.admin.requests_modules.permissions import ensure_lawyer_can_view_request_or_403
from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event
from app.services.request_read_markers import EVENT_ATTACHMENT, mark_unread_for_client from app.services.request_read_markers import EVENT_ATTACHMENT, mark_unread_for_client
@ -36,6 +45,9 @@ router = APIRouter()
AVATAR_MAX_SIZE_PX = 512 AVATAR_MAX_SIZE_PX = 512
AVATAR_THUMB_MAX_SIZE_PX = 160 AVATAR_THUMB_MAX_SIZE_PX = 160
AVATAR_WEBP_QUALITY = 80 AVATAR_WEBP_QUALITY = 80
AVATAR_THUMB_WEBP_QUALITY = 72
AVATAR_ORIGINAL_MAX_SIZE_PX = 1600
AVATAR_ORIGINAL_WEBP_QUALITY = 82
_AVATAR_RESAMPLE = getattr(getattr(Image, "Resampling", Image), "LANCZOS", 1) _AVATAR_RESAMPLE = getattr(getattr(Image, "Resampling", Image), "LANCZOS", 1)
@ -191,6 +203,113 @@ def _write_avatar_variant_or_400(storage, *, source_key: str, variant: str, max_
return target_key, int(len(optimized)), "image/webp" return target_key, int(len(optimized)), "image/webp"
def _parse_crop_dict(crop_json: str | None) -> dict:
"""Parse crop JSON string into a validated dict with clamped values."""
raw: dict = {}
if crop_json:
try:
raw = json.loads(crop_json)
except (ValueError, TypeError):
raw = {}
x = max(-1.0, min(1.0, float(raw.get("x", 0.0) or 0.0)))
y = max(-1.0, min(1.0, float(raw.get("y", 0.0) or 0.0)))
zoom = max(1.0, min(4.0, float(raw.get("zoom", 1.0) or 1.0)))
return {"x": x, "y": y, "zoom": zoom}
def _crop_cover(image: Image.Image, target_size: tuple[int, int], crop: dict) -> Image.Image:
"""Crop and resize *image* to *target_size* according to (x, y, zoom) parameters.
x, y: -1.0..1.0 normalized offset from the image center.
zoom: 1.0..4.0 zoom multiplier (1 = minimum crop to fill target aspect ratio).
Algorithm ported from Flw/backend/media_images.py and matches the
CSS-transform-based preview in AvatarCropEditor.jsx exactly.
"""
tw, th = target_size
sw, sh = image.size
# Minimum scale to cover the target rectangle from the source
base_scale = max(tw / sw, th / sh)
# Size of the crop window in source pixels
crop_w = max(1.0, min(float(sw), tw / base_scale / crop["zoom"]))
crop_h = max(1.0, min(float(sh), th / base_scale / crop["zoom"]))
# Maximum pan offsets (from center to edge) in source pixels
offset_x = (sw - crop_w) / 2.0
offset_y = (sh - crop_h) / 2.0
# Center of the crop window
cx = sw / 2.0 + crop["x"] * offset_x
cy = sh / 2.0 + crop["y"] * offset_y
left = max(0.0, min(sw - crop_w, cx - crop_w / 2.0))
top = max(0.0, min(sh - crop_h, cy - crop_h / 2.0))
box = (left, top, left + crop_w, top + crop_h)
return image.crop(box).resize((tw, th), _AVATAR_RESAMPLE)
def _render_avatar_original_webp(source: bytes) -> bytes:
"""Compress source image to an archival-quality WebP (max AVATAR_ORIGINAL_MAX_SIZE_PX px)."""
try:
with Image.open(io.BytesIO(source)) as image:
image = ImageOps.exif_transpose(image)
image.load()
if max(image.size) > AVATAR_ORIGINAL_MAX_SIZE_PX:
image.thumbnail(
(AVATAR_ORIGINAL_MAX_SIZE_PX, AVATAR_ORIGINAL_MAX_SIZE_PX),
resample=_AVATAR_RESAMPLE,
)
if image.mode != "RGB":
image = image.convert("RGB")
out = io.BytesIO()
image.save(out, format="WEBP", quality=AVATAR_ORIGINAL_WEBP_QUALITY, method=6)
result = out.getvalue()
except UnidentifiedImageError:
raise HTTPException(status_code=400, detail="Аватар должен быть изображением")
except OSError:
raise HTTPException(status_code=400, detail="Не удалось обработать изображение аватара")
if not result:
raise HTTPException(status_code=400, detail="Не удалось обработать изображение аватара")
return result
def _render_avatar_cropped_webp(source: bytes, crop: dict, *, size_px: int, quality: int) -> bytes:
"""Apply crop parameters to source image and produce a square WebP."""
try:
with Image.open(io.BytesIO(source)) as image:
image = ImageOps.exif_transpose(image)
image.load()
if image.mode != "RGB":
image = image.convert("RGB")
cropped = _crop_cover(image, (size_px, size_px), crop)
out = io.BytesIO()
cropped.save(out, format="WEBP", quality=quality, method=6)
result = out.getvalue()
except UnidentifiedImageError:
raise HTTPException(status_code=400, detail="Аватар должен быть изображением")
except OSError:
raise HTTPException(status_code=400, detail="Не удалось обработать изображение аватара")
if not result:
raise HTTPException(status_code=400, detail="Не удалось обработать изображение аватара")
return result
def _avatar_deterministic_keys(user_id: uuid.UUID) -> dict[str, str]:
"""Return the three deterministic S3 keys for a user's avatar."""
prefix = f"avatars/{user_id}"
return {
"original": f"{prefix}/original.webp",
"cropped": f"{prefix}/cropped.webp",
"thumb": f"{prefix}/cropped__thumb.webp",
}
def _delete_object_silent(storage, key: str) -> None:
"""Delete an S3 object, ignoring errors (best-effort cleanup)."""
try:
if hasattr(storage, "client") and hasattr(storage, "bucket"):
storage.client.delete_object(Bucket=storage.bucket, Key=key)
except Exception:
pass
def _serialize_attachment(row: Attachment) -> dict: def _serialize_attachment(row: Attachment) -> dict:
return { return {
"id": str(row.id), "id": str(row.id),
@ -426,13 +545,40 @@ def upload_complete(
if user is None: if user is None:
raise HTTPException(status_code=404, detail="Пользователь не найден") raise HTTPException(status_code=404, detail="Пользователь не найден")
_ensure_object_key_prefix_or_400(payload.key, f"avatars/{user.id}/") _ensure_object_key_prefix_or_400(payload.key, f"avatars/{user.id}/")
thumb_key, optimized_size, optimized_mime = _write_avatar_variant_or_400(
storage, # Read the raw uploaded file from the presigned-PUT key
source_key=payload.key, raw_source = _read_object_bytes_or_400(storage, payload.key)
variant="thumb",
max_size_px=AVATAR_THUMB_MAX_SIZE_PX, # Parse crop params (defaults to centered 1× zoom if not provided)
crop = _parse_crop_dict(payload.crop_json)
# Deterministic S3 keys for this user
keys = _avatar_deterministic_keys(user.id)
# 1. Compress original (archival quality, no crop)
original_bytes = _render_avatar_original_webp(raw_source)
_write_object_bytes_or_500(storage, key=keys["original"], content=original_bytes, mime_type="image/webp")
# 2. Cropped avatar (512×512)
cropped_bytes = _render_avatar_cropped_webp(
raw_source, crop, size_px=AVATAR_MAX_SIZE_PX, quality=AVATAR_WEBP_QUALITY
) )
user.avatar_url = f"s3://{payload.key}" _write_object_bytes_or_500(storage, key=keys["cropped"], content=cropped_bytes, mime_type="image/webp")
# 3. Thumbnail (160×160, same crop)
thumb_bytes = _render_avatar_cropped_webp(
raw_source, crop, size_px=AVATAR_THUMB_MAX_SIZE_PX, quality=AVATAR_THUMB_WEBP_QUALITY
)
_write_object_bytes_or_500(storage, key=keys["thumb"], content=thumb_bytes, mime_type="image/webp")
# Clean up the temp presigned-PUT object (best-effort)
if payload.key != keys["original"]:
_delete_object_silent(storage, payload.key)
# Update user record
user.avatar_url = f"s3://{keys['cropped']}"
user.avatar_original_key = keys["original"]
user.avatar_crop_json = json.dumps(crop)
user.responsible = responsible user.responsible = responsible
db.add(user) db.add(user)
record_file_security_event( record_file_security_event(
@ -447,15 +593,19 @@ def upload_complete(
details={ details={
"source_mime_type": payload.mime_type, "source_mime_type": payload.mime_type,
"source_size_bytes": int(actual_size), "source_size_bytes": int(actual_size),
"variant": "thumb", "original_key": keys["original"],
"variant_key": thumb_key, "cropped_key": keys["cropped"],
"variant_mime_type": optimized_mime, "thumb_key": keys["thumb"],
"variant_size_bytes": int(optimized_size), "crop": crop,
}, },
responsible=responsible, responsible=responsible,
) )
db.commit() db.commit()
return UploadCompleteResponse(status="ok", avatar_url=user.avatar_url) return UploadCompleteResponse(
status="ok",
avatar_url=user.avatar_url,
avatar_original_key=keys["original"],
)
raise HTTPException(status_code=400, detail="Неподдерживаемый scope") raise HTTPException(status_code=400, detail="Неподдерживаемый scope")
except HTTPException as exc: except HTTPException as exc:
@ -477,6 +627,93 @@ def upload_complete(
raise raise
@router.post("/recrop", response_model=RecropResponse)
def avatar_recrop(
payload: RecropPayload,
http_request: FastapiRequest,
db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
):
"""Re-apply crop parameters to an existing original avatar without re-uploading."""
role = str(admin.get("role") or "").upper() or "UNKNOWN"
actor_id = str(admin.get("sub") or "").strip()
actor_ip = _client_ip(http_request)
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
target_uuid_for_log: uuid.UUID | None = None
try:
# HIGH-2: explicit guard so an empty actor_id never silently passes the self-service check.
if not actor_id and role != "ADMIN":
raise HTTPException(status_code=401, detail="Некорректный токен")
target_uuid_for_log = _uuid_or_400(payload.user_id, "user_id")
if role != "ADMIN" and str(target_uuid_for_log) != actor_id:
raise HTTPException(status_code=403, detail="Недостаточно прав для обрезки аватара")
user = db.get(AdminUser, target_uuid_for_log)
if user is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
if not user.avatar_original_key:
raise HTTPException(status_code=400, detail="Оригинал аватара не найден — сначала загрузите фото")
crop = _parse_crop_dict(payload.crop_json)
storage = get_s3_storage()
# Read the compressed original from S3
original_source = _read_object_bytes_or_400(storage, user.avatar_original_key)
# Re-generate cropped variants
keys = _avatar_deterministic_keys(target_uuid_for_log)
cropped_bytes = _render_avatar_cropped_webp(
original_source, crop, size_px=AVATAR_MAX_SIZE_PX, quality=AVATAR_WEBP_QUALITY
)
_write_object_bytes_or_500(storage, key=keys["cropped"], content=cropped_bytes, mime_type="image/webp")
thumb_bytes = _render_avatar_cropped_webp(
original_source, crop, size_px=AVATAR_THUMB_MAX_SIZE_PX, quality=AVATAR_THUMB_WEBP_QUALITY
)
_write_object_bytes_or_500(storage, key=keys["thumb"], content=thumb_bytes, mime_type="image/webp")
user.avatar_url = f"s3://{keys['cropped']}"
user.avatar_crop_json = json.dumps(crop)
user.responsible = responsible
db.add(user)
record_file_security_event(
db,
actor_role=role,
actor_subject=actor_id,
actor_ip=actor_ip,
action="AVATAR_RECROP",
scope="avatars",
allowed=True,
object_key=keys["cropped"],
details={"crop": crop},
responsible=responsible,
persist_now=True,
)
db.commit()
return RecropResponse(status="ok", avatar_url=user.avatar_url)
except HTTPException as exc:
# HIGH-1: log rejected recrop attempts for audit consistency.
record_file_security_event(
db,
actor_role=role,
actor_subject=actor_id,
actor_ip=actor_ip,
action="AVATAR_RECROP",
scope="avatars",
allowed=False,
reason=str(exc.detail),
object_key=None,
details={"user_id": payload.user_id},
responsible=responsible,
persist_now=True,
)
raise
@router.get("/request-attachments/{request_id}") @router.get("/request-attachments/{request_id}")
def list_request_attachments( def list_request_attachments(
request_id: str, request_id: str,
@ -557,7 +794,13 @@ def get_object_proxy(
storage = get_s3_storage() storage = get_s3_storage()
if scope == "avatars" and requested_variant == "thumb": if scope == "avatars" and requested_variant == "thumb":
thumb_key = _avatar_variant_key(key, "thumb") # New deterministic layout: cropped.webp → cropped__thumb.webp
# Old layout: {uuid}-name.ext → {uuid}-name__thumb.webp (preserved via _avatar_variant_key)
if key.endswith("/cropped.webp"):
# New-style key — thumb is always stored alongside as cropped__thumb.webp
thumb_key = key[: -len("cropped.webp")] + "cropped__thumb.webp"
else:
thumb_key = _avatar_variant_key(key, "thumb")
try: try:
obj = storage.get_object(thumb_key) obj = storage.get_object(thumb_key)
except ClientError: except ClientError:

View file

@ -49,8 +49,6 @@ def list_featured_staff(
LandingFeaturedStaff.enabled.is_(True), LandingFeaturedStaff.enabled.is_(True),
AdminUser.is_active.is_(True), AdminUser.is_active.is_(True),
AdminUser.role.in_(("ADMIN", "LAWYER")), AdminUser.role.in_(("ADMIN", "LAWYER")),
AdminUser.avatar_url.is_not(None),
and_(AdminUser.avatar_url != ""),
) )
.order_by( .order_by(
LandingFeaturedStaff.pinned.desc(), LandingFeaturedStaff.pinned.desc(),

View file

@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import Boolean, DateTime, JSON, Numeric, String from sqlalchemy import Boolean, DateTime, JSON, Numeric, String, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base from app.db.session import Base
from app.models.common import UUIDMixin, TimestampMixin from app.models.common import UUIDMixin, TimestampMixin
@ -13,6 +13,8 @@ class AdminUser(Base, UUIDMixin, TimestampMixin):
phone: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True) phone: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False) password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True) avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
avatar_original_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
avatar_crop_json: Mapped[str | None] = mapped_column(Text, nullable=True)
primary_topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True) primary_topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
default_rate: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True) default_rate: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True)
salary_percent: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True) salary_percent: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True)

View file

@ -35,9 +35,25 @@ class UploadCompletePayload(BaseModel):
request_id: Optional[str] = None request_id: Optional[str] = None
message_id: Optional[str] = None message_id: Optional[str] = None
user_id: Optional[str] = None user_id: Optional[str] = None
# Optional crop parameters for USER_AVATAR scope.
# JSON string: {"x": float, "y": float, "zoom": float}
# x/y: -1.0..1.0 (offset from center), zoom: 1.0..4.0
crop_json: Optional[str] = None
class UploadCompleteResponse(BaseModel): class UploadCompleteResponse(BaseModel):
status: str = "ok" status: str = "ok"
attachment_id: Optional[str] = None attachment_id: Optional[str] = None
avatar_url: Optional[str] = None avatar_url: Optional[str] = None
avatar_original_key: Optional[str] = None
class RecropPayload(BaseModel):
user_id: str
# JSON string: {"x": float, "y": float, "zoom": float}
crop_json: str
class RecropResponse(BaseModel):
status: str = "ok"
avatar_url: Optional[str] = None

View file

@ -3663,6 +3663,19 @@
padding-top: 0.15rem; padding-top: 0.15rem;
} }
/* Crop-mode: collapse to single column, hide form panel, centre the editor */
.record-user-top--crop-mode {
grid-template-columns: 1fr;
}
.record-user-top--crop-mode .record-user-summary {
display: none;
}
.record-user-top--crop-mode .record-user-avatar-area {
padding-top: 0;
}
.record-user-avatar-shell { .record-user-avatar-shell {
width: 156px; width: 156px;
height: 156px; height: 156px;
@ -3855,6 +3868,81 @@
text-align: center; text-align: center;
} }
/* ── Avatar Crop Editor ─────────────────────────────────────────────────── */
.avatar-crop-editor {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.85rem;
padding: 0.25rem 0 0.5rem;
}
.avatar-crop-hint {
font-size: 0.78rem;
color: var(--fg3, #7a8ea0);
text-align: center;
margin: 0;
}
.avatar-crop-viewport {
width: 320px;
height: 320px;
position: relative;
border-radius: 50%;
overflow: hidden;
border: 2px solid rgba(241, 211, 163, 0.28);
background: #0a0e14;
cursor: grab;
user-select: none;
-webkit-user-select: none;
flex-shrink: 0;
}
.avatar-crop-viewport:active {
cursor: grabbing;
}
.avatar-crop-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.82rem;
color: var(--fg3, #7a8ea0);
}
.avatar-crop-controls {
display: flex;
align-items: center;
gap: 0.75rem;
width: 320px;
}
.avatar-crop-controls label {
font-size: 0.82rem;
color: var(--fg2, #a8bbcc);
white-space: nowrap;
}
.avatar-crop-controls input[type="range"] {
flex: 1;
accent-color: var(--accent, #f1d3a3);
}
.avatar-crop-controls span {
font-size: 0.8rem;
color: var(--fg3, #7a8ea0);
min-width: 2.6rem;
text-align: right;
}
.avatar-crop-actions {
display: flex;
gap: 0.55rem;
}
/* ────────────────────────────────────────────────────────────────────────── */
.overlay { .overlay {
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; overscroll-behavior: contain;

View file

@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>Административная панель • Правовой трекер</title> <title>Административная панель • Правовой трекер</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=20260302-01"> <link rel="icon" type="image/svg+xml" href="/favicon.svg?v=20260302-01">
<link rel="stylesheet" href="/admin.css?v=20260331-05"> <link rel="stylesheet" href="/admin.css?v=20260331-05">

File diff suppressed because one or more lines are too long

View file

@ -1281,6 +1281,15 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
})); }));
}, [dictionaries.users]); }, [dictionaries.users]);
const getLawyerAndAdminOptions = useCallback(() => {
return (dictionaries.users || [])
.filter((item) => item && item.id && ["LAWYER", "ADMIN"].includes(String(item.role || "").toUpperCase()))
.map((item) => ({
value: item.id,
label: (item.name || item.email || item.id) + (item.email ? " (" + item.email + ")" : ""),
}));
}, [dictionaries.users]);
const getFormFieldTypeOptions = useCallback(() => { const getFormFieldTypeOptions = useCallback(() => {
return (dictionaries.formFieldTypes || []).filter(Boolean).map((item) => ({ value: item, label: item })); return (dictionaries.formFieldTypes || []).filter(Boolean).map((item) => ({ value: item, label: item }));
}, [dictionaries.formFieldTypes]); }, [dictionaries.formFieldTypes]);
@ -1326,7 +1335,17 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
if (rawValue == null || rawValue === "") return; if (rawValue == null || rawValue === "") return;
const value = String(rawValue); const value = String(rawValue);
const labelRaw = row[reference.label_field]; const labelRaw = row[reference.label_field];
const label = String(labelRaw == null || labelRaw === "" ? rawValue : labelRaw); let label;
if (reference.table === "admin_users") {
const name = String(labelRaw || "").trim();
const email = String(row.email || "").trim();
if (name && email) label = name + " (" + email + ")";
else if (name) label = name;
else if (email) label = email;
else label = value;
} else {
label = String(labelRaw == null || labelRaw === "" ? rawValue : labelRaw);
}
if (!map.has(value)) map.set(value, label); if (!map.has(value)) map.set(value, label);
}); });
return Array.from(map.entries()) return Array.from(map.entries())
@ -1922,6 +1941,8 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
uploadScope: "USER_AVATAR", uploadScope: "USER_AVATAR",
accept: "image/*", accept: "image/*",
}, },
{ key: "avatar_original_key", label: "Оригинал аватара", type: "text", optional: true, hidden: true },
{ key: "avatar_crop_json", label: "Кроп аватара", type: "text", optional: true, hidden: true },
{ key: "primary_topic_code", label: "Профиль (тема)", type: "reference", optional: true, options: getTopicOptions }, { key: "primary_topic_code", label: "Профиль (тема)", type: "reference", optional: true, options: getTopicOptions },
{ key: "default_rate", label: "Ставка по умолчанию", type: "number", optional: true }, { key: "default_rate", label: "Ставка по умолчанию", type: "number", optional: true },
{ key: "salary_percent", label: "Процент зарплаты", type: "number", optional: true }, { key: "salary_percent", label: "Процент зарплаты", type: "number", optional: true },
@ -1935,6 +1956,15 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
{ key: "topic_code", label: "Дополнительная тема", type: "reference", required: true, options: getTopicOptions }, { key: "topic_code", label: "Дополнительная тема", type: "reference", required: true, options: getTopicOptions },
]; ];
} }
if (tableKey === "landing_featured_staff") {
return [
{ key: "admin_user_id", label: "Сотрудник", type: "reference", required: true, options: getLawyerAndAdminOptions },
{ key: "caption", label: "Подпись", type: "textarea", optional: true, placeholder: "Краткое описание специалиста" },
{ key: "sort_order", label: "Порядок", type: "number", defaultValue: "0" },
{ key: "pinned", label: "Закреплён", type: "boolean", defaultValue: "false" },
{ key: "enabled", label: "Отображать", type: "boolean", defaultValue: "true" },
];
}
const meta = tableCatalogMap[tableKey]; const meta = tableCatalogMap[tableKey];
if (!meta || !Array.isArray(meta.columns)) return []; if (!meta || !Array.isArray(meta.columns)) return [];
return (meta.columns || []) return (meta.columns || [])
@ -2459,7 +2489,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
); );
const uploadRecordFieldFile = useCallback( const uploadRecordFieldFile = useCallback(
async (field, file) => { async (field, file, cropJson) => {
if (!recordModal.tableKey || !field || !file) return; if (!recordModal.tableKey || !field || !file) return;
if (field.uploadScope !== "USER_AVATAR") return; if (field.uploadScope !== "USER_AVATAR") return;
if (recordModal.tableKey !== "users") return; if (recordModal.tableKey !== "users") return;
@ -2489,18 +2519,23 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
`Не удалось загрузить файл в хранилище (${putResp.status}${errorText ? `: ${errorText.slice(0, 200)}` : ""})` `Не удалось загрузить файл в хранилище (${putResp.status}${errorText ? `: ${errorText.slice(0, 200)}` : ""})`
); );
} }
const completeBody = {
key: init.key,
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "USER_AVATAR",
user_id: recordModal.rowId,
};
if (cropJson) completeBody.crop_json = JSON.stringify(cropJson);
const done = await api("/api/admin/uploads/complete", { const done = await api("/api/admin/uploads/complete", {
method: "POST", method: "POST",
body: { body: completeBody,
key: init.key,
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "USER_AVATAR",
user_id: recordModal.rowId,
},
}); });
updateRecordField("avatar_url", String(done.avatar_url || "")); updateRecordField("avatar_url", String(done.avatar_url || ""));
if (done.avatar_original_key) {
updateRecordField("avatar_original_key", String(done.avatar_original_key));
}
setStatus("recordForm", "Аватар загружен", "ok"); setStatus("recordForm", "Аватар загружен", "ok");
} catch (error) { } catch (error) {
setStatus("recordForm", "Ошибка загрузки: " + error.message, "error"); setStatus("recordForm", "Ошибка загрузки: " + error.message, "error");
@ -2509,6 +2544,52 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
[api, recordModal, setStatus, updateRecordField] [api, recordModal, setStatus, updateRecordField]
); );
// recropAvatar: fetches original from S3, then calls setCropFile(file) inside RecordModal
// via the provided callback, so the crop editor opens inline without full re-render.
const recropAvatar = useCallback(
async (avatarField, form, setCropFileCallback) => {
if (!recordModal.rowId || !form) return;
if (!form.avatar_original_key) {
setStatus("recordForm", "Оригинал аватара не найден", "error");
return;
}
try {
setStatus("recordForm", "Загрузка оригинала...", "");
const src = `/api/admin/uploads/object/${encodeURIComponent(form.avatar_original_key)}?token=${encodeURIComponent(token)}`;
const resp = await fetch(src);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const blob = await resp.blob();
const file = new File([blob], "original.webp", { type: blob.type || "image/webp" });
setStatus("recordForm", "", "");
if (setCropFileCallback) setCropFileCallback(file);
} catch (err) {
setStatus("recordForm", "Не удалось загрузить оригинал: " + err.message, "error");
}
},
[recordModal.rowId, setStatus, token]
);
// applyRecrop: called after user confirms new crop from the re-crop editor.
// Sends only the crop params to /recrop (no file re-upload needed).
const applyRecrop = useCallback(
async (cropJson) => {
if (!recordModal.rowId) return;
try {
setStatus("recordForm", "Применение кадрирования...", "");
const done = await api("/api/admin/uploads/recrop", {
method: "POST",
body: { user_id: recordModal.rowId, crop_json: JSON.stringify(cropJson) },
});
// Force browser to reload the avatar by appending a cache-bust param
updateRecordField("avatar_url", String(done.avatar_url || "") + "?t=" + Date.now());
setStatus("recordForm", "Кадрирование обновлено", "ok");
} catch (err) {
setStatus("recordForm", "Ошибка кадрирования: " + err.message, "error");
}
},
[api, recordModal.rowId, setStatus, updateRecordField]
);
const buildRecordPayload = useCallback( const buildRecordPayload = useCallback(
(tableKey, form, mode) => { (tableKey, form, mode) => {
const fields = getRecordFields(tableKey); const fields = getRecordFields(tableKey);
@ -2519,6 +2600,8 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
fields.forEach((field) => { fields.forEach((field) => {
if (isLawyerRequestEdit && field.key !== "topic_code") return; if (isLawyerRequestEdit && field.key !== "topic_code") return;
if (isAdminRequestEdit && adminRequestRestricted.has(field.key)) return; if (isAdminRequestEdit && adminRequestRestricted.has(field.key)) return;
// Hidden fields are for frontend state only never sent to the server.
if (field.hidden) return;
const raw = form[field.key]; const raw = form[field.key];
if (field.type === "boolean") { if (field.type === "boolean") {
payload[field.key] = raw === "true"; payload[field.key] = raw === "true";
@ -4254,6 +4337,9 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
onClose={closeRecordModal} onClose={closeRecordModal}
onChange={updateRecordField} onChange={updateRecordField}
onUploadField={uploadRecordFieldFile} onUploadField={uploadRecordFieldFile}
onUploadFieldWithCrop={uploadRecordFieldFile}
onRecropAvatar={recropAvatar}
onApplyRecrop={applyRecrop}
onSubmit={submitRecordModal} onSubmit={submitRecordModal}
OverlayComponent={Overlay} OverlayComponent={Overlay}
IconButtonComponent={IconButton} IconButtonComponent={IconButton}

View file

@ -0,0 +1,227 @@
// AvatarCropEditor inline circular avatar crop selector.
//
// The preview math mirrors _crop_cover() in uploads.py exactly so what you
// see in the circle is what the backend will generate.
//
// Crop parameters: { x, y, zoom }
// x, y : -1.0 1.0 normalized offset from image center
// zoom : 1.0 4.0 zoom multiplier (1 = minimum cover)
const { useCallback, useEffect, useRef, useState } = React;
const VIEWPORT_PX = 320; // diameter of the preview circle (matches CSS)
const ZOOM_MIN = 1.0;
const ZOOM_MAX = 4.0;
const ZOOM_STEP = 0.01;
export function AvatarCropEditor({ imageFile, initialCrop, onApply, onCancel }) {
const [objectUrl, setObjectUrl] = useState(null);
const [naturalW, setNaturalW] = useState(0);
const [naturalH, setNaturalH] = useState(0);
const [loaded, setLoaded] = useState(false);
const [panX, setPanX] = useState(initialCrop?.x ?? 0);
const [panY, setPanY] = useState(initialCrop?.y ?? 0);
const [zoom, setZoom] = useState(
Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, initialCrop?.zoom ?? 1.0))
);
const dragRef = useRef(null); // {startX, startY, startPanX, startPanY}
const viewportRef = useRef(null);
// Create a local object URL from the File; reset pan/zoom to initialCrop (or defaults)
useEffect(() => {
if (!imageFile) return;
const url = URL.createObjectURL(imageFile);
setObjectUrl(url);
setLoaded(false);
setPanX(initialCrop?.x ?? 0);
setPanY(initialCrop?.y ?? 0);
setZoom(Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, initialCrop?.zoom ?? 1.0)));
return () => URL.revokeObjectURL(url);
}, [imageFile, initialCrop]);
const handleImageLoad = useCallback(
(event) => {
setNaturalW(event.currentTarget.naturalWidth);
setNaturalH(event.currentTarget.naturalHeight);
setLoaded(true);
},
[]
);
// Geometry (matches _crop_cover in uploads.py)
const minSide = Math.min(naturalW, naturalH) || 1;
const cropSrcW = minSide / zoom; // crop window width in source pixels
const cropSrcH = minSide / zoom;
const displayScale = naturalW && naturalH ? VIEWPORT_PX / cropSrcW : 1;
const offsetX = (naturalW - cropSrcW) / 2;
const offsetY = (naturalH - cropSrcH) / 2;
const cx = naturalW / 2 + panX * offsetX;
const cy = naturalH / 2 + panY * offsetY;
const imgW = naturalW * displayScale;
const imgH = naturalH * displayScale;
const imgLeft = VIEWPORT_PX / 2 - cx * displayScale;
const imgTop = VIEWPORT_PX / 2 - cy * displayScale;
// Drag handling
const stopDrag = useCallback(() => {
dragRef.current = null;
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
window.removeEventListener("touchmove", onTouchMove);
window.removeEventListener("touchend", onMouseUp);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const applyDelta = useCallback(
(dxPx, dyPx) => {
if (!dragRef.current) return;
const { startPanX, startPanY } = dragRef.current;
// Converting screen-pixel delta to normalized pan units:
// newCx = oldCx - dxPx / displayScale
// newPanX = (newCx - naturalW/2) / offsetX
const panDx = offsetX > 0 ? dxPx / (displayScale * offsetX) : 0;
const panDy = offsetY > 0 ? dyPx / (displayScale * offsetY) : 0;
setPanX(Math.max(-1, Math.min(1, startPanX - panDx)));
setPanY(Math.max(-1, Math.min(1, startPanY - panDy)));
},
[displayScale, offsetX, offsetY]
);
const onMouseMove = useCallback(
(event) => {
if (!dragRef.current) return;
applyDelta(
event.clientX - dragRef.current.startX,
event.clientY - dragRef.current.startY
);
},
[applyDelta]
);
const onTouchMove = useCallback(
(event) => {
if (!dragRef.current) return;
event.preventDefault();
const t = event.touches[0];
applyDelta(
t.clientX - dragRef.current.startX,
t.clientY - dragRef.current.startY
);
},
[applyDelta]
);
const onMouseUp = useCallback(() => stopDrag(), [stopDrag]);
const startDrag = useCallback(
(clientX, clientY) => {
dragRef.current = {
startX: clientX,
startY: clientY,
startPanX: panX,
startPanY: panY,
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
window.addEventListener("touchmove", onTouchMove, { passive: false });
window.addEventListener("touchend", onMouseUp);
},
[panX, panY, onMouseMove, onMouseUp, onTouchMove]
);
const handleMouseDown = useCallback(
(event) => {
event.preventDefault();
startDrag(event.clientX, event.clientY);
},
[startDrag]
);
const handleTouchStart = useCallback(
(event) => {
const t = event.touches[0];
startDrag(t.clientX, t.clientY);
},
[startDrag]
);
// Clean up listeners on unmount
useEffect(
() => () => stopDrag(),
[stopDrag]
);
// Apply
const handleApply = useCallback(() => {
if (!imageFile || !onApply) return;
onApply({
file: imageFile,
cropJson: { x: panX, y: panY, zoom },
});
}, [imageFile, onApply, panX, panY, zoom]);
// Render
return (
<div className="avatar-crop-editor">
<p className="avatar-crop-hint">
Перетащите изображение, чтобы выбрать область фокуса
</p>
<div
ref={viewportRef}
className="avatar-crop-viewport"
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
aria-label="Область кадрирования аватара"
>
{objectUrl ? (
<img
src={objectUrl}
alt=""
draggable={false}
onLoad={handleImageLoad}
style={
loaded
? {
position: "absolute",
width: imgW + "px",
height: imgH + "px",
left: imgLeft + "px",
top: imgTop + "px",
pointerEvents: "none",
userSelect: "none",
}
: { opacity: 0 }
}
/>
) : null}
{!loaded ? (
<span className="avatar-crop-loading">Загрузка</span>
) : null}
</div>
<div className="avatar-crop-controls">
<label htmlFor="avatar-crop-zoom">Масштаб</label>
<input
id="avatar-crop-zoom"
type="range"
min={ZOOM_MIN}
max={ZOOM_MAX}
step={ZOOM_STEP}
value={zoom}
onChange={(event) => setZoom(parseFloat(event.target.value))}
/>
<span>{zoom.toFixed(1)}×</span>
</div>
<div className="avatar-crop-actions">
<button className="btn" type="button" onClick={handleApply} disabled={!loaded}>
Применить
</button>
<button className="btn secondary" type="button" onClick={onCancel}>
Отмена
</button>
</div>
</div>
);
}

View file

@ -1,5 +1,6 @@
import { DropdownField } from "./DropdownField.jsx"; import { DropdownField } from "./DropdownField.jsx";
import { resolveAvatarSrc, roleLabel } from "./utils.js"; import { resolveAvatarSrc, roleLabel } from "./utils.js";
import { AvatarCropEditor } from "./AvatarCropEditor.jsx";
const { useEffect, useRef, useState } = React; const { useEffect, useRef, useState } = React;
@ -16,6 +17,9 @@ export function RecordModal({
onChange, onChange,
onSubmit, onSubmit,
onUploadField, onUploadField,
onUploadFieldWithCrop,
onRecropAvatar,
onApplyRecrop,
OverlayComponent, OverlayComponent,
IconButtonComponent, IconButtonComponent,
UserAvatarComponent, UserAvatarComponent,
@ -27,8 +31,12 @@ export function RecordModal({
const StatusLine = StatusLineComponent; const StatusLine = StatusLineComponent;
const [avatarPreviewOpen, setAvatarPreviewOpen] = useState(false); const [avatarPreviewOpen, setAvatarPreviewOpen] = useState(false);
const [userEditing, setUserEditing] = useState(false); const [userEditing, setUserEditing] = useState(false);
const [cropFile, setCropFile] = useState(null); // File waiting for crop selection
const [cropInitial, setCropInitial] = useState(null); // {x,y,zoom} restored from avatar_crop_json
const isRecropRef = useRef(false); // true when cropFile came from existing original (re-crop flow)
const avatarUploadRef = useRef(null); const avatarUploadRef = useRef(null);
const visibleFields = (fields || []).filter((field) => { const visibleFields = (fields || []).filter((field) => {
if (field.hidden) return false;
if (typeof field.visibleWhen !== "function") return true; if (typeof field.visibleWhen !== "function") return true;
try { try {
return Boolean(field.visibleWhen(form || {})); return Boolean(field.visibleWhen(form || {}));
@ -259,58 +267,112 @@ export function RecordModal({
</div> </div>
<form className={"stack" + (isUserModal ? " record-user-scroll" : "")} id="record-modal-form" onSubmit={onSubmit}> <form className={"stack" + (isUserModal ? " record-user-scroll" : "")} id="record-modal-form" onSubmit={onSubmit}>
{isUserModal ? ( {isUserModal ? (
<div className="record-user-top"> <div className={"record-user-top" + (cropFile ? " record-user-top--crop-mode" : "")}>
<div className="record-user-avatar-area"> <div className="record-user-avatar-area">
<button {cropFile ? (
type="button" // Crop editor takes over the avatar area while selecting focus
className={"record-user-avatar-shell" + (avatarPreviewSrc ? " interactive" : "")} <AvatarCropEditor
onClick={() => { imageFile={cropFile}
if (avatarPreviewSrc) setAvatarPreviewOpen(true); initialCrop={cropInitial}
}} onApply={({ file, cropJson }) => {
disabled={!avatarPreviewSrc} const wasRecrop = isRecropRef.current;
aria-label={avatarPreviewSrc ? "Открыть аватар крупно" : "Аватар не загружен"} isRecropRef.current = false;
> setCropFile(null);
<UserAvatar name={userName} email={userEmail} avatarUrl={avatarValue} accessToken={accessToken} size={148} /> setCropInitial(null);
</button> if (wasRecrop && onApplyRecrop) {
{avatarField && (isCreateMode || userEditing) ? ( // Re-crop flow: just send new crop params, no re-upload
onApplyRecrop(cropJson);
} else if (onUploadFieldWithCrop) {
onUploadFieldWithCrop(avatarField, file, cropJson);
} else if (onUploadField) {
onUploadField(avatarField, file);
}
}}
onCancel={() => {
isRecropRef.current = false;
setCropFile(null);
setCropInitial(null);
}}
/>
) : (
<> <>
<input <button
ref={avatarUploadRef} type="button"
type="file" className={"record-user-avatar-shell" + (avatarPreviewSrc ? " interactive" : "")}
accept={avatarField.accept || "image/*"} onClick={() => {
style={{ display: "none" }} if (avatarPreviewSrc) setAvatarPreviewOpen(true);
onChange={(event) => {
const file = event.target.files && event.target.files[0];
if (file && onUploadField) onUploadField(avatarField, file);
event.target.value = "";
}} }}
/> disabled={!avatarPreviewSrc}
<div className="record-user-avatar-toolbar"> aria-label={avatarPreviewSrc ? "Открыть аватар крупно" : "Аватар не загружен"}
<IconButton >
icon={ <UserAvatar name={userName} email={userEmail} avatarUrl={avatarValue} accessToken={accessToken} size={148} />
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false"> </button>
<path d="M12 5a1 1 0 0 1 1 1v6.17l2.59-2.58a1 1 0 1 1 1.41 1.42l-4.29 4.29a1 1 0 0 1-1.42 0L7 11.01a1 1 0 1 1 1.41-1.42L11 12.17V6a1 1 0 0 1 1-1Zm-7 12a1 1 0 0 1 1 1v1h12v-1a1 1 0 1 1 2 0v2a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1Z" fill="currentColor" /> {avatarField && (isCreateMode || userEditing) ? (
</svg> <>
} <input
tooltip="Загрузить аватар" ref={avatarUploadRef}
onClick={() => avatarUploadRef.current?.click()} type="file"
/> accept={avatarField.accept || "image/*"}
<IconButton style={{ display: "none" }}
icon={ onChange={(event) => {
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false"> const file = event.target.files && event.target.files[0];
<path d="M6.7 6.7a1 1 0 0 1 1.4 0L12 10.58l3.9-3.88a1 1 0 1 1 1.4 1.42L13.42 12l3.88 3.9a1 1 0 1 1-1.42 1.4L12 13.42l-3.9 3.88a1 1 0 0 1-1.4-1.42L10.58 12 6.7 8.1a1 1 0 0 1 0-1.4Z" fill="currentColor" /> if (file) {
</svg> setCropInitial(null); // new upload start from center
} setCropFile(file);
tooltip="Сбросить аватар" }
onClick={() => { event.target.value = "";
onChange(avatarField.key, ""); }}
setAvatarPreviewOpen(false); />
}} <div className="record-user-avatar-toolbar">
disabled={!avatarValue} <IconButton
/> icon={
</div> <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
<path d="M12 5a1 1 0 0 1 1 1v6.17l2.59-2.58a1 1 0 1 1 1.41 1.42l-4.29 4.29a1 1 0 0 1-1.42 0L7 11.01a1 1 0 1 1 1.41-1.42L11 12.17V6a1 1 0 0 1 1-1Zm-7 12a1 1 0 0 1 1 1v1h12v-1a1 1 0 1 1 2 0v2a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1Z" fill="currentColor" />
</svg>
}
tooltip="Загрузить аватар"
onClick={() => avatarUploadRef.current?.click()}
/>
{avatarValue && form?.avatar_original_key && onRecropAvatar ? (
<IconButton
icon={
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
<path d="M7 3a1 1 0 0 1 1 1v1h8V4a1 1 0 1 1 2 0v1h1a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h1V4a1 1 0 0 1 1-1Zm-2 6v10h14V9H5Zm4 2h2v2H9v-2Zm4 0h2v2h-2v-2Zm-4 4h2v2H9v-2Zm4 0h2v2h-2v-2Z" fill="currentColor" />
</svg>
}
tooltip="Изменить кадрирование"
onClick={() => {
isRecropRef.current = true;
// Restore previously saved crop so editor opens at last position
let saved = null;
try {
saved = form?.avatar_crop_json
? JSON.parse(form.avatar_crop_json)
: null;
} catch (_) {}
setCropInitial(saved);
onRecropAvatar(avatarField, form, setCropFile);
}}
/>
) : null}
<IconButton
icon={
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
<path d="M6.7 6.7a1 1 0 0 1 1.4 0L12 10.58l3.9-3.88a1 1 0 1 1 1.4 1.42L13.42 12l3.88 3.9a1 1 0 1 1-1.42 1.4L12 13.42l-3.9 3.88a1 1 0 0 1-1.4-1.42L10.58 12 6.7 8.1a1 1 0 0 1 0-1.4Z" fill="currentColor" />
</svg>
}
tooltip="Сбросить аватар"
onClick={() => {
onChange(avatarField.key, "");
setAvatarPreviewOpen(false);
}}
disabled={!avatarValue}
/>
</div>
</>
) : null}
</> </>
) : null} )}
</div> </div>
<div className="record-user-summary"> <div className="record-user-summary">
<div className="record-user-summary-head"> <div className="record-user-summary-head">

View file

@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>Страница клиента • Правовой трекер</title> <title>Страница клиента • Правовой трекер</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=20260302-01"> <link rel="icon" type="image/svg+xml" href="/favicon.svg?v=20260302-01">
<link rel="stylesheet" href="/admin.css?v=20260317-01"> <link rel="stylesheet" href="/admin.css?v=20260317-01">

View file

@ -792,90 +792,143 @@
.featured-team-track { .featured-team-track {
display: grid; display: grid;
grid-auto-flow: column; grid-auto-flow: column;
grid-auto-columns: minmax(280px, 34%); grid-auto-columns: 220px;
gap: 0.85rem; gap: 1rem;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
scroll-snap-type: x proximity; scroll-snap-type: x mandatory;
scrollbar-width: thin; scrollbar-width: none;
padding: 0.15rem 0.1rem 0.4rem; padding: 0.5rem 0.25rem 1rem;
}
.featured-team-track::-webkit-scrollbar {
display: none;
} }
.featured-card { .featured-card {
scroll-snap-align: start; scroll-snap-align: start;
border: 1px solid var(--line); position: relative;
border-radius: 16px; border: 1px solid rgba(255,255,255,0.08);
background: linear-gradient(165deg, rgba(32, 43, 57, 0.95), rgba(17, 24, 32, 0.96)); border-radius: 20px;
display: grid; background: linear-gradient(175deg, rgba(30, 42, 58, 0.96), rgba(16, 23, 33, 0.98));
grid-template-columns: 150px 1fr; display: flex;
gap: 0.95rem; flex-direction: column;
padding: 0.85rem; align-items: center;
min-height: 188px; gap: 0;
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.18); padding: 1.4rem 1rem 1.2rem;
animation: rise 0.45s ease forwards; box-shadow: 0 8px 28px rgba(0, 0, 0, 0.28), 0 1px 0 rgba(255,255,255,0.04) inset;
animation: rise 0.42s ease forwards;
opacity: 0; opacity: 0;
transform: translateY(8px); transform: translateY(10px);
transition: box-shadow 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
cursor: default;
}
.featured-card:hover {
border-color: rgba(212, 169, 104, 0.25);
box-shadow: 0 14px 42px rgba(0, 0, 0, 0.38), 0 0 0 1px rgba(212, 169, 104, 0.12);
transform: translateY(-3px);
}
/* Avatar wrapper keeps the golden ring glow */
.featured-avatar-wrap {
position: relative;
margin-bottom: 0.9rem;
flex: 0 0 auto;
} }
.featured-avatar { .featured-avatar {
width: 146px; width: 96px;
height: 146px; height: 96px;
border-radius: 50%; border-radius: 50%;
object-fit: cover; object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.24); border: 2px solid rgba(212, 169, 104, 0.45);
box-shadow: 0 0 0 1px rgba(212, 169, 104, 0.42), 0 0 0 5px rgba(212, 169, 104, 0.08); box-shadow: 0 0 0 4px rgba(212, 169, 104, 0.1), 0 6px 18px rgba(0,0,0,0.35);
background: rgba(255, 255, 255, 0.04); background: rgba(30, 42, 58, 0.8);
align-self: center; display: block;
}
/* Initials placeholder when no avatar */
.featured-avatar-initials {
width: 96px;
height: 96px;
border-radius: 50%;
border: 2px solid rgba(212, 169, 104, 0.45);
box-shadow: 0 0 0 4px rgba(212, 169, 104, 0.1), 0 6px 18px rgba(0,0,0,0.35);
background: linear-gradient(135deg, #1e3558, #0f1e32);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.6rem;
font-weight: 700;
color: rgba(212, 169, 104, 0.85);
letter-spacing: 0.01em;
user-select: none;
}
.featured-chip {
position: absolute;
top: -6px;
right: -6px;
border-radius: 999px;
padding: 0.16rem 0.44rem;
border: 1px solid rgba(212, 169, 104, 0.4);
background: rgba(212, 169, 104, 0.14);
color: #f4d7a8;
font-size: 0.66rem;
font-weight: 700;
white-space: nowrap;
backdrop-filter: blur(4px);
} }
.featured-card-body { .featured-card-body {
min-width: 0; min-width: 0;
display: grid; width: 100%;
align-content: start; display: flex;
gap: 0.35rem; flex-direction: column;
align-items: center;
gap: 0.3rem;
text-align: center;
} }
.featured-card-top { .featured-card-top {
display: flex; display: flex;
align-items: flex-start; align-items: center;
justify-content: space-between; justify-content: center;
gap: 0.5rem;
} }
.featured-card-top h3 { .featured-card-top h3 {
margin: 0; margin: 0;
font-size: 1rem; font-size: 0.95rem;
line-height: 1.25; font-weight: 700;
line-height: 1.3;
color: #eef4ff; color: #eef4ff;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.featured-chip {
flex: 0 0 auto;
border-radius: 999px;
padding: 0.18rem 0.5rem;
border: 1px solid rgba(212, 169, 104, 0.35);
background: rgba(212, 169, 104, 0.12);
color: #f4d7a8;
font-size: 0.72rem;
font-weight: 700;
white-space: nowrap;
}
.featured-meta { .featured-meta {
margin: 0; margin: 0;
color: #a6b5c8; padding: 0.2rem 0.6rem;
font-size: 0.84rem; border-radius: 999px;
line-height: 1.35; background: rgba(37, 99, 235, 0.16);
border: 1px solid rgba(37, 99, 235, 0.28);
color: #93b4e8;
font-size: 0.76rem;
font-weight: 500;
line-height: 1.3;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.featured-caption { .featured-caption {
margin: 0.1rem 0 0; margin: 0.2rem 0 0;
color: #dde8f6; color: #8ea5be;
font-size: 0.9rem; font-size: 0.81rem;
line-height: 1.5; line-height: 1.55;
overflow-wrap: anywhere; overflow-wrap: anywhere;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
} }
.featured-caption > :first-child { .featured-caption > :first-child {
@ -887,7 +940,7 @@
} }
.featured-caption p { .featured-caption p {
margin: 0.14rem 0; margin: 0;
} }
.featured-caption ul { .featured-caption ul {
@ -979,7 +1032,7 @@
} }
.featured-team-track { .featured-team-track {
grid-auto-columns: minmax(300px, 52%); grid-auto-columns: 200px;
} }
} }
@ -1064,7 +1117,7 @@
} }
.featured-team-track { .featured-team-track {
grid-auto-columns: 86%; grid-auto-columns: 186px;
} }
.simple-list { .simple-list {
@ -1090,14 +1143,14 @@
top: calc(100% - 3px); top: calc(100% - 3px);
} }
.featured-card { .featured-team-track {
grid-template-columns: 114px 1fr; grid-auto-columns: 172px;
min-height: 156px;
} }
.featured-avatar { .featured-avatar,
width: 106px; .featured-avatar-initials {
height: 106px; width: 80px;
height: 80px;
} }
.hero { .hero {

View file

@ -3,9 +3,94 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Аудиторы корпоративной безопасности</title>
<meta name="description" content="Юридический консалтинг и судебное сопровождение для сложных бизнес-ситуаций."> <!-- Primary SEO -->
<title>Аудиторы корпоративной безопасности — юридический консалтинг и судебное сопровождение</title>
<meta name="description" content="Консалтинговая компания «Аудиторы корпоративной безопасности» — судебное сопровождение, банкротство, сделки, кадровые споры и GR. 28 лет опыта. Первая консультация онлайн.">
<meta name="keywords" content="юридический консалтинг, судебное сопровождение, банкротство бизнеса, корпоративная безопасность, защита активов, трудовые споры, арбитраж">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://ruakb.online/">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://ruakb.online/">
<meta property="og:title" content="Аудиторы корпоративной безопасности — юридический консалтинг">
<meta property="og:description" content="Судебное сопровождение, банкротство, сделки и GR-поддержка. 28 лет опыта. Первая консультация онлайн лично с директором.">
<meta property="og:image" content="https://ruakb.online/og-image.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:locale" content="ru_RU">
<meta property="og:site_name" content="Аудиторы корпоративной безопасности">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Аудиторы корпоративной безопасности">
<meta name="twitter:description" content="Юридический консалтинг и судебное сопровождение. 28 лет опыта. Первая консультация онлайн.">
<meta name="twitter:image" content="https://ruakb.online/og-image.jpg">
<!-- Geo / Local SEO -->
<meta name="geo.region" content="RU">
<meta name="geo.placename" content="Россия">
<!-- Structured Data: Organization + LegalService -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": "https://ruakb.online/#organization",
"name": "Аудиторы корпоративной безопасности",
"url": "https://ruakb.online/",
"logo": {
"@type": "ImageObject",
"url": "https://ruakb.online/brand-mark.svg"
},
"description": "Консалтинговая компания по юридическому сопровождению бизнеса: судебные споры, банкротство, сделки, трудовые конфликты, GR.",
"foundingDate": "1997",
"areaServed": "RU",
"contactPoint": {
"@type": "ContactPoint",
"contactType": "customer support",
"availableLanguage": "Russian"
}
},
{
"@type": "LegalService",
"@id": "https://ruakb.online/#legalservice",
"name": "Аудиторы корпоративной безопасности",
"url": "https://ruakb.online/",
"provider": { "@id": "https://ruakb.online/#organization" },
"serviceType": [
"Судебное сопровождение",
"Арбитраж",
"Банкротство и антикризис",
"Сделки и защита активов",
"Кадровые споры",
"GR-сопровождение"
],
"areaServed": {
"@type": "Country",
"name": "Россия"
}
},
{
"@type": "WebSite",
"@id": "https://ruakb.online/#website",
"url": "https://ruakb.online/",
"name": "Аудиторы корпоративной безопасности",
"inLanguage": "ru"
}
]
}
</script>
<!-- Performance hints -->
<link rel="preconnect" href="https://ruakb.online">
<link rel="dns-prefetch" href="https://ruakb.online">
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=20260302-01"> <link rel="icon" type="image/svg+xml" href="/favicon.svg?v=20260302-01">
<link rel="shortcut icon" href="/favicon.svg">
<link rel="stylesheet" href="/landing.css"> <link rel="stylesheet" href="/landing.css">
</head> </head>
<body> <body>
@ -185,7 +270,12 @@
</main> </main>
<footer> <footer>
ООО «Аудиторы корпоративной безопасности» • Юридический консалтинг и судебное сопровождение <address>
<strong>ООО «Аудиторы корпоративной безопасности»</strong>
— юридический консалтинг и судебное сопровождение
</address>
<small>© <span id="footer-year">2026</span> Все права защищены</small>
<script>document.getElementById('footer-year').textContent = new Date().getFullYear();</script>
</footer> </footer>
<div class="modal-backdrop" id="request-modal" aria-hidden="true"> <div class="modal-backdrop" id="request-modal" aria-hidden="true">

View file

@ -522,16 +522,42 @@
} }
featuredTeamTrack.innerHTML = ""; featuredTeamTrack.innerHTML = "";
items.forEach((item) => { items.forEach((item, idx) => {
const card = document.createElement("article"); const card = document.createElement("article");
card.className = "featured-card"; card.className = "featured-card";
card.style.animationDelay = (idx * 0.06) + "s";
const avatar = document.createElement("img"); // Avatar wrap (holds photo or initials + optional pinned chip)
avatar.className = "featured-avatar"; const avatarWrap = document.createElement("div");
avatar.src = String(item.avatar_url || ""); avatarWrap.className = "featured-avatar-wrap";
avatar.alt = String(item.name || "Сотрудник");
avatar.loading = "lazy"; const rawAvatarUrl = String(item.avatar_url || "").trim();
card.appendChild(avatar); if (rawAvatarUrl) {
const avatar = document.createElement("img");
avatar.className = "featured-avatar";
avatar.src = rawAvatarUrl;
avatar.alt = String(item.name || "Сотрудник");
avatar.loading = "lazy";
avatarWrap.appendChild(avatar);
} else {
const initBox = document.createElement("div");
initBox.className = "featured-avatar-initials";
const nameParts = String(item.name || "").trim().split(/\s+/);
const initials = nameParts.length >= 2
? (nameParts[0][0] + nameParts[1][0]).toUpperCase()
: (nameParts[0] || "?")[0].toUpperCase();
initBox.textContent = initials;
avatarWrap.appendChild(initBox);
}
if (item.pinned) {
const chip = document.createElement("span");
chip.className = "featured-chip";
chip.textContent = "Рекомендуем";
avatarWrap.appendChild(chip);
}
card.appendChild(avatarWrap);
const body = document.createElement("div"); const body = document.createElement("div");
body.className = "featured-card-body"; body.className = "featured-card-body";
@ -541,12 +567,6 @@
const name = document.createElement("h3"); const name = document.createElement("h3");
name.textContent = String(item.name || "Сотрудник"); name.textContent = String(item.name || "Сотрудник");
top.appendChild(name); top.appendChild(name);
if (item.pinned) {
const chip = document.createElement("span");
chip.className = "featured-chip";
chip.textContent = "Рекомендуем";
top.appendChild(chip);
}
body.appendChild(top); body.appendChild(top);
const metaText = String(item.primary_topic_name || "").trim(); const metaText = String(item.primary_topic_name || "").trim();
@ -557,11 +577,13 @@
body.appendChild(meta); body.appendChild(meta);
} }
const caption = document.createElement("div"); const captionText = String(item.caption || "").trim();
caption.className = "featured-caption"; if (captionText) {
const captionText = String(item.caption || "").trim() || "Практический опыт в сложных юридических делах и сопровождении споров."; const caption = document.createElement("div");
caption.innerHTML = markdownToHtml(captionText); caption.className = "featured-caption";
body.appendChild(caption); caption.innerHTML = markdownToHtml(captionText);
body.appendChild(caption);
}
card.appendChild(body); card.appendChild(body);
featuredTeamTrack.appendChild(card); featuredTeamTrack.appendChild(card);

BIN
app/web/og-image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

8
app/web/robots.txt Normal file
View file

@ -0,0 +1,8 @@
User-agent: *
Allow: /
Disallow: /admin.html
Disallow: /admin
Disallow: /cabinet
Disallow: /api/
Sitemap: https://ruakb.online/sitemap.xml

15
app/web/sitemap.xml Normal file
View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://ruakb.online/</loc>
<lastmod>2026-04-06</lastmod>
<changefreq>monthly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://ruakb.online/privacy.html</loc>
<lastmod>2026-04-06</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
</urlset>

View file

@ -246,6 +246,18 @@ async function createRequestViaLanding(page, options = {}) {
throw new Error("Не найдена доступная тема для E2E-создания заявки"); throw new Error("Не найдена доступная тема для E2E-создания заявки");
} }
// Ensure the CREATE_REQUEST cookie is active before posting.
// A prior createRequestViaLanding call may have replaced it with VIEW_REQUEST.
await page.context().addCookies([
{
name: PUBLIC_COOKIE_NAME,
value: createPublicCookieToken(phone),
url: `${baseUrl}/`,
httpOnly: true,
sameSite: "Lax",
},
]);
const createResponse = await page.request.post(`${baseUrl}/api/public/requests`, { const createResponse = await page.request.post(`${baseUrl}/api/public/requests`, {
headers: { headers: {
Origin: baseUrl, Origin: baseUrl,

View file

@ -0,0 +1,414 @@
/**
* SHOWCASE: Полный флоу администратора
*
* Покрывает:
* 1. Настройка справочников группы статусов, статусы, темы, цитаты
* 2. Добавление юриста с темой, ставкой, аватаром
* 3. Карусель лендинга добавить юриста в featured-staff
* 4. Назначение юриста новой заявке
* 5. Назначение юриста действующей заявке (переназначение)
* 6. Смена статуса заявки через список заявок
* 7. Ответ администратора в чате заявки
* 8. Дашборд фильтр по юристу, по теме
* 9. Канбан фильтр + смена статуса из карточки
* 10. Выставление счёта и подтверждение оплаты
* 11. Запросы на обслуживание (ServiceRequests) просмотр и решение
*/
const { test, expect } = require("@playwright/test");
const {
randomPhone,
createRequestViaLanding,
loginAdminPanel,
openRequestsSection,
openDictionaryTree,
selectDictionaryNode,
selectDropdownOption,
selectFirstDropdownOption,
rowByTrack,
trackCleanupPhone,
trackCleanupTrack,
trackCleanupEmail,
cleanupTrackedTestData,
preparePublicSession,
} = require("./helpers");
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || "admin123";
test.afterEach(async ({ page }, testInfo) => {
await cleanupTrackedTestData(page, testInfo);
});
// ─────────────────────────────────────────────────────────────────────────────
// 1. Настройка справочников
// ─────────────────────────────────────────────────────────────────────────────
test("showcase-admin-1: справочники — группы статусов, статусы, темы", async ({ context, page }, testInfo) => {
const unique = `sc${Date.now()}`;
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
await openDictionaryTree(page);
// --- Группы статусов ---
await selectDictionaryNode(page, "Группы статусов");
await page.locator("#section-config .section-head").getByRole("button", { name: "Добавить" }).click();
await expect(page.getByRole("heading", { name: /Создание/ })).toBeVisible();
await page.locator("#record-field-name").fill(`Тестовая группа ${unique}`);
await page.locator("#record-field-sort_order").fill("99");
await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
await expect(page.locator("#section-config .status").first()).toContainText("Список обновлен");
await expect(page.locator("#section-config table")).toContainText(`Тестовая группа ${unique}`);
// --- Статусы ---
await selectDictionaryNode(page, "Статусы");
await page.locator("#section-config .section-head").getByRole("button", { name: "Добавить" }).click();
await page.locator("#record-field-code").fill(`SC_TEST_${unique.toUpperCase()}`);
await page.locator("#record-field-name").fill(`Тестовый статус ${unique}`);
await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
await expect(page.locator("#section-config .status").first()).toContainText("Список обновлен");
// --- Темы ---
await selectDictionaryNode(page, "Темы");
await page.locator("#section-config .section-head").getByRole("button", { name: "Добавить" }).click();
await page.locator("#record-field-name").fill(`Тема Showcase ${unique}`);
await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
await expect(page.locator("#section-config .status").first()).toContainText("Список обновлен");
await expect(page.locator("#section-config table")).toContainText(`Тема Showcase ${unique}`);
});
// ─────────────────────────────────────────────────────────────────────────────
// 2. Добавление юриста и настройка профиля
// ─────────────────────────────────────────────────────────────────────────────
test("showcase-admin-2: создание юриста — профиль, тема, ставка", async ({ context, page }, testInfo) => {
const unique = Date.now();
const lawyerEmail = `sc-lawyer-${unique}@example.com`;
trackCleanupEmail(testInfo, lawyerEmail);
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
await openDictionaryTree(page);
await selectDictionaryNode(page, "Пользователи");
await page.locator("#section-config .section-head").getByRole("button", { name: "Добавить" }).click();
await expect(page.getByRole("heading", { name: /Создание/ })).toBeVisible();
await page.locator("#record-field-name").fill(`Юрист Showcase ${unique}`);
await page.locator("#record-field-email").fill(lawyerEmail);
await page.locator("#record-field-phone").fill(`+7900${String(unique).slice(-7)}`);
await selectDropdownOption(page, "#record-field-role", "Юрист");
await selectFirstDropdownOption(page, "#record-field-primary_topic_code");
await page.locator("#record-field-default_rate").fill("7500");
await page.locator("#record-field-salary_percent").fill("30");
await page.locator("#record-field-password").fill("ShowcasePass-1!");
await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
await expect(page.locator("#section-config .status").first()).toContainText("Список обновлен");
await expect(page.locator("#section-config table")).toContainText(lawyerEmail);
// Открыть профиль юриста — убедиться что данные сохранились
// В таблице пользователей нет отдельной кнопки "Редактировать" — редактирование
// открывается кликом по имени пользователя (button.user-identity-link).
const lawyerRow = page.locator("#section-config table tbody tr").filter({ hasText: lawyerEmail }).first();
await lawyerRow.locator("button.user-identity-link").click();
await expect(page.getByRole("heading", { name: /Редактирование/ })).toBeVisible();
await expect(page.locator(".record-user-summary-value").first()).not.toBeEmpty();
// Используем .first() — в пользовательском модале может быть 2 кнопки .close
// (основная и кнопка закрытия превью аватара)
await page.locator("#record-overlay .close").first().click();
});
// ─────────────────────────────────────────────────────────────────────────────
// 3. Цитаты лендинга
// ─────────────────────────────────────────────────────────────────────────────
test("showcase-admin-3: добавить цитату на лендинг", async ({ page }, testInfo) => {
const unique = Date.now();
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
await openDictionaryTree(page);
await selectDictionaryNode(page, "Цитаты");
await page.locator("#section-config .section-head").getByRole("button", { name: "Добавить" }).click();
await page.locator("#record-field-author").fill(`Иван Тестов ${unique}`);
await page.locator("#record-field-text").fill("Профессиональное сопровождение — ключ к успеху.");
await page.locator("#record-field-source").fill("showcase-test");
await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
await expect(page.locator("#section-config .status").first()).toContainText("Список обновлен");
await expect(page.locator("#section-config table")).toContainText(`Иван Тестов ${unique}`);
});
// ─────────────────────────────────────────────────────────────────────────────
// 4. Карусель юристов лендинга
// ─────────────────────────────────────────────────────────────────────────────
test("showcase-admin-4: добавить юриста в карусель лендинга", async ({ page }, testInfo) => {
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
await openDictionaryTree(page);
await selectDictionaryNode(page, "Карусель сотрудников лендинга");
await page.locator("#section-config .section-head").getByRole("button", { name: "Добавить" }).click();
await expect(page.getByRole("heading", { name: /Создание/ })).toBeVisible();
// Выбрать любого существующего юриста
const lawyerLabel = await selectFirstDropdownOption(page, "#record-field-admin_user_id");
expect(lawyerLabel).not.toBe("");
await page.locator("#record-field-caption").fill("Опытный специалист в области гражданского права.");
await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
// Ждём пока запись реально появится в таблице — гарантирует, что сохранение прошло успешно
// (статус "Список обновлен" может быть устаревшим от предыдущей загрузки)
await expect(page.locator("#section-config table")).toContainText("Опытный специалист", { timeout: 15_000 });
// Проверить что карточка появилась на лендинге
// Секция скрыта по умолчанию — JS загружает данные асинхронно после загрузки страницы.
await page.goto("/");
await page.waitForLoadState("networkidle");
const featuredSection = page.locator(".featured-team-section");
if (await featuredSection.count()) {
await expect(featuredSection).not.toBeHidden({ timeout: 20_000 });
}
});
// ─────────────────────────────────────────────────────────────────────────────
// 5. Назначение юриста новой заявке
// ─────────────────────────────────────────────────────────────────────────────
test("showcase-admin-5: назначить юриста новой заявке", async ({ context, page }, testInfo) => {
const phone = randomPhone();
trackCleanupPhone(testInfo, phone);
await preparePublicSession(context, page, process.env.E2E_BASE_URL || "http://localhost:8081", phone);
const { trackNumber } = await createRequestViaLanding(page, {
phone,
description: "Showcase: назначение юриста администратором",
});
trackCleanupTrack(testInfo, trackNumber);
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
await openRequestsSection(page);
const row = rowByTrack(page, "#section-requests", trackNumber);
await expect(row).toHaveCount(1);
// Открыть карточку заявки
await row.first().locator(".request-track-link").click();
await expect(page.getByRole("heading", { name: /Карточка заявки/ })).toBeVisible();
// Назначить юриста
const lawyerSelect = page.locator("[data-field='assigned_lawyer_id']").first();
if (await lawyerSelect.count()) {
await selectFirstDropdownOption(page, lawyerSelect);
await page.getByRole("button", { name: /Сохранить|Назначить/ }).first().click();
await expect(page.locator("#section-request-workspace .status, #request-detail-status").first()).toContainText(/сохран|назначен|обновлен/i);
}
// Проверить что в списке теперь отображается юрист
await page.getByRole("button", { name: "Назад" }).click();
await expect(row.first()).toContainText(/.+/); // строка обновилась
});
// ─────────────────────────────────────────────────────────────────────────────
// 6. Смена статуса заявки + ответ администратора в чате
// ─────────────────────────────────────────────────────────────────────────────
test("showcase-admin-6: смена статуса и ответ в чате заявки", async ({ context, page }, testInfo) => {
const phone = randomPhone();
trackCleanupPhone(testInfo, phone);
await preparePublicSession(context, page, process.env.E2E_BASE_URL || "http://localhost:8081", phone);
const { trackNumber } = await createRequestViaLanding(page, {
phone,
description: "Showcase: смена статуса и чат администратора",
});
trackCleanupTrack(testInfo, trackNumber);
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
await openRequestsSection(page);
const row = rowByTrack(page, "#section-requests", trackNumber);
await row.first().locator(".request-track-link").click();
await expect(page.getByRole("heading", { name: /Карточка заявки/ })).toBeVisible();
// Сменить статус
const statusSelect = page.locator("#request-status-select, [data-field='status_code']").first();
if (await statusSelect.count()) {
const newStatus = await selectFirstDropdownOption(page, statusSelect);
await page.getByRole("button", { name: /Сохранить|Применить/ }).first().click();
await expect(page.locator("#section-request-workspace .status").first()).toContainText(/обновлен|сохранен|изменен/i);
}
// Написать ответ в чате
const adminReply = `Администратор отвечает. ${Date.now()}`;
await page.getByRole("tab", { name: /Чат/ }).click();
await page.locator("#request-modal-message-body").fill(adminReply);
await page.locator("#request-modal-message-send").click();
await expect(page.locator("#section-request-workspace .status").first()).toContainText("Сообщение отправлено");
await expect(page.locator("#request-modal-messages")).toContainText(adminReply);
});
// ─────────────────────────────────────────────────────────────────────────────
// 7. Дашборд — фильтрация и KPI
// ─────────────────────────────────────────────────────────────────────────────
test("showcase-admin-7: дашборд — метрики и виджеты загрузки", async ({ page }, testInfo) => {
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
// Дашборд открывается по умолчанию
await expect(page.locator("#section-dashboard h2")).toHaveText("Обзор метрик");
// Основные блоки присутствуют
await expect(page.locator("#section-dashboard")).toContainText("Загрузка юристов");
await expect(page.locator("#section-dashboard")).toContainText(/Новых|Активных|Всего|Заявок/);
// Счётчики ненулевые (или хотя бы рендерятся)
// Дашборд использует класс .card для виджетов (не .dash-tile / .kpi-value)
const kpiTiles = page.locator("#section-dashboard .card");
await expect(kpiTiles.first()).toBeVisible();
});
// ─────────────────────────────────────────────────────────────────────────────
// 8. Канбан — фильтрация, смена статуса из карточки
// ─────────────────────────────────────────────────────────────────────────────
test("showcase-admin-8: канбан — фильтр и смена статуса карточки", async ({ context, page }, testInfo) => {
const phone = randomPhone();
trackCleanupPhone(testInfo, phone);
await preparePublicSession(context, page, process.env.E2E_BASE_URL || "http://localhost:8081", phone);
const { trackNumber, name } = await createRequestViaLanding(page, {
phone,
description: "Showcase: канбан администратора",
});
trackCleanupTrack(testInfo, trackNumber);
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
// Перейти в Канбан
await page.locator("aside .menu button[data-section='kanban']").click();
await expect(page.locator("#section-kanban h2")).toHaveText("Канбан заявок");
// Применить фильтр по имени клиента
await page.locator("#section-kanban .section-head-actions").getByRole("button", { name: "Фильтр" }).click();
await selectDropdownOption(page, "#filter-field", "Клиент");
await page.locator("#filter-value").fill(name);
await page.locator("#filter-overlay").getByRole("button", { name: /Добавить|Сохранить/i }).click();
await expect(page.locator("#section-kanban .filter-chip")).toHaveCount(1);
// Карточка должна быть видна
const card = page.locator("#section-kanban .kanban-card").filter({ hasText: trackNumber }).first();
await expect(card).toBeVisible();
// Убедиться что колонки имеют читаемые названия (не UUID)
const columnHeaders = page.locator("#section-kanban .kanban-column-head b");
const count = await columnHeaders.count();
for (let i = 0; i < count; i++) {
const text = await columnHeaders.nth(i).textContent();
// UUID-паттерн: 8-4-4-4-12 hex
expect(text).not.toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
}
// Смена статуса через dropdown «Перевести…»
const transitionSelect = card.locator(".kanban-transition-select");
if (await transitionSelect.count()) {
const newStatus = await selectFirstDropdownOption(page, transitionSelect);
await expect(page.locator("#section-kanban .status")).toContainText(/обновлен|переведен|Статус/i);
}
});
// ─────────────────────────────────────────────────────────────────────────────
// 9. Счёт — выставить и подтвердить оплату
// ─────────────────────────────────────────────────────────────────────────────
test("showcase-admin-9: выставить счёт и подтвердить оплату", async ({ context, page }, testInfo) => {
const phone = randomPhone();
trackCleanupPhone(testInfo, phone);
await preparePublicSession(context, page, process.env.E2E_BASE_URL || "http://localhost:8081", phone);
const { trackNumber } = await createRequestViaLanding(page, {
phone,
description: "Showcase: выставление счёта и оплата",
});
trackCleanupTrack(testInfo, trackNumber);
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
// Перейти в раздел Счета
await page.locator("aside .menu button[data-section='invoices']").click();
await expect(page.locator("#section-invoices h2")).toHaveText("Счета");
// Создать счёт
await page.locator("#section-invoices .section-head").getByRole("button", { name: "Добавить" }).click();
await expect(page.getByRole("heading", { name: /Создание/ })).toBeVisible();
await selectDropdownOption(page, "#record-field-request_track_number", trackNumber);
// После выбора заявки форма авто-подставляет плательщика — нужно его выбрать
await selectFirstDropdownOption(page, "#record-field-payer_display_name");
await page.locator("#record-field-amount").fill("25000");
await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
await expect(page.locator("#section-invoices .status")).toContainText("Список обновлен");
const invoiceRow = rowByTrack(page, "#section-invoices", trackNumber);
await expect(invoiceRow).toHaveCount(1);
await expect(invoiceRow.first()).toContainText("25000");
// Подтвердить оплату
await invoiceRow.first().getByRole("button", { name: "Редактировать счет" }).click();
await expect(page.getByRole("heading", { name: /Редактирование/ })).toBeVisible();
await selectDropdownOption(page, "#record-field-status", "Оплачен");
await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
await expect(page.locator("#section-invoices .status")).toContainText("Список обновлен");
await expect(invoiceRow.first()).toContainText(/Оплачен/);
});
// ─────────────────────────────────────────────────────────────────────────────
// 10. Запросы на обслуживание — просмотр и разрешение
// ─────────────────────────────────────────────────────────────────────────────
test("showcase-admin-10: сервисные запросы — обращения клиента к куратору", async ({ context, page }, testInfo) => {
const phone = randomPhone();
trackCleanupPhone(testInfo, phone);
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
await preparePublicSession(context, page, appUrl, phone);
const createResp = await page.request.post(`${appUrl}/api/public/requests`, {
data: {
client_name: `Showcase Client ${Date.now()}`,
client_phone: phone,
topic_code: "consulting",
description: "Showcase: тест сервисных запросов.",
pdn_consent: true,
},
failOnStatusCode: false,
});
const body = await createResp.json().catch(() => ({}));
const trackNumber = String(body.track_number || "");
if (!trackNumber) return; // skip if no topics configured
trackCleanupTrack(testInfo, trackNumber);
// Открыть кабинет и отправить обращение к куратору
await page.goto(`/client.html?track=${encodeURIComponent(trackNumber)}`);
await expect(page.locator("#cabinet-summary")).toBeVisible();
const helpBtn = page.locator("#cabinet-help-open");
if (await helpBtn.count()) {
await helpBtn.click();
await expect(page.locator("#client-help-overlay")).toBeVisible();
// Кнопка для связи с куратором (без textarea) — "Обратиться к куратору"
const curatorBtn = page.locator("#cabinet-curator-request-open");
const lawyerChangeBtn = page.locator("#cabinet-lawyer-change-open");
if (await curatorBtn.count() && !(await curatorBtn.isDisabled())) {
await curatorBtn.click();
} else if (await lawyerChangeBtn.count() && !(await lawyerChangeBtn.isDisabled())) {
// Если куратор заблокирован — запрос смены юриста (с textarea)
await page.locator("#service-request-body").fill("Прошу сменить юриста — нет обратной связи.");
await lawyerChangeBtn.click();
}
// Успех — оверлей закрылся или статус обновился
await expect(page.locator("#client-help-overlay, #cabinet-status")).toBeVisible({ timeout: 10_000 });
}
// Администратор видит обращение
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
await page.locator("aside .menu button[data-section='serviceRequests']").click();
await expect(page.locator("#section-service-requests h2")).toBeVisible();
const srRow = page.locator("#section-service-requests table tbody tr").filter({ hasText: trackNumber }).first();
if (await srRow.count()) {
await expect(srRow).toBeVisible();
// Разрешить запрос
const resolveBtn = srRow.getByRole("button", { name: /Решить|Закрыть|Resolve/i });
if (await resolveBtn.count()) {
await resolveBtn.click();
await expect(page.locator("#section-service-requests .status")).toContainText(/обновлен|решен/i);
}
}
});

View file

@ -0,0 +1,410 @@
/**
* SHOWCASE: Полный флоу клиента (публичный кабинет)
*
* Покрывает:
* 1. Регистрация оставить заявку через лендинг
* 2. Открыть кабинет, убедиться в наличии статуса
* 3. Написать сообщение юристу
* 4. Прикрепить файл (PDF) к сообщению
* 5. Скачать/просмотреть свой файл
* 6. Увидеть ответ юриста (после назначения и ответа)
* 7. Оставить вторую заявку
* 8. Заполнить запрошенные данные (поля DataRequirement)
* 9. Отправить обращение к куратору / администратору
* 10. Запросить смену юриста
* 11. Убедиться в смене юриста (имя юриста обновилось)
* 12. Наблюдать смену статуса заявки в реальном времени
*/
const { test, expect } = require("@playwright/test");
const {
randomPhone,
createRequestViaLanding,
openPublicCabinet,
sendCabinetMessage,
uploadCabinetFile,
loginAdminPanel,
openRequestsSection,
rowByTrack,
selectDropdownOption,
selectFirstDropdownOption,
trackCleanupPhone,
trackCleanupTrack,
cleanupTrackedTestData,
preparePublicSession,
buildTinyPdfBuffer,
} = require("./helpers");
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || "admin123";
const LAWYER_EMAIL = process.env.E2E_LAWYER_EMAIL || "ivan@mail.ru";
const LAWYER_PASSWORD = process.env.E2E_LAWYER_PASSWORD || "LawyerPass-123!";
test.afterEach(async ({ page }, testInfo) => {
await cleanupTrackedTestData(page, testInfo);
});
// ─────────────────────────────────────────────────────────────────────────────
// 1. Оставить заявку и открыть кабинет
// ─────────────────────────────────────────────────────────────────────────────
test("showcase-client-1: оставить заявку через лендинг и открыть кабинет", async ({ context, page }, testInfo) => {
const phone = randomPhone();
trackCleanupPhone(testInfo, phone);
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
await preparePublicSession(context, page, appUrl, phone);
const { trackNumber } = await createRequestViaLanding(page, {
phone,
name: `Клиент Showcase ${Date.now()}`,
description: "Проблема с нарушением прав потребителя — showcase.",
});
trackCleanupTrack(testInfo, trackNumber);
// Открыть кабинет — статус заявки виден
await openPublicCabinet(page, trackNumber);
await expect(page.locator("#cabinet-summary")).toBeVisible();
await expect(page.locator("#cabinet-request-status")).not.toHaveText("-");
// Трек-номер присутствует в URL кабинета
expect(page.url()).toContain(encodeURIComponent(trackNumber));
});
// ─────────────────────────────────────────────────────────────────────────────
// 2. Написать сообщение и прикрепить PDF
// ─────────────────────────────────────────────────────────────────────────────
test("showcase-client-2: написать сообщение и загрузить PDF", async ({ context, page }, testInfo) => {
const phone = randomPhone();
trackCleanupPhone(testInfo, phone);
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
await preparePublicSession(context, page, appUrl, phone);
const { trackNumber } = await createRequestViaLanding(page, {
phone,
description: "Showcase: сообщение и файл клиента",
});
trackCleanupTrack(testInfo, trackNumber);
await openPublicCabinet(page, trackNumber);
// Текстовое сообщение
const clientMsg = `Уважаемый юрист, ${Date.now()} — прошу помочь с ситуацией.`;
await sendCabinetMessage(page, clientMsg);
await expect(page.locator("#cabinet-messages")).toContainText(clientMsg);
// PDF-файл
const fileName = `client-doc-${Date.now()}.pdf`;
await uploadCabinetFile(page, fileName, "Showcase client document");
// Перейти на вкладку Файлы — убедиться что файл там
const filesTab = page.getByRole("tab", { name: /Файлы/ });
if (await filesTab.count()) {
await filesTab.click();
await expect(page.locator("#cabinet-files")).toContainText(fileName);
}
});
// ─────────────────────────────────────────────────────────────────────────────
// 3. Предпросмотр своего файла
// ─────────────────────────────────────────────────────────────────────────────
test("showcase-client-3: предпросмотр загруженного файла", async ({ context, page }, testInfo) => {
const phone = randomPhone();
trackCleanupPhone(testInfo, phone);
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
await preparePublicSession(context, page, appUrl, phone);
const { trackNumber } = await createRequestViaLanding(page, {
phone,
description: "Showcase: предпросмотр файла клиентом",
});
trackCleanupTrack(testInfo, trackNumber);
await openPublicCabinet(page, trackNumber);
const fileName = `preview-${Date.now()}.txt`;
await uploadCabinetFile(page, fileName, "ShowcaseFileContent");
const filesTab = page.getByRole("tab", { name: /Файлы/ });
if (await filesTab.count()) {
await filesTab.click();
}
const fileRow = page.locator("#cabinet-files li").filter({ hasText: fileName }).first();
await expect(fileRow).toBeVisible();
await fileRow.getByRole("button", { name: "Предпросмотр" }).click();
await expect(page.locator("#file-preview-overlay, .file-preview-overlay")).toBeVisible();
await expect(page.locator("#file-preview-body, .file-preview-body")).toContainText("ShowcaseFileContent");
await page.locator("#file-preview-close, .file-preview-close, .close").first().click();
await expect(page.locator("#file-preview-overlay, .file-preview-overlay")).not.toBeVisible();
});
// ─────────────────────────────────────────────────────────────────────────────
// 4. Увидеть ответ юриста в кабинете
// ─────────────────────────────────────────────────────────────────────────────
test("showcase-client-4: клиент видит ответ юриста в чате кабинета", async ({ context, page }, testInfo) => {
const phone = randomPhone();
trackCleanupPhone(testInfo, phone);
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
await preparePublicSession(context, page, appUrl, phone);
const { trackNumber } = await createRequestViaLanding(page, {
phone,
description: "Showcase: клиент видит ответ юриста",
});
trackCleanupTrack(testInfo, trackNumber);
await openPublicCabinet(page, trackNumber);
await sendCabinetMessage(page, `Клиент: ${Date.now()}`);
// Юрист берёт заявку и отвечает
await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
await openRequestsSection(page);
const row = rowByTrack(page, "#section-requests", trackNumber);
await expect(row).toHaveCount(1);
const claimBtn = row.first().getByRole("button", { name: "Взять в работу" });
if (await claimBtn.count()) {
await claimBtn.click();
await expect(page.locator("#section-requests .status")).toContainText(/работу|обновлен/i);
}
await row.first().locator(".request-track-link").click();
await expect(page.getByRole("heading", { name: /Карточка заявки/ })).toBeVisible();
const lawyerReply = `Юрист отвечает: ${Date.now()}`;
await page.getByRole("tab", { name: /Чат/ }).click();
await page.locator("#request-modal-message-body").fill(lawyerReply);
await page.locator("#request-modal-message-send").click();
await expect(page.locator("#section-request-workspace .status")).toContainText("Сообщение отправлено");
// Клиент открывает кабинет заново — видит ответ
await page.goto(`/client.html?track=${encodeURIComponent(trackNumber)}`);
await expect(page.locator("#cabinet-summary")).toBeVisible();
await expect(page.locator("#cabinet-messages")).toContainText(lawyerReply);
});
// ─────────────────────────────────────────────────────────────────────────────
// 5. Оставить вторую заявку (с того же телефона / email)
// ─────────────────────────────────────────────────────────────────────────────
test("showcase-client-5: оставить вторую заявку с того же аккаунта", async ({ context, page }, testInfo) => {
const phone = randomPhone();
trackCleanupPhone(testInfo, phone);
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
await preparePublicSession(context, page, appUrl, phone);
const { trackNumber: track1 } = await createRequestViaLanding(page, {
phone,
description: "Первая заявка от клиента Showcase",
});
trackCleanupTrack(testInfo, track1);
const { trackNumber: track2 } = await createRequestViaLanding(page, {
phone,
description: "Вторая заявка от того же клиента",
});
trackCleanupTrack(testInfo, track2);
expect(track1).not.toBe(track2);
// Обе заявки открываются в кабинете
await openPublicCabinet(page, track1);
await expect(page.locator("#cabinet-summary")).toBeVisible();
await page.goto(`/client.html?track=${encodeURIComponent(track2)}`);
await expect(page.locator("#cabinet-summary")).toBeVisible();
expect(page.url()).toContain(encodeURIComponent(track2));
});
// ─────────────────────────────────────────────────────────────────────────────
// 6. Заполнить запрошенные данные (DataRequirement fields)
// ─────────────────────────────────────────────────────────────────────────────
test("showcase-client-6: заполнить запрошенные данные (DataRequirement)", async ({ context, page }, testInfo) => {
const phone = randomPhone();
trackCleanupPhone(testInfo, phone);
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
await preparePublicSession(context, page, appUrl, phone);
const { trackNumber } = await createRequestViaLanding(page, {
phone,
description: "Showcase: клиент заполняет запрошенные данные",
});
trackCleanupTrack(testInfo, trackNumber);
// Администратор / юрист создаёт DataRequirement
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
await openRequestsSection(page);
const row = rowByTrack(page, "#section-requests", trackNumber);
await row.first().locator(".request-track-link").click();
await expect(page.getByRole("heading", { name: /Карточка заявки/ })).toBeVisible();
const dataTab = page.getByRole("tab", { name: /Данные|Сбор|Запрос/i });
if (await dataTab.count()) {
await dataTab.click();
const addBtn = page.getByRole("button", { name: /Добавить|Запросить/i });
if (await addBtn.count()) {
await addBtn.click();
const labelField = page.locator("input[placeholder*='Название'], #data-req-label, [name='label']").first();
if (await labelField.count()) {
await labelField.fill("ФИО полностью");
await page.getByRole("button", { name: /Сохранить|Добавить/i }).first().click();
await expect(page.locator(".status, #section-request-workspace .status").first()).toContainText(/сохранен|добавлен/i);
}
}
}
// Клиент видит запрос в кабинете и заполняет его
await page.goto(`/client.html?track=${encodeURIComponent(trackNumber)}`);
await expect(page.locator("#cabinet-summary")).toBeVisible();
const dataSection = page.locator("#cabinet-data-requirements, .cabinet-data-section, [data-section='data']");
if (await dataSection.count()) {
const dataField = dataSection.locator("input, textarea").first();
if (await dataField.count()) {
await dataField.fill("Иванов Иван Иванович");
await dataSection.getByRole("button", { name: /Отправить|Сохранить|Подтвердить/i }).first().click();
await expect(page.locator("#client-page-status, #cabinet-status")).toContainText(/отправлен|сохранен|принят/i);
}
}
});
// ─────────────────────────────────────────────────────────────────────────────
// 7. Отправить обращение к куратору (ServiceRequest)
// ─────────────────────────────────────────────────────────────────────────────
test("showcase-client-7: отправить обращение к куратору", async ({ context, page }, testInfo) => {
const phone = randomPhone();
trackCleanupPhone(testInfo, phone);
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
await preparePublicSession(context, page, appUrl, phone);
const createResp = await page.request.post(`${appUrl}/api/public/requests`, {
data: {
client_name: `Showcase ${Date.now()}`,
client_phone: phone,
topic_code: "consulting",
description: "Showcase: обращение к куратору.",
pdn_consent: true,
},
failOnStatusCode: false,
});
const body = await createResp.json().catch(() => ({}));
const trackNumber = String(body.track_number || "");
if (!trackNumber) return;
trackCleanupTrack(testInfo, trackNumber);
await page.goto(`/client.html?track=${encodeURIComponent(trackNumber)}`);
await expect(page.locator("#cabinet-summary")).toBeVisible();
const helpBtn = page.locator("#cabinet-help-open");
if (await helpBtn.count()) {
await helpBtn.click();
await expect(page.locator("#client-help-overlay")).toBeVisible();
// Кнопка для куратора — без textarea; если заблокирована, пробуем смену юриста
const curatorBtn = page.locator("#cabinet-curator-request-open");
if (await curatorBtn.count() && !(await curatorBtn.isDisabled())) {
await curatorBtn.click();
} else {
await page.locator("#service-request-body").fill("Прошу уточнить сроки рассмотрения заявки.");
await page.locator("#cabinet-lawyer-change-open").click();
}
}
});
// ─────────────────────────────────────────────────────────────────────────────
// 8. Запросить смену юриста и увидеть нового юриста
// ─────────────────────────────────────────────────────────────────────────────
test("showcase-client-8: запросить смену юриста и увидеть нового в кабинете", async ({ context, page }, testInfo) => {
const phone = randomPhone();
trackCleanupPhone(testInfo, phone);
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
await preparePublicSession(context, page, appUrl, phone);
const { trackNumber } = await createRequestViaLanding(page, {
phone,
description: "Showcase: смена юриста",
});
trackCleanupTrack(testInfo, trackNumber);
// Клиент отправляет обращение о смене юриста
await page.goto(`/client.html?track=${encodeURIComponent(trackNumber)}`);
await expect(page.locator("#cabinet-summary")).toBeVisible();
const helpBtn = page.locator("#cabinet-help-open");
if (await helpBtn.count()) {
await helpBtn.click();
await expect(page.locator("#client-help-overlay")).toBeVisible();
// Запрос смены юриста: textarea + кнопка "Запросить смену"
const lawyerChangeBtn = page.locator("#cabinet-lawyer-change-open");
if (await lawyerChangeBtn.count() && !(await lawyerChangeBtn.isDisabled())) {
await page.locator("#service-request-body").fill("Прошу назначить другого юриста.");
await lawyerChangeBtn.click();
}
}
// Администратор берёт и переназначает юриста
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
await openRequestsSection(page);
const row = rowByTrack(page, "#section-requests", trackNumber);
await row.first().locator(".request-track-link").click();
await expect(page.getByRole("heading", { name: /Карточка заявки/ })).toBeVisible();
// Назначить нового юриста
const lawyerField = page.locator("[data-field='assigned_lawyer_id'], #request-lawyer-select").first();
if (await lawyerField.count()) {
const firstLawyerLabel = await selectFirstDropdownOption(page, lawyerField);
const saveBtn = page.getByRole("button", { name: /Сохранить|Назначить/i }).first();
if (await saveBtn.count()) {
await saveBtn.click();
await expect(page.locator("#section-request-workspace .status").first()).toContainText(/сохранен|назначен|обновлен/i);
}
// Клиент обновляет кабинет и видит юриста
await page.goto(`/client.html?track=${encodeURIComponent(trackNumber)}`);
await expect(page.locator("#cabinet-summary")).toBeVisible();
const lawyerDisplay = page.locator("#cabinet-assigned-lawyer, .cabinet-lawyer-name, [data-field='lawyer']");
if (await lawyerDisplay.count()) {
await expect(lawyerDisplay.first()).not.toHaveText("-");
}
}
});
// ─────────────────────────────────────────────────────────────────────────────
// 9. Клиент наблюдает смену статуса (юрист меняет → клиент видит)
// ─────────────────────────────────────────────────────────────────────────────
test("showcase-client-9: клиент видит изменение статуса заявки в реальном времени", async ({ context, page }, testInfo) => {
const phone = randomPhone();
trackCleanupPhone(testInfo, phone);
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
await preparePublicSession(context, page, appUrl, phone);
const { trackNumber } = await createRequestViaLanding(page, {
phone,
description: "Showcase: наблюдение смены статуса",
});
trackCleanupTrack(testInfo, trackNumber);
// Запомнить начальный статус
await openPublicCabinet(page, trackNumber);
const initialStatus = await page.locator("#cabinet-request-status").textContent();
// Юрист берёт заявку (статус → ASSIGNED/IN_PROGRESS)
await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
await openRequestsSection(page);
const row = rowByTrack(page, "#section-requests", trackNumber);
const claimBtn = row.first().getByRole("button", { name: "Взять в работу" });
if (await claimBtn.count()) {
await claimBtn.click();
await expect(page.locator("#section-requests .status")).toContainText(/работу|обновлен/i);
}
// Клиент перезагружает кабинет — статус должен был измениться
await page.goto(`/client.html?track=${encodeURIComponent(trackNumber)}`);
await expect(page.locator("#cabinet-summary")).toBeVisible();
const newStatus = await page.locator("#cabinet-request-status").textContent();
// Статус изменился (если юрист был без назначения)
// В крайнем случае — просто убедиться что статус читаемый, а не UUID
expect(newStatus).not.toMatch(/^[0-9a-f]{8}-/i);
expect(newStatus?.trim()).not.toBe("");
});

View file

@ -0,0 +1,364 @@
/**
* SHOWCASE: Полный флоу юриста
*
* Покрывает:
* 1. Дашборд юриста «Моя загрузка»
* 2. Взять заявку в работу из списка заявок
* 3. Взять заявку из Канбана + смена статуса через «Перевести»
* 4. Открыть карточку заявки, прочитать сообщения и файлы клиента
* 5. Ответить клиенту в чате (текст)
* 6. Прикрепить файл (PDF) к ответу
* 7. Запросить данные клиента создать DataRequirement
* 8. Работа с шаблонами данных (DataTemplate)
* 9. Сменить статус из карточки заявки
* 10. Выставить счёт из карточки заявки
* 11. Закрыть заявку (перевести в терминальный статус)
*/
const { test, expect } = require("@playwright/test");
const {
randomPhone,
createRequestViaLanding,
openPublicCabinet,
sendCabinetMessage,
uploadCabinetFile,
loginAdminPanel,
openRequestsSection,
rowByTrack,
buildTinyPdfBuffer,
selectDropdownOption,
selectFirstDropdownOption,
trackCleanupPhone,
trackCleanupTrack,
cleanupTrackedTestData,
preparePublicSession,
} = require("./helpers");
const LAWYER_EMAIL = process.env.E2E_LAWYER_EMAIL || "ivan@mail.ru";
const LAWYER_PASSWORD = process.env.E2E_LAWYER_PASSWORD || "LawyerPass-123!";
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || "admin123";
test.afterEach(async ({ page }, testInfo) => {
await cleanupTrackedTestData(page, testInfo);
});
// ─────────────────────────────────────────────────────────────────────────────
// 1. Дашборд юриста
// ─────────────────────────────────────────────────────────────────────────────
test("showcase-lawyer-1: дашборд — «Моя загрузка» и KPI юриста", async ({ page }) => {
await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
await expect(page.locator("#section-dashboard h2")).toHaveText("Обзор метрик");
await expect(page.locator("#section-dashboard")).toContainText("Моя загрузка");
// KPI-плитки видны (дашборд использует .card)
const tiles = page.locator("#section-dashboard .card");
await expect(tiles.first()).toBeVisible();
});
// ─────────────────────────────────────────────────────────────────────────────
// 2. Взять заявку в работу из списка + прочитать сообщение клиента
// ─────────────────────────────────────────────────────────────────────────────
test("showcase-lawyer-2: взять заявку и прочитать сообщение клиента", async ({ context, page }, testInfo) => {
const phone = randomPhone();
trackCleanupPhone(testInfo, phone);
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
await preparePublicSession(context, page, appUrl, phone);
const { trackNumber } = await createRequestViaLanding(page, {
phone,
description: "Showcase: юрист берёт заявку и читает сообщение",
});
trackCleanupTrack(testInfo, trackNumber);
// Клиент пишет сообщение
await openPublicCabinet(page, trackNumber);
const clientMsg = `Вопрос клиента ${Date.now()}`;
await sendCabinetMessage(page, clientMsg);
// Юрист входит в систему
await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
await openRequestsSection(page);
const row = rowByTrack(page, "#section-requests", trackNumber);
await expect(row).toHaveCount(1);
// Иконка непрочитанных сообщений видна
await expect(row.first().locator(".request-update-chip")).toBeVisible();
// Взять в работу
const claimBtn = row.first().getByRole("button", { name: "Взять в работу" });
await expect(claimBtn).toBeVisible();
await claimBtn.click();
await expect(page.locator("#section-requests .status")).toContainText(/Заявка взята в работу|Список обновлен/);
// Открыть карточку
await row.first().locator(".request-track-link").click();
await expect(page.getByRole("heading", { name: /Карточка заявки/ })).toBeVisible();
await expect(page.locator("#request-modal-messages")).toContainText(clientMsg);
});
// ─────────────────────────────────────────────────────────────────────────────
// 3. Ответ юриста в чате + прикрепление PDF
// ─────────────────────────────────────────────────────────────────────────────
test("showcase-lawyer-3: ответить клиенту текстом и прикрепить PDF", async ({ context, page }, testInfo) => {
const phone = randomPhone();
trackCleanupPhone(testInfo, phone);
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
await preparePublicSession(context, page, appUrl, phone);
const { trackNumber } = await createRequestViaLanding(page, {
phone,
description: "Showcase: ответ юриста и PDF",
});
trackCleanupTrack(testInfo, trackNumber);
await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
await openRequestsSection(page);
const row = rowByTrack(page, "#section-requests", trackNumber);
// Ждём пока строка с заявкой загрузится в таблицу (async fetch)
await expect(row).toHaveCount(1);
const claimBtn = row.first().getByRole("button", { name: "Взять в работу" });
if (await claimBtn.isVisible().catch(() => false)) {
await claimBtn.click();
await expect(page.locator("#section-requests .status")).toContainText(/работу|обновлен/i);
}
await row.first().locator(".request-track-link").click();
await expect(page.getByRole("heading", { name: /Карточка заявки/ })).toBeVisible();
// Текстовый ответ
const lawyerReply = `Ответ юриста ${Date.now()}`;
await page.getByRole("tab", { name: /Чат/ }).click();
await page.locator("#request-modal-message-body").fill(lawyerReply);
await page.locator("#request-modal-message-send").click();
await expect(page.locator("#section-request-workspace .status")).toContainText("Сообщение отправлено");
await expect(page.locator("#request-modal-messages")).toContainText(lawyerReply);
// Прикрепить PDF
const pdfName = `lawyer-reply-${Date.now()}.pdf`;
const pdfBuffer = buildTinyPdfBuffer("Showcase answer");
const fileInput = page.locator("#request-modal-file-input");
if (await fileInput.count()) {
await fileInput.setInputFiles({ name: pdfName, mimeType: "application/pdf", buffer: pdfBuffer });
await page.locator("#request-modal-message-send").click();
await expect(page.locator("#section-request-workspace .status")).toContainText(/файл|отправлен/i);
await page.getByRole("tab", { name: /Файлы/ }).click();
await expect(page.locator("#request-modal-files")).toContainText(pdfName);
}
});
// ─────────────────────────────────────────────────────────────────────────────
// 4. Запрос данных от клиента (DataRequirement / форма сбора)
// ─────────────────────────────────────────────────────────────────────────────
test("showcase-lawyer-4: запросить данные клиента — создать DataRequirement", async ({ context, page }, testInfo) => {
const phone = randomPhone();
trackCleanupPhone(testInfo, phone);
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
await preparePublicSession(context, page, appUrl, phone);
const { trackNumber } = await createRequestViaLanding(page, {
phone,
description: "Showcase: запрос данных от клиента",
});
trackCleanupTrack(testInfo, trackNumber);
await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
await openRequestsSection(page);
const row = rowByTrack(page, "#section-requests", trackNumber);
const claimBtn = row.first().getByRole("button", { name: "Взять в работу" });
if (await claimBtn.count()) {
await claimBtn.click();
await expect(page.locator("#section-requests .status")).toContainText(/работу|обновлен/i);
}
await row.first().locator(".request-track-link").click();
await expect(page.getByRole("heading", { name: /Карточка заявки/ })).toBeVisible();
// Перейти на вкладку «Данные» / «Запросы данных»
const dataTab = page.getByRole("tab", { name: /Данные|Сбор данных|Запрос/ });
if (await dataTab.count()) {
await dataTab.click();
// Создать запрос данных
const addDataBtn = page.getByRole("button", { name: /Добавить|Запросить|Запрос данных/i });
if (await addDataBtn.count()) {
await addDataBtn.click();
await expect(page.getByRole("heading", { name: /запрос|данных/i })).toBeVisible();
// Заполнить поле
const fieldInput = page.locator("[name='field_label'], #data-req-label, input[placeholder*='Название']").first();
if (await fieldInput.count()) {
await fieldInput.fill("Серия и номер паспорта");
}
// Выбрать тип поля
const typeSelect = page.locator("[name='field_type'], #data-req-type").first();
if (await typeSelect.count()) {
await selectDropdownOption(page, typeSelect, "Текст").catch(() => {});
}
await page.getByRole("button", { name: /Сохранить|Добавить/i }).first().click();
await expect(page.locator("#section-request-workspace .status, .data-req-status").first())
.toContainText(/сохранен|добавлен|обновлен/i);
}
}
});
// ─────────────────────────────────────────────────────────────────────────────
// 5. Канбан — взять заявку и сменить статус через «Перевести…»
// ─────────────────────────────────────────────────────────────────────────────
test("showcase-lawyer-5: канбан — взять карточку и сменить статус", async ({ context, page }, testInfo) => {
const phone = randomPhone();
trackCleanupPhone(testInfo, phone);
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
await preparePublicSession(context, page, appUrl, phone);
const { trackNumber, name } = await createRequestViaLanding(page, {
phone,
description: "Showcase: канбан юриста",
});
trackCleanupTrack(testInfo, trackNumber);
await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
await page.locator("aside .menu button[data-section='kanban']").click();
await expect(page.locator("#section-kanban h2")).toHaveText("Канбан заявок");
// Фильтр по имени клиента
await page.locator("#section-kanban .section-head-actions").getByRole("button", { name: "Фильтр" }).click();
await selectDropdownOption(page, "#filter-field", "Клиент");
await page.locator("#filter-value").fill(name);
await page.locator("#filter-overlay").getByRole("button", { name: /Добавить|Сохранить/i }).click();
await expect(page.locator("#section-kanban .filter-chip")).toHaveCount(1);
const card = page.locator("#section-kanban .kanban-card").filter({ hasText: trackNumber }).first();
await expect(card).toBeVisible();
// Взять в работу
const claimBtn = card.getByRole("button", { name: "Взять в работу" });
if (await claimBtn.count()) {
await claimBtn.click();
await expect(page.locator("#section-kanban .status")).toContainText(/работу|обновлен/i);
}
// Сменить статус через «Перевести…»
const freshCard = page.locator("#section-kanban .kanban-card").filter({ hasText: trackNumber }).first();
const transSelect = freshCard.locator(".kanban-transition-select");
if (await transSelect.count()) {
const newStatus = await selectFirstDropdownOption(page, transSelect);
await expect(page.locator("#section-kanban .status")).toContainText(/обновлен|Статус/i);
// Карточка в канбане отображает новый статус
await expect(page.locator("#section-kanban .kanban-card").filter({ hasText: trackNumber }).first())
.toContainText(newStatus);
}
// Убедиться что колонки не содержат UUID-заголовков
const headers = page.locator("#section-kanban .kanban-column-head b");
const headCount = await headers.count();
for (let i = 0; i < headCount; i++) {
const text = await headers.nth(i).textContent();
expect(text).not.toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
}
});
// ─────────────────────────────────────────────────────────────────────────────
// 6. Выставить счёт клиенту из карточки заявки
// ─────────────────────────────────────────────────────────────────────────────
test("showcase-lawyer-6: выставить счёт клиенту", async ({ context, page }, testInfo) => {
const phone = randomPhone();
trackCleanupPhone(testInfo, phone);
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
await preparePublicSession(context, page, appUrl, phone);
const { trackNumber } = await createRequestViaLanding(page, {
phone,
description: "Showcase: выставление счёта юристом",
});
trackCleanupTrack(testInfo, trackNumber);
await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
await openRequestsSection(page);
const row = rowByTrack(page, "#section-requests", trackNumber);
const claimBtn = row.first().getByRole("button", { name: "Взять в работу" });
if (await claimBtn.count()) {
await claimBtn.click();
await expect(page.locator("#section-requests .status")).toContainText(/работу|обновлен/i);
}
await row.first().locator(".request-track-link").click();
await expect(page.getByRole("heading", { name: /Карточка заявки/ })).toBeVisible();
// Вкладка «Финансы» / «Счета»
const financeTab = page.getByRole("tab", { name: /Финанс|Счет|Оплат/i });
if (await financeTab.count()) {
await financeTab.click();
const createInvoiceBtn = page.getByRole("button", { name: /Выставить счёт|Создать счёт|Добавить счёт/i });
if (await createInvoiceBtn.count()) {
await createInvoiceBtn.click();
await page.locator("[name='amount'], #invoice-amount, #record-field-amount").first().fill("12000");
await page.getByRole("button", { name: /Сохранить|Создать/i }).first().click();
await expect(page.locator("#section-request-workspace .status").first()).toContainText(/сохранен|выставлен|создан/i);
}
}
});
// ─────────────────────────────────────────────────────────────────────────────
// 7. Пройти все статусы и закрыть заявку
// ─────────────────────────────────────────────────────────────────────────────
test("showcase-lawyer-7: пройти по статусам и закрыть заявку", async ({ context, page }, testInfo) => {
const phone = randomPhone();
trackCleanupPhone(testInfo, phone);
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
await preparePublicSession(context, page, appUrl, phone);
const { trackNumber } = await createRequestViaLanding(page, {
phone,
description: "Showcase: полный цикл статусов",
});
trackCleanupTrack(testInfo, trackNumber);
await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
await openRequestsSection(page);
const row = rowByTrack(page, "#section-requests", trackNumber);
const claimBtn = row.first().getByRole("button", { name: "Взять в работу" });
if (await claimBtn.count()) {
await claimBtn.click();
await expect(page.locator("#section-requests .status")).toContainText(/работу|обновлен/i);
}
await row.first().locator(".request-track-link").click();
await expect(page.getByRole("heading", { name: /Карточка заявки/ })).toBeVisible();
// Менять статусы пока не дойдём до терминального
const MAX_TRANSITIONS = 8;
for (let i = 0; i < MAX_TRANSITIONS; i++) {
const statusPanel = page.locator("#request-status-route, .status-route-panel, [data-testid='status-route']").first();
const nextBtn = statusPanel.getByRole("button", { name: /Следующий|Перевести|Подтвердить|→/i }).first();
const selectStatus = page.locator("#request-available-status-select, .available-status-select").first();
if (await selectStatus.count()) {
const label = await selectFirstDropdownOption(page, selectStatus);
if (!label) break;
await page.getByRole("button", { name: /Применить|Сохранить|ОК/i }).first().click();
await expect(page.locator("#section-request-workspace .status").first()).toContainText(/обновлен|изменен/i);
// Проверить признак терминального статуса
const terminal = await page.locator(".status-terminal, [data-terminal='true']").count();
if (terminal) break;
} else if (await nextBtn.count()) {
await nextBtn.click();
await expect(page.locator("#section-request-workspace .status").first()).toContainText(/обновлен|изменен/i);
} else {
break;
}
await page.waitForTimeout(200);
}
// Итоговый статус заявки — не должен быть UUID
const statusBadge = page.locator(".request-field-value.status-badge, .status-name, [data-testid='request-status']").first();
if (await statusBadge.count()) {
const statusText = await statusBadge.textContent();
expect(statusText).not.toMatch(/^[0-9a-f]{8}-/i);
}
});