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_catalog.append(columns_by_key[fallback_key])
elif status_group not in columns_by_key:
columns_by_key[status_group] = {
"key": status_group,
"label": status_group_name or status_group,
"sort_order": int(status_group_order or 999),
}
columns_catalog.append(columns_by_key[status_group])
# status_group_id references a deleted/non-existent StatusGroup —
# remap to a heuristic fallback column instead of creating a phantom UUID column
fallback_key, fallback_label, fallback_order = fallback_group_for_status(status_code, status_meta)
status_group = fallback_key
status_group_name = fallback_label
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 = []
topic_rules = transitions_by_topic.get(topic_code) or []
@ -541,7 +544,7 @@ def get_requests_kanban_service(
continue
to_meta = status_meta_or_default(status_meta_map, to_status)
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)
if target_group not in columns_by_key:
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
to_meta = status_meta_or_default(status_meta_map, to_status)
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)
if target_group not in columns_by_key:
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
import io
import json
import uuid
from typing import Tuple
@ -18,7 +19,15 @@ from app.models.admin_user import AdminUser
from app.models.attachment import Attachment
from app.models.message import Message
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.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
@ -36,6 +45,9 @@ router = APIRouter()
AVATAR_MAX_SIZE_PX = 512
AVATAR_THUMB_MAX_SIZE_PX = 160
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)
@ -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"
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:
return {
"id": str(row.id),
@ -426,13 +545,40 @@ def upload_complete(
if user is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
_ensure_object_key_prefix_or_400(payload.key, f"avatars/{user.id}/")
thumb_key, optimized_size, optimized_mime = _write_avatar_variant_or_400(
storage,
source_key=payload.key,
variant="thumb",
max_size_px=AVATAR_THUMB_MAX_SIZE_PX,
# Read the raw uploaded file from the presigned-PUT key
raw_source = _read_object_bytes_or_400(storage, payload.key)
# 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
db.add(user)
record_file_security_event(
@ -447,15 +593,19 @@ def upload_complete(
details={
"source_mime_type": payload.mime_type,
"source_size_bytes": int(actual_size),
"variant": "thumb",
"variant_key": thumb_key,
"variant_mime_type": optimized_mime,
"variant_size_bytes": int(optimized_size),
"original_key": keys["original"],
"cropped_key": keys["cropped"],
"thumb_key": keys["thumb"],
"crop": crop,
},
responsible=responsible,
)
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")
except HTTPException as exc:
@ -477,6 +627,93 @@ def upload_complete(
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}")
def list_request_attachments(
request_id: str,
@ -557,6 +794,12 @@ def get_object_proxy(
storage = get_s3_storage()
if scope == "avatars" and requested_variant == "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:
obj = storage.get_object(thumb_key)

View file

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

View file

@ -1,6 +1,6 @@
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 app.db.session import Base
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)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
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)
default_rate: Mapped[float | None] = mapped_column(Numeric(12, 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
message_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):
status: str = "ok"
attachment_id: 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;
}
/* 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 {
width: 156px;
height: 156px;
@ -3855,6 +3868,81 @@
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 {
overflow-y: auto;
overscroll-behavior: contain;

View file

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>Административная панель • Правовой трекер</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=20260302-01">
<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]);
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(() => {
return (dictionaries.formFieldTypes || []).filter(Boolean).map((item) => ({ value: item, label: item }));
}, [dictionaries.formFieldTypes]);
@ -1326,7 +1335,17 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
if (rawValue == null || rawValue === "") return;
const value = String(rawValue);
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);
});
return Array.from(map.entries())
@ -1922,6 +1941,8 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
uploadScope: "USER_AVATAR",
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: "default_rate", 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 },
];
}
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];
if (!meta || !Array.isArray(meta.columns)) return [];
return (meta.columns || [])
@ -2459,7 +2489,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
);
const uploadRecordFieldFile = useCallback(
async (field, file) => {
async (field, file, cropJson) => {
if (!recordModal.tableKey || !field || !file) return;
if (field.uploadScope !== "USER_AVATAR") return;
if (recordModal.tableKey !== "users") return;
@ -2489,18 +2519,23 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
`Не удалось загрузить файл в хранилище (${putResp.status}${errorText ? `: ${errorText.slice(0, 200)}` : ""})`
);
}
const done = await api("/api/admin/uploads/complete", {
method: "POST",
body: {
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", {
method: "POST",
body: completeBody,
});
updateRecordField("avatar_url", String(done.avatar_url || ""));
if (done.avatar_original_key) {
updateRecordField("avatar_original_key", String(done.avatar_original_key));
}
setStatus("recordForm", "Аватар загружен", "ok");
} catch (error) {
setStatus("recordForm", "Ошибка загрузки: " + error.message, "error");
@ -2509,6 +2544,52 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
[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(
(tableKey, form, mode) => {
const fields = getRecordFields(tableKey);
@ -2519,6 +2600,8 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
fields.forEach((field) => {
if (isLawyerRequestEdit && field.key !== "topic_code") 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];
if (field.type === "boolean") {
payload[field.key] = raw === "true";
@ -4254,6 +4337,9 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
onClose={closeRecordModal}
onChange={updateRecordField}
onUploadField={uploadRecordFieldFile}
onUploadFieldWithCrop={uploadRecordFieldFile}
onRecropAvatar={recropAvatar}
onApplyRecrop={applyRecrop}
onSubmit={submitRecordModal}
OverlayComponent={Overlay}
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 { resolveAvatarSrc, roleLabel } from "./utils.js";
import { AvatarCropEditor } from "./AvatarCropEditor.jsx";
const { useEffect, useRef, useState } = React;
@ -16,6 +17,9 @@ export function RecordModal({
onChange,
onSubmit,
onUploadField,
onUploadFieldWithCrop,
onRecropAvatar,
onApplyRecrop,
OverlayComponent,
IconButtonComponent,
UserAvatarComponent,
@ -27,8 +31,12 @@ export function RecordModal({
const StatusLine = StatusLineComponent;
const [avatarPreviewOpen, setAvatarPreviewOpen] = 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 visibleFields = (fields || []).filter((field) => {
if (field.hidden) return false;
if (typeof field.visibleWhen !== "function") return true;
try {
return Boolean(field.visibleWhen(form || {}));
@ -259,8 +267,35 @@ export function RecordModal({
</div>
<form className={"stack" + (isUserModal ? " record-user-scroll" : "")} id="record-modal-form" onSubmit={onSubmit}>
{isUserModal ? (
<div className="record-user-top">
<div className={"record-user-top" + (cropFile ? " record-user-top--crop-mode" : "")}>
<div className="record-user-avatar-area">
{cropFile ? (
// Crop editor takes over the avatar area while selecting focus
<AvatarCropEditor
imageFile={cropFile}
initialCrop={cropInitial}
onApply={({ file, cropJson }) => {
const wasRecrop = isRecropRef.current;
isRecropRef.current = false;
setCropFile(null);
setCropInitial(null);
if (wasRecrop && onApplyRecrop) {
// 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);
}}
/>
) : (
<>
<button
type="button"
className={"record-user-avatar-shell" + (avatarPreviewSrc ? " interactive" : "")}
@ -281,7 +316,10 @@ export function RecordModal({
style={{ display: "none" }}
onChange={(event) => {
const file = event.target.files && event.target.files[0];
if (file && onUploadField) onUploadField(avatarField, file);
if (file) {
setCropInitial(null); // new upload start from center
setCropFile(file);
}
event.target.value = "";
}}
/>
@ -295,6 +333,28 @@ export function RecordModal({
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">
@ -311,6 +371,8 @@ export function RecordModal({
</div>
</>
) : null}
</>
)}
</div>
<div className="record-user-summary">
<div className="record-user-summary-head">

View file

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

View file

@ -792,90 +792,143 @@
.featured-team-track {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(280px, 34%);
gap: 0.85rem;
grid-auto-columns: 220px;
gap: 1rem;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x proximity;
scrollbar-width: thin;
padding: 0.15rem 0.1rem 0.4rem;
scroll-snap-type: x mandatory;
scrollbar-width: none;
padding: 0.5rem 0.25rem 1rem;
}
.featured-team-track::-webkit-scrollbar {
display: none;
}
.featured-card {
scroll-snap-align: start;
border: 1px solid var(--line);
border-radius: 16px;
background: linear-gradient(165deg, rgba(32, 43, 57, 0.95), rgba(17, 24, 32, 0.96));
display: grid;
grid-template-columns: 150px 1fr;
gap: 0.95rem;
padding: 0.85rem;
min-height: 188px;
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.18);
animation: rise 0.45s ease forwards;
position: relative;
border: 1px solid rgba(255,255,255,0.08);
border-radius: 20px;
background: linear-gradient(175deg, rgba(30, 42, 58, 0.96), rgba(16, 23, 33, 0.98));
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
padding: 1.4rem 1rem 1.2rem;
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;
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 {
width: 146px;
height: 146px;
width: 96px;
height: 96px;
border-radius: 50%;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.24);
box-shadow: 0 0 0 1px rgba(212, 169, 104, 0.42), 0 0 0 5px rgba(212, 169, 104, 0.08);
background: rgba(255, 255, 255, 0.04);
align-self: center;
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: rgba(30, 42, 58, 0.8);
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 {
min-width: 0;
display: grid;
align-content: start;
gap: 0.35rem;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
text-align: center;
}
.featured-card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
align-items: center;
justify-content: center;
}
.featured-card-top h3 {
margin: 0;
font-size: 1rem;
line-height: 1.25;
font-size: 0.95rem;
font-weight: 700;
line-height: 1.3;
color: #eef4ff;
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 {
margin: 0;
color: #a6b5c8;
font-size: 0.84rem;
line-height: 1.35;
padding: 0.2rem 0.6rem;
border-radius: 999px;
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;
}
.featured-caption {
margin: 0.1rem 0 0;
color: #dde8f6;
font-size: 0.9rem;
line-height: 1.5;
margin: 0.2rem 0 0;
color: #8ea5be;
font-size: 0.81rem;
line-height: 1.55;
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.featured-caption > :first-child {
@ -887,7 +940,7 @@
}
.featured-caption p {
margin: 0.14rem 0;
margin: 0;
}
.featured-caption ul {
@ -979,7 +1032,7 @@
}
.featured-team-track {
grid-auto-columns: minmax(300px, 52%);
grid-auto-columns: 200px;
}
}
@ -1064,7 +1117,7 @@
}
.featured-team-track {
grid-auto-columns: 86%;
grid-auto-columns: 186px;
}
.simple-list {
@ -1090,14 +1143,14 @@
top: calc(100% - 3px);
}
.featured-card {
grid-template-columns: 114px 1fr;
min-height: 156px;
.featured-team-track {
grid-auto-columns: 172px;
}
.featured-avatar {
width: 106px;
height: 106px;
.featured-avatar,
.featured-avatar-initials {
width: 80px;
height: 80px;
}
.hero {

View file

@ -3,9 +3,94 @@
<head>
<meta charset="UTF-8">
<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="shortcut icon" href="/favicon.svg">
<link rel="stylesheet" href="/landing.css">
</head>
<body>
@ -185,7 +270,12 @@
</main>
<footer>
ООО «Аудиторы корпоративной безопасности» • Юридический консалтинг и судебное сопровождение
<address>
<strong>ООО «Аудиторы корпоративной безопасности»</strong>
— юридический консалтинг и судебное сопровождение
</address>
<small>© <span id="footer-year">2026</span> Все права защищены</small>
<script>document.getElementById('footer-year').textContent = new Date().getFullYear();</script>
</footer>
<div class="modal-backdrop" id="request-modal" aria-hidden="true">

View file

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