mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
test new design 06
This commit is contained in:
parent
9a53d92377
commit
1221bcc684
24 changed files with 2973 additions and 429 deletions
98
alembic/versions/0037_fix_orphaned_status_group_ids.py
Normal file
98
alembic/versions/0037_fix_orphaned_status_group_ids.py
Normal 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
|
||||||
29
alembic/versions/0038_avatar_crop_fields.py
Normal file
29
alembic/versions/0038_avatar_crop_fields.py
Normal 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")
|
||||||
|
|
@ -524,12 +524,15 @@ def get_requests_kanban_service(
|
||||||
columns_by_key[fallback_key] = {"key": fallback_key, "label": fallback_label, "sort_order": fallback_order}
|
columns_by_key[fallback_key] = {"key": fallback_key, "label": fallback_label, "sort_order": fallback_order}
|
||||||
columns_catalog.append(columns_by_key[fallback_key])
|
columns_catalog.append(columns_by_key[fallback_key])
|
||||||
elif status_group not in columns_by_key:
|
elif status_group not in columns_by_key:
|
||||||
columns_by_key[status_group] = {
|
# status_group_id references a deleted/non-existent StatusGroup —
|
||||||
"key": status_group,
|
# remap to a heuristic fallback column instead of creating a phantom UUID column
|
||||||
"label": status_group_name or status_group,
|
fallback_key, fallback_label, fallback_order = fallback_group_for_status(status_code, status_meta)
|
||||||
"sort_order": int(status_group_order or 999),
|
status_group = fallback_key
|
||||||
}
|
status_group_name = fallback_label
|
||||||
columns_catalog.append(columns_by_key[status_group])
|
status_group_order = fallback_order
|
||||||
|
if fallback_key not in columns_by_key:
|
||||||
|
columns_by_key[fallback_key] = {"key": fallback_key, "label": fallback_label, "sort_order": fallback_order}
|
||||||
|
columns_catalog.append(columns_by_key[fallback_key])
|
||||||
|
|
||||||
available_transitions = []
|
available_transitions = []
|
||||||
topic_rules = transitions_by_topic.get(topic_code) or []
|
topic_rules = transitions_by_topic.get(topic_code) or []
|
||||||
|
|
@ -541,7 +544,7 @@ def get_requests_kanban_service(
|
||||||
continue
|
continue
|
||||||
to_meta = status_meta_or_default(status_meta_map, to_status)
|
to_meta = status_meta_or_default(status_meta_map, to_status)
|
||||||
target_group = str(to_meta.get("status_group_id") or "").strip()
|
target_group = str(to_meta.get("status_group_id") or "").strip()
|
||||||
if not target_group:
|
if not target_group or target_group not in columns_by_key:
|
||||||
target_group, fallback_label, fallback_order = fallback_group_for_status(to_status, to_meta)
|
target_group, fallback_label, fallback_order = fallback_group_for_status(to_status, to_meta)
|
||||||
if target_group not in columns_by_key:
|
if target_group not in columns_by_key:
|
||||||
columns_by_key[target_group] = {"key": target_group, "label": fallback_label, "sort_order": fallback_order}
|
columns_by_key[target_group] = {"key": target_group, "label": fallback_label, "sort_order": fallback_order}
|
||||||
|
|
@ -563,7 +566,7 @@ def get_requests_kanban_service(
|
||||||
continue
|
continue
|
||||||
to_meta = status_meta_or_default(status_meta_map, to_status)
|
to_meta = status_meta_or_default(status_meta_map, to_status)
|
||||||
target_group = str(to_meta.get("status_group_id") or "").strip()
|
target_group = str(to_meta.get("status_group_id") or "").strip()
|
||||||
if not target_group:
|
if not target_group or target_group not in columns_by_key:
|
||||||
target_group, fallback_label, fallback_order = fallback_group_for_status(to_status, to_meta)
|
target_group, fallback_label, fallback_order = fallback_group_for_status(to_status, to_meta)
|
||||||
if target_group not in columns_by_key:
|
if target_group not in columns_by_key:
|
||||||
columns_by_key[target_group] = {"key": target_group, "label": fallback_label, "sort_order": fallback_order}
|
columns_by_key[target_group] = {"key": target_group, "label": fallback_label, "sort_order": fallback_order}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
|
|
@ -18,7 +19,15 @@ from app.models.admin_user import AdminUser
|
||||||
from app.models.attachment import Attachment
|
from app.models.attachment import Attachment
|
||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, UploadInitPayload, UploadInitResponse, UploadScope
|
from app.schemas.uploads import (
|
||||||
|
RecropPayload,
|
||||||
|
RecropResponse,
|
||||||
|
UploadCompletePayload,
|
||||||
|
UploadCompleteResponse,
|
||||||
|
UploadInitPayload,
|
||||||
|
UploadInitResponse,
|
||||||
|
UploadScope,
|
||||||
|
)
|
||||||
from app.api.admin.requests_modules.permissions import ensure_lawyer_can_view_request_or_403
|
from app.api.admin.requests_modules.permissions import ensure_lawyer_can_view_request_or_403
|
||||||
from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event
|
from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event
|
||||||
from app.services.request_read_markers import EVENT_ATTACHMENT, mark_unread_for_client
|
from app.services.request_read_markers import EVENT_ATTACHMENT, mark_unread_for_client
|
||||||
|
|
@ -36,6 +45,9 @@ router = APIRouter()
|
||||||
AVATAR_MAX_SIZE_PX = 512
|
AVATAR_MAX_SIZE_PX = 512
|
||||||
AVATAR_THUMB_MAX_SIZE_PX = 160
|
AVATAR_THUMB_MAX_SIZE_PX = 160
|
||||||
AVATAR_WEBP_QUALITY = 80
|
AVATAR_WEBP_QUALITY = 80
|
||||||
|
AVATAR_THUMB_WEBP_QUALITY = 72
|
||||||
|
AVATAR_ORIGINAL_MAX_SIZE_PX = 1600
|
||||||
|
AVATAR_ORIGINAL_WEBP_QUALITY = 82
|
||||||
_AVATAR_RESAMPLE = getattr(getattr(Image, "Resampling", Image), "LANCZOS", 1)
|
_AVATAR_RESAMPLE = getattr(getattr(Image, "Resampling", Image), "LANCZOS", 1)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -191,6 +203,113 @@ def _write_avatar_variant_or_400(storage, *, source_key: str, variant: str, max_
|
||||||
return target_key, int(len(optimized)), "image/webp"
|
return target_key, int(len(optimized)), "image/webp"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_crop_dict(crop_json: str | None) -> dict:
|
||||||
|
"""Parse crop JSON string into a validated dict with clamped values."""
|
||||||
|
raw: dict = {}
|
||||||
|
if crop_json:
|
||||||
|
try:
|
||||||
|
raw = json.loads(crop_json)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raw = {}
|
||||||
|
x = max(-1.0, min(1.0, float(raw.get("x", 0.0) or 0.0)))
|
||||||
|
y = max(-1.0, min(1.0, float(raw.get("y", 0.0) or 0.0)))
|
||||||
|
zoom = max(1.0, min(4.0, float(raw.get("zoom", 1.0) or 1.0)))
|
||||||
|
return {"x": x, "y": y, "zoom": zoom}
|
||||||
|
|
||||||
|
|
||||||
|
def _crop_cover(image: Image.Image, target_size: tuple[int, int], crop: dict) -> Image.Image:
|
||||||
|
"""Crop and resize *image* to *target_size* according to (x, y, zoom) parameters.
|
||||||
|
|
||||||
|
x, y: -1.0..1.0 — normalized offset from the image center.
|
||||||
|
zoom: 1.0..4.0 — zoom multiplier (1 = minimum crop to fill target aspect ratio).
|
||||||
|
|
||||||
|
Algorithm ported from Flw/backend/media_images.py and matches the
|
||||||
|
CSS-transform-based preview in AvatarCropEditor.jsx exactly.
|
||||||
|
"""
|
||||||
|
tw, th = target_size
|
||||||
|
sw, sh = image.size
|
||||||
|
# Minimum scale to cover the target rectangle from the source
|
||||||
|
base_scale = max(tw / sw, th / sh)
|
||||||
|
# Size of the crop window in source pixels
|
||||||
|
crop_w = max(1.0, min(float(sw), tw / base_scale / crop["zoom"]))
|
||||||
|
crop_h = max(1.0, min(float(sh), th / base_scale / crop["zoom"]))
|
||||||
|
# Maximum pan offsets (from center to edge) in source pixels
|
||||||
|
offset_x = (sw - crop_w) / 2.0
|
||||||
|
offset_y = (sh - crop_h) / 2.0
|
||||||
|
# Center of the crop window
|
||||||
|
cx = sw / 2.0 + crop["x"] * offset_x
|
||||||
|
cy = sh / 2.0 + crop["y"] * offset_y
|
||||||
|
left = max(0.0, min(sw - crop_w, cx - crop_w / 2.0))
|
||||||
|
top = max(0.0, min(sh - crop_h, cy - crop_h / 2.0))
|
||||||
|
box = (left, top, left + crop_w, top + crop_h)
|
||||||
|
return image.crop(box).resize((tw, th), _AVATAR_RESAMPLE)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_avatar_original_webp(source: bytes) -> bytes:
|
||||||
|
"""Compress source image to an archival-quality WebP (max AVATAR_ORIGINAL_MAX_SIZE_PX px)."""
|
||||||
|
try:
|
||||||
|
with Image.open(io.BytesIO(source)) as image:
|
||||||
|
image = ImageOps.exif_transpose(image)
|
||||||
|
image.load()
|
||||||
|
if max(image.size) > AVATAR_ORIGINAL_MAX_SIZE_PX:
|
||||||
|
image.thumbnail(
|
||||||
|
(AVATAR_ORIGINAL_MAX_SIZE_PX, AVATAR_ORIGINAL_MAX_SIZE_PX),
|
||||||
|
resample=_AVATAR_RESAMPLE,
|
||||||
|
)
|
||||||
|
if image.mode != "RGB":
|
||||||
|
image = image.convert("RGB")
|
||||||
|
out = io.BytesIO()
|
||||||
|
image.save(out, format="WEBP", quality=AVATAR_ORIGINAL_WEBP_QUALITY, method=6)
|
||||||
|
result = out.getvalue()
|
||||||
|
except UnidentifiedImageError:
|
||||||
|
raise HTTPException(status_code=400, detail="Аватар должен быть изображением")
|
||||||
|
except OSError:
|
||||||
|
raise HTTPException(status_code=400, detail="Не удалось обработать изображение аватара")
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=400, detail="Не удалось обработать изображение аватара")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _render_avatar_cropped_webp(source: bytes, crop: dict, *, size_px: int, quality: int) -> bytes:
|
||||||
|
"""Apply crop parameters to source image and produce a square WebP."""
|
||||||
|
try:
|
||||||
|
with Image.open(io.BytesIO(source)) as image:
|
||||||
|
image = ImageOps.exif_transpose(image)
|
||||||
|
image.load()
|
||||||
|
if image.mode != "RGB":
|
||||||
|
image = image.convert("RGB")
|
||||||
|
cropped = _crop_cover(image, (size_px, size_px), crop)
|
||||||
|
out = io.BytesIO()
|
||||||
|
cropped.save(out, format="WEBP", quality=quality, method=6)
|
||||||
|
result = out.getvalue()
|
||||||
|
except UnidentifiedImageError:
|
||||||
|
raise HTTPException(status_code=400, detail="Аватар должен быть изображением")
|
||||||
|
except OSError:
|
||||||
|
raise HTTPException(status_code=400, detail="Не удалось обработать изображение аватара")
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=400, detail="Не удалось обработать изображение аватара")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _avatar_deterministic_keys(user_id: uuid.UUID) -> dict[str, str]:
|
||||||
|
"""Return the three deterministic S3 keys for a user's avatar."""
|
||||||
|
prefix = f"avatars/{user_id}"
|
||||||
|
return {
|
||||||
|
"original": f"{prefix}/original.webp",
|
||||||
|
"cropped": f"{prefix}/cropped.webp",
|
||||||
|
"thumb": f"{prefix}/cropped__thumb.webp",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_object_silent(storage, key: str) -> None:
|
||||||
|
"""Delete an S3 object, ignoring errors (best-effort cleanup)."""
|
||||||
|
try:
|
||||||
|
if hasattr(storage, "client") and hasattr(storage, "bucket"):
|
||||||
|
storage.client.delete_object(Bucket=storage.bucket, Key=key)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _serialize_attachment(row: Attachment) -> dict:
|
def _serialize_attachment(row: Attachment) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
|
|
@ -426,13 +545,40 @@ def upload_complete(
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||||
_ensure_object_key_prefix_or_400(payload.key, f"avatars/{user.id}/")
|
_ensure_object_key_prefix_or_400(payload.key, f"avatars/{user.id}/")
|
||||||
thumb_key, optimized_size, optimized_mime = _write_avatar_variant_or_400(
|
|
||||||
storage,
|
# Read the raw uploaded file from the presigned-PUT key
|
||||||
source_key=payload.key,
|
raw_source = _read_object_bytes_or_400(storage, payload.key)
|
||||||
variant="thumb",
|
|
||||||
max_size_px=AVATAR_THUMB_MAX_SIZE_PX,
|
# Parse crop params (defaults to centered 1× zoom if not provided)
|
||||||
|
crop = _parse_crop_dict(payload.crop_json)
|
||||||
|
|
||||||
|
# Deterministic S3 keys for this user
|
||||||
|
keys = _avatar_deterministic_keys(user.id)
|
||||||
|
|
||||||
|
# 1. Compress original (archival quality, no crop)
|
||||||
|
original_bytes = _render_avatar_original_webp(raw_source)
|
||||||
|
_write_object_bytes_or_500(storage, key=keys["original"], content=original_bytes, mime_type="image/webp")
|
||||||
|
|
||||||
|
# 2. Cropped avatar (512×512)
|
||||||
|
cropped_bytes = _render_avatar_cropped_webp(
|
||||||
|
raw_source, crop, size_px=AVATAR_MAX_SIZE_PX, quality=AVATAR_WEBP_QUALITY
|
||||||
)
|
)
|
||||||
user.avatar_url = f"s3://{payload.key}"
|
_write_object_bytes_or_500(storage, key=keys["cropped"], content=cropped_bytes, mime_type="image/webp")
|
||||||
|
|
||||||
|
# 3. Thumbnail (160×160, same crop)
|
||||||
|
thumb_bytes = _render_avatar_cropped_webp(
|
||||||
|
raw_source, crop, size_px=AVATAR_THUMB_MAX_SIZE_PX, quality=AVATAR_THUMB_WEBP_QUALITY
|
||||||
|
)
|
||||||
|
_write_object_bytes_or_500(storage, key=keys["thumb"], content=thumb_bytes, mime_type="image/webp")
|
||||||
|
|
||||||
|
# Clean up the temp presigned-PUT object (best-effort)
|
||||||
|
if payload.key != keys["original"]:
|
||||||
|
_delete_object_silent(storage, payload.key)
|
||||||
|
|
||||||
|
# Update user record
|
||||||
|
user.avatar_url = f"s3://{keys['cropped']}"
|
||||||
|
user.avatar_original_key = keys["original"]
|
||||||
|
user.avatar_crop_json = json.dumps(crop)
|
||||||
user.responsible = responsible
|
user.responsible = responsible
|
||||||
db.add(user)
|
db.add(user)
|
||||||
record_file_security_event(
|
record_file_security_event(
|
||||||
|
|
@ -447,15 +593,19 @@ def upload_complete(
|
||||||
details={
|
details={
|
||||||
"source_mime_type": payload.mime_type,
|
"source_mime_type": payload.mime_type,
|
||||||
"source_size_bytes": int(actual_size),
|
"source_size_bytes": int(actual_size),
|
||||||
"variant": "thumb",
|
"original_key": keys["original"],
|
||||||
"variant_key": thumb_key,
|
"cropped_key": keys["cropped"],
|
||||||
"variant_mime_type": optimized_mime,
|
"thumb_key": keys["thumb"],
|
||||||
"variant_size_bytes": int(optimized_size),
|
"crop": crop,
|
||||||
},
|
},
|
||||||
responsible=responsible,
|
responsible=responsible,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return UploadCompleteResponse(status="ok", avatar_url=user.avatar_url)
|
return UploadCompleteResponse(
|
||||||
|
status="ok",
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
avatar_original_key=keys["original"],
|
||||||
|
)
|
||||||
|
|
||||||
raise HTTPException(status_code=400, detail="Неподдерживаемый scope")
|
raise HTTPException(status_code=400, detail="Неподдерживаемый scope")
|
||||||
except HTTPException as exc:
|
except HTTPException as exc:
|
||||||
|
|
@ -477,6 +627,93 @@ def upload_complete(
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/recrop", response_model=RecropResponse)
|
||||||
|
def avatar_recrop(
|
||||||
|
payload: RecropPayload,
|
||||||
|
http_request: FastapiRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||||
|
):
|
||||||
|
"""Re-apply crop parameters to an existing original avatar without re-uploading."""
|
||||||
|
role = str(admin.get("role") or "").upper() or "UNKNOWN"
|
||||||
|
actor_id = str(admin.get("sub") or "").strip()
|
||||||
|
actor_ip = _client_ip(http_request)
|
||||||
|
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
|
target_uuid_for_log: uuid.UUID | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# HIGH-2: explicit guard so an empty actor_id never silently passes the self-service check.
|
||||||
|
if not actor_id and role != "ADMIN":
|
||||||
|
raise HTTPException(status_code=401, detail="Некорректный токен")
|
||||||
|
|
||||||
|
target_uuid_for_log = _uuid_or_400(payload.user_id, "user_id")
|
||||||
|
if role != "ADMIN" and str(target_uuid_for_log) != actor_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Недостаточно прав для обрезки аватара")
|
||||||
|
|
||||||
|
user = db.get(AdminUser, target_uuid_for_log)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||||
|
if not user.avatar_original_key:
|
||||||
|
raise HTTPException(status_code=400, detail="Оригинал аватара не найден — сначала загрузите фото")
|
||||||
|
|
||||||
|
crop = _parse_crop_dict(payload.crop_json)
|
||||||
|
storage = get_s3_storage()
|
||||||
|
|
||||||
|
# Read the compressed original from S3
|
||||||
|
original_source = _read_object_bytes_or_400(storage, user.avatar_original_key)
|
||||||
|
|
||||||
|
# Re-generate cropped variants
|
||||||
|
keys = _avatar_deterministic_keys(target_uuid_for_log)
|
||||||
|
|
||||||
|
cropped_bytes = _render_avatar_cropped_webp(
|
||||||
|
original_source, crop, size_px=AVATAR_MAX_SIZE_PX, quality=AVATAR_WEBP_QUALITY
|
||||||
|
)
|
||||||
|
_write_object_bytes_or_500(storage, key=keys["cropped"], content=cropped_bytes, mime_type="image/webp")
|
||||||
|
|
||||||
|
thumb_bytes = _render_avatar_cropped_webp(
|
||||||
|
original_source, crop, size_px=AVATAR_THUMB_MAX_SIZE_PX, quality=AVATAR_THUMB_WEBP_QUALITY
|
||||||
|
)
|
||||||
|
_write_object_bytes_or_500(storage, key=keys["thumb"], content=thumb_bytes, mime_type="image/webp")
|
||||||
|
|
||||||
|
user.avatar_url = f"s3://{keys['cropped']}"
|
||||||
|
user.avatar_crop_json = json.dumps(crop)
|
||||||
|
user.responsible = responsible
|
||||||
|
db.add(user)
|
||||||
|
record_file_security_event(
|
||||||
|
db,
|
||||||
|
actor_role=role,
|
||||||
|
actor_subject=actor_id,
|
||||||
|
actor_ip=actor_ip,
|
||||||
|
action="AVATAR_RECROP",
|
||||||
|
scope="avatars",
|
||||||
|
allowed=True,
|
||||||
|
object_key=keys["cropped"],
|
||||||
|
details={"crop": crop},
|
||||||
|
responsible=responsible,
|
||||||
|
persist_now=True,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return RecropResponse(status="ok", avatar_url=user.avatar_url)
|
||||||
|
|
||||||
|
except HTTPException as exc:
|
||||||
|
# HIGH-1: log rejected recrop attempts for audit consistency.
|
||||||
|
record_file_security_event(
|
||||||
|
db,
|
||||||
|
actor_role=role,
|
||||||
|
actor_subject=actor_id,
|
||||||
|
actor_ip=actor_ip,
|
||||||
|
action="AVATAR_RECROP",
|
||||||
|
scope="avatars",
|
||||||
|
allowed=False,
|
||||||
|
reason=str(exc.detail),
|
||||||
|
object_key=None,
|
||||||
|
details={"user_id": payload.user_id},
|
||||||
|
responsible=responsible,
|
||||||
|
persist_now=True,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
@router.get("/request-attachments/{request_id}")
|
@router.get("/request-attachments/{request_id}")
|
||||||
def list_request_attachments(
|
def list_request_attachments(
|
||||||
request_id: str,
|
request_id: str,
|
||||||
|
|
@ -557,6 +794,12 @@ def get_object_proxy(
|
||||||
|
|
||||||
storage = get_s3_storage()
|
storage = get_s3_storage()
|
||||||
if scope == "avatars" and requested_variant == "thumb":
|
if scope == "avatars" and requested_variant == "thumb":
|
||||||
|
# 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")
|
thumb_key = _avatar_variant_key(key, "thumb")
|
||||||
try:
|
try:
|
||||||
obj = storage.get_object(thumb_key)
|
obj = storage.get_object(thumb_key)
|
||||||
|
|
|
||||||
|
|
@ -49,8 +49,6 @@ def list_featured_staff(
|
||||||
LandingFeaturedStaff.enabled.is_(True),
|
LandingFeaturedStaff.enabled.is_(True),
|
||||||
AdminUser.is_active.is_(True),
|
AdminUser.is_active.is_(True),
|
||||||
AdminUser.role.in_(("ADMIN", "LAWYER")),
|
AdminUser.role.in_(("ADMIN", "LAWYER")),
|
||||||
AdminUser.avatar_url.is_not(None),
|
|
||||||
and_(AdminUser.avatar_url != ""),
|
|
||||||
)
|
)
|
||||||
.order_by(
|
.order_by(
|
||||||
LandingFeaturedStaff.pinned.desc(),
|
LandingFeaturedStaff.pinned.desc(),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, JSON, Numeric, String
|
from sqlalchemy import Boolean, DateTime, JSON, Numeric, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
from app.models.common import UUIDMixin, TimestampMixin
|
from app.models.common import UUIDMixin, TimestampMixin
|
||||||
|
|
@ -13,6 +13,8 @@ class AdminUser(Base, UUIDMixin, TimestampMixin):
|
||||||
phone: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
|
phone: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
|
||||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
avatar_original_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
avatar_crop_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
primary_topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
|
primary_topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
|
||||||
default_rate: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True)
|
default_rate: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True)
|
||||||
salary_percent: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True)
|
salary_percent: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True)
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,25 @@ class UploadCompletePayload(BaseModel):
|
||||||
request_id: Optional[str] = None
|
request_id: Optional[str] = None
|
||||||
message_id: Optional[str] = None
|
message_id: Optional[str] = None
|
||||||
user_id: Optional[str] = None
|
user_id: Optional[str] = None
|
||||||
|
# Optional crop parameters for USER_AVATAR scope.
|
||||||
|
# JSON string: {"x": float, "y": float, "zoom": float}
|
||||||
|
# x/y: -1.0..1.0 (offset from center), zoom: 1.0..4.0
|
||||||
|
crop_json: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class UploadCompleteResponse(BaseModel):
|
class UploadCompleteResponse(BaseModel):
|
||||||
status: str = "ok"
|
status: str = "ok"
|
||||||
attachment_id: Optional[str] = None
|
attachment_id: Optional[str] = None
|
||||||
avatar_url: Optional[str] = None
|
avatar_url: Optional[str] = None
|
||||||
|
avatar_original_key: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RecropPayload(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
# JSON string: {"x": float, "y": float, "zoom": float}
|
||||||
|
crop_json: str
|
||||||
|
|
||||||
|
|
||||||
|
class RecropResponse(BaseModel):
|
||||||
|
status: str = "ok"
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
|
|
||||||
|
|
@ -3663,6 +3663,19 @@
|
||||||
padding-top: 0.15rem;
|
padding-top: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Crop-mode: collapse to single column, hide form panel, centre the editor */
|
||||||
|
.record-user-top--crop-mode {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-top--crop-mode .record-user-summary {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-top--crop-mode .record-user-avatar-area {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.record-user-avatar-shell {
|
.record-user-avatar-shell {
|
||||||
width: 156px;
|
width: 156px;
|
||||||
height: 156px;
|
height: 156px;
|
||||||
|
|
@ -3855,6 +3868,81 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Avatar Crop Editor ─────────────────────────────────────────────────── */
|
||||||
|
.avatar-crop-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: 0.25rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-hint {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--fg3, #7a8ea0);
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-viewport {
|
||||||
|
width: 320px;
|
||||||
|
height: 320px;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid rgba(241, 211, 163, 0.28);
|
||||||
|
background: #0a0e14;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-viewport:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-loading {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--fg3, #7a8ea0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-controls label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--fg2, #a8bbcc);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-controls input[type="range"] {
|
||||||
|
flex: 1;
|
||||||
|
accent-color: var(--accent, #f1d3a3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-controls span {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--fg3, #7a8ea0);
|
||||||
|
min-width: 2.6rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
/* ────────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.overlay {
|
.overlay {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<title>Административная панель • Правовой трекер</title>
|
<title>Административная панель • Правовой трекер</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=20260302-01">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=20260302-01">
|
||||||
<link rel="stylesheet" href="/admin.css?v=20260331-05">
|
<link rel="stylesheet" href="/admin.css?v=20260331-05">
|
||||||
|
|
|
||||||
830
app/web/admin.js
830
app/web/admin.js
File diff suppressed because one or more lines are too long
|
|
@ -1281,6 +1281,15 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
}));
|
}));
|
||||||
}, [dictionaries.users]);
|
}, [dictionaries.users]);
|
||||||
|
|
||||||
|
const getLawyerAndAdminOptions = useCallback(() => {
|
||||||
|
return (dictionaries.users || [])
|
||||||
|
.filter((item) => item && item.id && ["LAWYER", "ADMIN"].includes(String(item.role || "").toUpperCase()))
|
||||||
|
.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: (item.name || item.email || item.id) + (item.email ? " (" + item.email + ")" : ""),
|
||||||
|
}));
|
||||||
|
}, [dictionaries.users]);
|
||||||
|
|
||||||
const getFormFieldTypeOptions = useCallback(() => {
|
const getFormFieldTypeOptions = useCallback(() => {
|
||||||
return (dictionaries.formFieldTypes || []).filter(Boolean).map((item) => ({ value: item, label: item }));
|
return (dictionaries.formFieldTypes || []).filter(Boolean).map((item) => ({ value: item, label: item }));
|
||||||
}, [dictionaries.formFieldTypes]);
|
}, [dictionaries.formFieldTypes]);
|
||||||
|
|
@ -1326,7 +1335,17 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
if (rawValue == null || rawValue === "") return;
|
if (rawValue == null || rawValue === "") return;
|
||||||
const value = String(rawValue);
|
const value = String(rawValue);
|
||||||
const labelRaw = row[reference.label_field];
|
const labelRaw = row[reference.label_field];
|
||||||
const label = String(labelRaw == null || labelRaw === "" ? rawValue : labelRaw);
|
let label;
|
||||||
|
if (reference.table === "admin_users") {
|
||||||
|
const name = String(labelRaw || "").trim();
|
||||||
|
const email = String(row.email || "").trim();
|
||||||
|
if (name && email) label = name + " (" + email + ")";
|
||||||
|
else if (name) label = name;
|
||||||
|
else if (email) label = email;
|
||||||
|
else label = value;
|
||||||
|
} else {
|
||||||
|
label = String(labelRaw == null || labelRaw === "" ? rawValue : labelRaw);
|
||||||
|
}
|
||||||
if (!map.has(value)) map.set(value, label);
|
if (!map.has(value)) map.set(value, label);
|
||||||
});
|
});
|
||||||
return Array.from(map.entries())
|
return Array.from(map.entries())
|
||||||
|
|
@ -1922,6 +1941,8 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
uploadScope: "USER_AVATAR",
|
uploadScope: "USER_AVATAR",
|
||||||
accept: "image/*",
|
accept: "image/*",
|
||||||
},
|
},
|
||||||
|
{ key: "avatar_original_key", label: "Оригинал аватара", type: "text", optional: true, hidden: true },
|
||||||
|
{ key: "avatar_crop_json", label: "Кроп аватара", type: "text", optional: true, hidden: true },
|
||||||
{ key: "primary_topic_code", label: "Профиль (тема)", type: "reference", optional: true, options: getTopicOptions },
|
{ key: "primary_topic_code", label: "Профиль (тема)", type: "reference", optional: true, options: getTopicOptions },
|
||||||
{ key: "default_rate", label: "Ставка по умолчанию", type: "number", optional: true },
|
{ key: "default_rate", label: "Ставка по умолчанию", type: "number", optional: true },
|
||||||
{ key: "salary_percent", label: "Процент зарплаты", type: "number", optional: true },
|
{ key: "salary_percent", label: "Процент зарплаты", type: "number", optional: true },
|
||||||
|
|
@ -1935,6 +1956,15 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
{ key: "topic_code", label: "Дополнительная тема", type: "reference", required: true, options: getTopicOptions },
|
{ key: "topic_code", label: "Дополнительная тема", type: "reference", required: true, options: getTopicOptions },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
if (tableKey === "landing_featured_staff") {
|
||||||
|
return [
|
||||||
|
{ key: "admin_user_id", label: "Сотрудник", type: "reference", required: true, options: getLawyerAndAdminOptions },
|
||||||
|
{ key: "caption", label: "Подпись", type: "textarea", optional: true, placeholder: "Краткое описание специалиста" },
|
||||||
|
{ key: "sort_order", label: "Порядок", type: "number", defaultValue: "0" },
|
||||||
|
{ key: "pinned", label: "Закреплён", type: "boolean", defaultValue: "false" },
|
||||||
|
{ key: "enabled", label: "Отображать", type: "boolean", defaultValue: "true" },
|
||||||
|
];
|
||||||
|
}
|
||||||
const meta = tableCatalogMap[tableKey];
|
const meta = tableCatalogMap[tableKey];
|
||||||
if (!meta || !Array.isArray(meta.columns)) return [];
|
if (!meta || !Array.isArray(meta.columns)) return [];
|
||||||
return (meta.columns || [])
|
return (meta.columns || [])
|
||||||
|
|
@ -2459,7 +2489,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
);
|
);
|
||||||
|
|
||||||
const uploadRecordFieldFile = useCallback(
|
const uploadRecordFieldFile = useCallback(
|
||||||
async (field, file) => {
|
async (field, file, cropJson) => {
|
||||||
if (!recordModal.tableKey || !field || !file) return;
|
if (!recordModal.tableKey || !field || !file) return;
|
||||||
if (field.uploadScope !== "USER_AVATAR") return;
|
if (field.uploadScope !== "USER_AVATAR") return;
|
||||||
if (recordModal.tableKey !== "users") return;
|
if (recordModal.tableKey !== "users") return;
|
||||||
|
|
@ -2489,18 +2519,23 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
`Не удалось загрузить файл в хранилище (${putResp.status}${errorText ? `: ${errorText.slice(0, 200)}` : ""})`
|
`Не удалось загрузить файл в хранилище (${putResp.status}${errorText ? `: ${errorText.slice(0, 200)}` : ""})`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const done = await api("/api/admin/uploads/complete", {
|
const completeBody = {
|
||||||
method: "POST",
|
|
||||||
body: {
|
|
||||||
key: init.key,
|
key: init.key,
|
||||||
file_name: file.name,
|
file_name: file.name,
|
||||||
mime_type: mimeType,
|
mime_type: mimeType,
|
||||||
size_bytes: file.size,
|
size_bytes: file.size,
|
||||||
scope: "USER_AVATAR",
|
scope: "USER_AVATAR",
|
||||||
user_id: recordModal.rowId,
|
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 || ""));
|
updateRecordField("avatar_url", String(done.avatar_url || ""));
|
||||||
|
if (done.avatar_original_key) {
|
||||||
|
updateRecordField("avatar_original_key", String(done.avatar_original_key));
|
||||||
|
}
|
||||||
setStatus("recordForm", "Аватар загружен", "ok");
|
setStatus("recordForm", "Аватар загружен", "ok");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus("recordForm", "Ошибка загрузки: " + error.message, "error");
|
setStatus("recordForm", "Ошибка загрузки: " + error.message, "error");
|
||||||
|
|
@ -2509,6 +2544,52 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
[api, recordModal, setStatus, updateRecordField]
|
[api, recordModal, setStatus, updateRecordField]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// recropAvatar: fetches original from S3, then calls setCropFile(file) inside RecordModal
|
||||||
|
// via the provided callback, so the crop editor opens inline without full re-render.
|
||||||
|
const recropAvatar = useCallback(
|
||||||
|
async (avatarField, form, setCropFileCallback) => {
|
||||||
|
if (!recordModal.rowId || !form) return;
|
||||||
|
if (!form.avatar_original_key) {
|
||||||
|
setStatus("recordForm", "Оригинал аватара не найден", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setStatus("recordForm", "Загрузка оригинала...", "");
|
||||||
|
const src = `/api/admin/uploads/object/${encodeURIComponent(form.avatar_original_key)}?token=${encodeURIComponent(token)}`;
|
||||||
|
const resp = await fetch(src);
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const file = new File([blob], "original.webp", { type: blob.type || "image/webp" });
|
||||||
|
setStatus("recordForm", "", "");
|
||||||
|
if (setCropFileCallback) setCropFileCallback(file);
|
||||||
|
} catch (err) {
|
||||||
|
setStatus("recordForm", "Не удалось загрузить оригинал: " + err.message, "error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[recordModal.rowId, setStatus, token]
|
||||||
|
);
|
||||||
|
|
||||||
|
// applyRecrop: called after user confirms new crop from the re-crop editor.
|
||||||
|
// Sends only the crop params to /recrop (no file re-upload needed).
|
||||||
|
const applyRecrop = useCallback(
|
||||||
|
async (cropJson) => {
|
||||||
|
if (!recordModal.rowId) return;
|
||||||
|
try {
|
||||||
|
setStatus("recordForm", "Применение кадрирования...", "");
|
||||||
|
const done = await api("/api/admin/uploads/recrop", {
|
||||||
|
method: "POST",
|
||||||
|
body: { user_id: recordModal.rowId, crop_json: JSON.stringify(cropJson) },
|
||||||
|
});
|
||||||
|
// Force browser to reload the avatar by appending a cache-bust param
|
||||||
|
updateRecordField("avatar_url", String(done.avatar_url || "") + "?t=" + Date.now());
|
||||||
|
setStatus("recordForm", "Кадрирование обновлено", "ok");
|
||||||
|
} catch (err) {
|
||||||
|
setStatus("recordForm", "Ошибка кадрирования: " + err.message, "error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, recordModal.rowId, setStatus, updateRecordField]
|
||||||
|
);
|
||||||
|
|
||||||
const buildRecordPayload = useCallback(
|
const buildRecordPayload = useCallback(
|
||||||
(tableKey, form, mode) => {
|
(tableKey, form, mode) => {
|
||||||
const fields = getRecordFields(tableKey);
|
const fields = getRecordFields(tableKey);
|
||||||
|
|
@ -2519,6 +2600,8 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
fields.forEach((field) => {
|
fields.forEach((field) => {
|
||||||
if (isLawyerRequestEdit && field.key !== "topic_code") return;
|
if (isLawyerRequestEdit && field.key !== "topic_code") return;
|
||||||
if (isAdminRequestEdit && adminRequestRestricted.has(field.key)) return;
|
if (isAdminRequestEdit && adminRequestRestricted.has(field.key)) return;
|
||||||
|
// Hidden fields are for frontend state only — never sent to the server.
|
||||||
|
if (field.hidden) return;
|
||||||
const raw = form[field.key];
|
const raw = form[field.key];
|
||||||
if (field.type === "boolean") {
|
if (field.type === "boolean") {
|
||||||
payload[field.key] = raw === "true";
|
payload[field.key] = raw === "true";
|
||||||
|
|
@ -4254,6 +4337,9 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
onClose={closeRecordModal}
|
onClose={closeRecordModal}
|
||||||
onChange={updateRecordField}
|
onChange={updateRecordField}
|
||||||
onUploadField={uploadRecordFieldFile}
|
onUploadField={uploadRecordFieldFile}
|
||||||
|
onUploadFieldWithCrop={uploadRecordFieldFile}
|
||||||
|
onRecropAvatar={recropAvatar}
|
||||||
|
onApplyRecrop={applyRecrop}
|
||||||
onSubmit={submitRecordModal}
|
onSubmit={submitRecordModal}
|
||||||
OverlayComponent={Overlay}
|
OverlayComponent={Overlay}
|
||||||
IconButtonComponent={IconButton}
|
IconButtonComponent={IconButton}
|
||||||
|
|
|
||||||
227
app/web/admin/shared/AvatarCropEditor.jsx
Normal file
227
app/web/admin/shared/AvatarCropEditor.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { DropdownField } from "./DropdownField.jsx";
|
import { DropdownField } from "./DropdownField.jsx";
|
||||||
import { resolveAvatarSrc, roleLabel } from "./utils.js";
|
import { resolveAvatarSrc, roleLabel } from "./utils.js";
|
||||||
|
import { AvatarCropEditor } from "./AvatarCropEditor.jsx";
|
||||||
|
|
||||||
const { useEffect, useRef, useState } = React;
|
const { useEffect, useRef, useState } = React;
|
||||||
|
|
||||||
|
|
@ -16,6 +17,9 @@ export function RecordModal({
|
||||||
onChange,
|
onChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onUploadField,
|
onUploadField,
|
||||||
|
onUploadFieldWithCrop,
|
||||||
|
onRecropAvatar,
|
||||||
|
onApplyRecrop,
|
||||||
OverlayComponent,
|
OverlayComponent,
|
||||||
IconButtonComponent,
|
IconButtonComponent,
|
||||||
UserAvatarComponent,
|
UserAvatarComponent,
|
||||||
|
|
@ -27,8 +31,12 @@ export function RecordModal({
|
||||||
const StatusLine = StatusLineComponent;
|
const StatusLine = StatusLineComponent;
|
||||||
const [avatarPreviewOpen, setAvatarPreviewOpen] = useState(false);
|
const [avatarPreviewOpen, setAvatarPreviewOpen] = useState(false);
|
||||||
const [userEditing, setUserEditing] = useState(false);
|
const [userEditing, setUserEditing] = useState(false);
|
||||||
|
const [cropFile, setCropFile] = useState(null); // File waiting for crop selection
|
||||||
|
const [cropInitial, setCropInitial] = useState(null); // {x,y,zoom} restored from avatar_crop_json
|
||||||
|
const isRecropRef = useRef(false); // true when cropFile came from existing original (re-crop flow)
|
||||||
const avatarUploadRef = useRef(null);
|
const avatarUploadRef = useRef(null);
|
||||||
const visibleFields = (fields || []).filter((field) => {
|
const visibleFields = (fields || []).filter((field) => {
|
||||||
|
if (field.hidden) return false;
|
||||||
if (typeof field.visibleWhen !== "function") return true;
|
if (typeof field.visibleWhen !== "function") return true;
|
||||||
try {
|
try {
|
||||||
return Boolean(field.visibleWhen(form || {}));
|
return Boolean(field.visibleWhen(form || {}));
|
||||||
|
|
@ -259,8 +267,35 @@ export function RecordModal({
|
||||||
</div>
|
</div>
|
||||||
<form className={"stack" + (isUserModal ? " record-user-scroll" : "")} id="record-modal-form" onSubmit={onSubmit}>
|
<form className={"stack" + (isUserModal ? " record-user-scroll" : "")} id="record-modal-form" onSubmit={onSubmit}>
|
||||||
{isUserModal ? (
|
{isUserModal ? (
|
||||||
<div className="record-user-top">
|
<div className={"record-user-top" + (cropFile ? " record-user-top--crop-mode" : "")}>
|
||||||
<div className="record-user-avatar-area">
|
<div className="record-user-avatar-area">
|
||||||
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={"record-user-avatar-shell" + (avatarPreviewSrc ? " interactive" : "")}
|
className={"record-user-avatar-shell" + (avatarPreviewSrc ? " interactive" : "")}
|
||||||
|
|
@ -281,7 +316,10 @@ export function RecordModal({
|
||||||
style={{ display: "none" }}
|
style={{ display: "none" }}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const file = event.target.files && event.target.files[0];
|
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 = "";
|
event.target.value = "";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -295,6 +333,28 @@ export function RecordModal({
|
||||||
tooltip="Загрузить аватар"
|
tooltip="Загрузить аватар"
|
||||||
onClick={() => avatarUploadRef.current?.click()}
|
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
|
<IconButton
|
||||||
icon={
|
icon={
|
||||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
|
||||||
|
|
@ -311,6 +371,8 @@ export function RecordModal({
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="record-user-summary">
|
<div className="record-user-summary">
|
||||||
<div className="record-user-summary-head">
|
<div className="record-user-summary-head">
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<title>Страница клиента • Правовой трекер</title>
|
<title>Страница клиента • Правовой трекер</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=20260302-01">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=20260302-01">
|
||||||
<link rel="stylesheet" href="/admin.css?v=20260317-01">
|
<link rel="stylesheet" href="/admin.css?v=20260317-01">
|
||||||
|
|
|
||||||
|
|
@ -792,90 +792,143 @@
|
||||||
.featured-team-track {
|
.featured-team-track {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-auto-flow: column;
|
grid-auto-flow: column;
|
||||||
grid-auto-columns: minmax(280px, 34%);
|
grid-auto-columns: 220px;
|
||||||
gap: 0.85rem;
|
gap: 1rem;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
scroll-snap-type: x proximity;
|
scroll-snap-type: x mandatory;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: none;
|
||||||
padding: 0.15rem 0.1rem 0.4rem;
|
padding: 0.5rem 0.25rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-team-track::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.featured-card {
|
.featured-card {
|
||||||
scroll-snap-align: start;
|
scroll-snap-align: start;
|
||||||
border: 1px solid var(--line);
|
position: relative;
|
||||||
border-radius: 16px;
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
background: linear-gradient(165deg, rgba(32, 43, 57, 0.95), rgba(17, 24, 32, 0.96));
|
border-radius: 20px;
|
||||||
display: grid;
|
background: linear-gradient(175deg, rgba(30, 42, 58, 0.96), rgba(16, 23, 33, 0.98));
|
||||||
grid-template-columns: 150px 1fr;
|
display: flex;
|
||||||
gap: 0.95rem;
|
flex-direction: column;
|
||||||
padding: 0.85rem;
|
align-items: center;
|
||||||
min-height: 188px;
|
gap: 0;
|
||||||
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.18);
|
padding: 1.4rem 1rem 1.2rem;
|
||||||
animation: rise 0.45s ease forwards;
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.28), 0 1px 0 rgba(255,255,255,0.04) inset;
|
||||||
|
animation: rise 0.42s ease forwards;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(8px);
|
transform: translateY(10px);
|
||||||
|
transition: box-shadow 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-card:hover {
|
||||||
|
border-color: rgba(212, 169, 104, 0.25);
|
||||||
|
box-shadow: 0 14px 42px rgba(0, 0, 0, 0.38), 0 0 0 1px rgba(212, 169, 104, 0.12);
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avatar wrapper keeps the golden ring glow */
|
||||||
|
.featured-avatar-wrap {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.featured-avatar {
|
.featured-avatar {
|
||||||
width: 146px;
|
width: 96px;
|
||||||
height: 146px;
|
height: 96px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.24);
|
border: 2px solid rgba(212, 169, 104, 0.45);
|
||||||
box-shadow: 0 0 0 1px rgba(212, 169, 104, 0.42), 0 0 0 5px rgba(212, 169, 104, 0.08);
|
box-shadow: 0 0 0 4px rgba(212, 169, 104, 0.1), 0 6px 18px rgba(0,0,0,0.35);
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(30, 42, 58, 0.8);
|
||||||
align-self: center;
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Initials placeholder when no avatar */
|
||||||
|
.featured-avatar-initials {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(212, 169, 104, 0.45);
|
||||||
|
box-shadow: 0 0 0 4px rgba(212, 169, 104, 0.1), 0 6px 18px rgba(0,0,0,0.35);
|
||||||
|
background: linear-gradient(135deg, #1e3558, #0f1e32);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(212, 169, 104, 0.85);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-chip {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.16rem 0.44rem;
|
||||||
|
border: 1px solid rgba(212, 169, 104, 0.4);
|
||||||
|
background: rgba(212, 169, 104, 0.14);
|
||||||
|
color: #f4d7a8;
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.featured-card-body {
|
.featured-card-body {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
width: 100%;
|
||||||
align-content: start;
|
display: flex;
|
||||||
gap: 0.35rem;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.featured-card-top {
|
.featured-card-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: center;
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.featured-card-top h3 {
|
.featured-card-top h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 0.95rem;
|
||||||
line-height: 1.25;
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
color: #eef4ff;
|
color: #eef4ff;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.featured-chip {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0.18rem 0.5rem;
|
|
||||||
border: 1px solid rgba(212, 169, 104, 0.35);
|
|
||||||
background: rgba(212, 169, 104, 0.12);
|
|
||||||
color: #f4d7a8;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 700;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-meta {
|
.featured-meta {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #a6b5c8;
|
padding: 0.2rem 0.6rem;
|
||||||
font-size: 0.84rem;
|
border-radius: 999px;
|
||||||
line-height: 1.35;
|
background: rgba(37, 99, 235, 0.16);
|
||||||
|
border: 1px solid rgba(37, 99, 235, 0.28);
|
||||||
|
color: #93b4e8;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.3;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.featured-caption {
|
.featured-caption {
|
||||||
margin: 0.1rem 0 0;
|
margin: 0.2rem 0 0;
|
||||||
color: #dde8f6;
|
color: #8ea5be;
|
||||||
font-size: 0.9rem;
|
font-size: 0.81rem;
|
||||||
line-height: 1.5;
|
line-height: 1.55;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.featured-caption > :first-child {
|
.featured-caption > :first-child {
|
||||||
|
|
@ -887,7 +940,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.featured-caption p {
|
.featured-caption p {
|
||||||
margin: 0.14rem 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.featured-caption ul {
|
.featured-caption ul {
|
||||||
|
|
@ -979,7 +1032,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.featured-team-track {
|
.featured-team-track {
|
||||||
grid-auto-columns: minmax(300px, 52%);
|
grid-auto-columns: 200px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1064,7 +1117,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.featured-team-track {
|
.featured-team-track {
|
||||||
grid-auto-columns: 86%;
|
grid-auto-columns: 186px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.simple-list {
|
.simple-list {
|
||||||
|
|
@ -1090,14 +1143,14 @@
|
||||||
top: calc(100% - 3px);
|
top: calc(100% - 3px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.featured-card {
|
.featured-team-track {
|
||||||
grid-template-columns: 114px 1fr;
|
grid-auto-columns: 172px;
|
||||||
min-height: 156px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.featured-avatar {
|
.featured-avatar,
|
||||||
width: 106px;
|
.featured-avatar-initials {
|
||||||
height: 106px;
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,94 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Аудиторы корпоративной безопасности</title>
|
|
||||||
<meta name="description" content="Юридический консалтинг и судебное сопровождение для сложных бизнес-ситуаций.">
|
<!-- Primary SEO -->
|
||||||
|
<title>Аудиторы корпоративной безопасности — юридический консалтинг и судебное сопровождение</title>
|
||||||
|
<meta name="description" content="Консалтинговая компания «Аудиторы корпоративной безопасности» — судебное сопровождение, банкротство, сделки, кадровые споры и GR. 28 лет опыта. Первая консультация онлайн.">
|
||||||
|
<meta name="keywords" content="юридический консалтинг, судебное сопровождение, банкротство бизнеса, корпоративная безопасность, защита активов, трудовые споры, арбитраж">
|
||||||
|
<meta name="robots" content="index, follow">
|
||||||
|
<link rel="canonical" href="https://ruakb.online/">
|
||||||
|
|
||||||
|
<!-- Open Graph / Facebook -->
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:url" content="https://ruakb.online/">
|
||||||
|
<meta property="og:title" content="Аудиторы корпоративной безопасности — юридический консалтинг">
|
||||||
|
<meta property="og:description" content="Судебное сопровождение, банкротство, сделки и GR-поддержка. 28 лет опыта. Первая консультация онлайн лично с директором.">
|
||||||
|
<meta property="og:image" content="https://ruakb.online/og-image.jpg">
|
||||||
|
<meta property="og:image:width" content="1200">
|
||||||
|
<meta property="og:image:height" content="630">
|
||||||
|
<meta property="og:locale" content="ru_RU">
|
||||||
|
<meta property="og:site_name" content="Аудиторы корпоративной безопасности">
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="Аудиторы корпоративной безопасности">
|
||||||
|
<meta name="twitter:description" content="Юридический консалтинг и судебное сопровождение. 28 лет опыта. Первая консультация онлайн.">
|
||||||
|
<meta name="twitter:image" content="https://ruakb.online/og-image.jpg">
|
||||||
|
|
||||||
|
<!-- Geo / Local SEO -->
|
||||||
|
<meta name="geo.region" content="RU">
|
||||||
|
<meta name="geo.placename" content="Россия">
|
||||||
|
|
||||||
|
<!-- Structured Data: Organization + LegalService -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@graph": [
|
||||||
|
{
|
||||||
|
"@type": "Organization",
|
||||||
|
"@id": "https://ruakb.online/#organization",
|
||||||
|
"name": "Аудиторы корпоративной безопасности",
|
||||||
|
"url": "https://ruakb.online/",
|
||||||
|
"logo": {
|
||||||
|
"@type": "ImageObject",
|
||||||
|
"url": "https://ruakb.online/brand-mark.svg"
|
||||||
|
},
|
||||||
|
"description": "Консалтинговая компания по юридическому сопровождению бизнеса: судебные споры, банкротство, сделки, трудовые конфликты, GR.",
|
||||||
|
"foundingDate": "1997",
|
||||||
|
"areaServed": "RU",
|
||||||
|
"contactPoint": {
|
||||||
|
"@type": "ContactPoint",
|
||||||
|
"contactType": "customer support",
|
||||||
|
"availableLanguage": "Russian"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "LegalService",
|
||||||
|
"@id": "https://ruakb.online/#legalservice",
|
||||||
|
"name": "Аудиторы корпоративной безопасности",
|
||||||
|
"url": "https://ruakb.online/",
|
||||||
|
"provider": { "@id": "https://ruakb.online/#organization" },
|
||||||
|
"serviceType": [
|
||||||
|
"Судебное сопровождение",
|
||||||
|
"Арбитраж",
|
||||||
|
"Банкротство и антикризис",
|
||||||
|
"Сделки и защита активов",
|
||||||
|
"Кадровые споры",
|
||||||
|
"GR-сопровождение"
|
||||||
|
],
|
||||||
|
"areaServed": {
|
||||||
|
"@type": "Country",
|
||||||
|
"name": "Россия"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "WebSite",
|
||||||
|
"@id": "https://ruakb.online/#website",
|
||||||
|
"url": "https://ruakb.online/",
|
||||||
|
"name": "Аудиторы корпоративной безопасности",
|
||||||
|
"inLanguage": "ru"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Performance hints -->
|
||||||
|
<link rel="preconnect" href="https://ruakb.online">
|
||||||
|
<link rel="dns-prefetch" href="https://ruakb.online">
|
||||||
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=20260302-01">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=20260302-01">
|
||||||
|
<link rel="shortcut icon" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="/landing.css">
|
<link rel="stylesheet" href="/landing.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -185,7 +270,12 @@
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
ООО «Аудиторы корпоративной безопасности» • Юридический консалтинг и судебное сопровождение
|
<address>
|
||||||
|
<strong>ООО «Аудиторы корпоративной безопасности»</strong>
|
||||||
|
— юридический консалтинг и судебное сопровождение
|
||||||
|
</address>
|
||||||
|
<small>© <span id="footer-year">2026</span> Все права защищены</small>
|
||||||
|
<script>document.getElementById('footer-year').textContent = new Date().getFullYear();</script>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<div class="modal-backdrop" id="request-modal" aria-hidden="true">
|
<div class="modal-backdrop" id="request-modal" aria-hidden="true">
|
||||||
|
|
|
||||||
|
|
@ -522,16 +522,42 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
featuredTeamTrack.innerHTML = "";
|
featuredTeamTrack.innerHTML = "";
|
||||||
items.forEach((item) => {
|
items.forEach((item, idx) => {
|
||||||
const card = document.createElement("article");
|
const card = document.createElement("article");
|
||||||
card.className = "featured-card";
|
card.className = "featured-card";
|
||||||
|
card.style.animationDelay = (idx * 0.06) + "s";
|
||||||
|
|
||||||
|
// 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");
|
const avatar = document.createElement("img");
|
||||||
avatar.className = "featured-avatar";
|
avatar.className = "featured-avatar";
|
||||||
avatar.src = String(item.avatar_url || "");
|
avatar.src = rawAvatarUrl;
|
||||||
avatar.alt = String(item.name || "Сотрудник");
|
avatar.alt = String(item.name || "Сотрудник");
|
||||||
avatar.loading = "lazy";
|
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");
|
const body = document.createElement("div");
|
||||||
body.className = "featured-card-body";
|
body.className = "featured-card-body";
|
||||||
|
|
@ -541,12 +567,6 @@
|
||||||
const name = document.createElement("h3");
|
const name = document.createElement("h3");
|
||||||
name.textContent = String(item.name || "Сотрудник");
|
name.textContent = String(item.name || "Сотрудник");
|
||||||
top.appendChild(name);
|
top.appendChild(name);
|
||||||
if (item.pinned) {
|
|
||||||
const chip = document.createElement("span");
|
|
||||||
chip.className = "featured-chip";
|
|
||||||
chip.textContent = "Рекомендуем";
|
|
||||||
top.appendChild(chip);
|
|
||||||
}
|
|
||||||
body.appendChild(top);
|
body.appendChild(top);
|
||||||
|
|
||||||
const metaText = String(item.primary_topic_name || "").trim();
|
const metaText = String(item.primary_topic_name || "").trim();
|
||||||
|
|
@ -557,11 +577,13 @@
|
||||||
body.appendChild(meta);
|
body.appendChild(meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const captionText = String(item.caption || "").trim();
|
||||||
|
if (captionText) {
|
||||||
const caption = document.createElement("div");
|
const caption = document.createElement("div");
|
||||||
caption.className = "featured-caption";
|
caption.className = "featured-caption";
|
||||||
const captionText = String(item.caption || "").trim() || "Практический опыт в сложных юридических делах и сопровождении споров.";
|
|
||||||
caption.innerHTML = markdownToHtml(captionText);
|
caption.innerHTML = markdownToHtml(captionText);
|
||||||
body.appendChild(caption);
|
body.appendChild(caption);
|
||||||
|
}
|
||||||
|
|
||||||
card.appendChild(body);
|
card.appendChild(body);
|
||||||
featuredTeamTrack.appendChild(card);
|
featuredTeamTrack.appendChild(card);
|
||||||
|
|
|
||||||
BIN
app/web/og-image.jpg
Normal file
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
8
app/web/robots.txt
Normal 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
15
app/web/sitemap.xml
Normal 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>
|
||||||
|
|
@ -246,6 +246,18 @@ async function createRequestViaLanding(page, options = {}) {
|
||||||
throw new Error("Не найдена доступная тема для E2E-создания заявки");
|
throw new Error("Не найдена доступная тема для E2E-создания заявки");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure the CREATE_REQUEST cookie is active before posting.
|
||||||
|
// A prior createRequestViaLanding call may have replaced it with VIEW_REQUEST.
|
||||||
|
await page.context().addCookies([
|
||||||
|
{
|
||||||
|
name: PUBLIC_COOKIE_NAME,
|
||||||
|
value: createPublicCookieToken(phone),
|
||||||
|
url: `${baseUrl}/`,
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "Lax",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
const createResponse = await page.request.post(`${baseUrl}/api/public/requests`, {
|
const createResponse = await page.request.post(`${baseUrl}/api/public/requests`, {
|
||||||
headers: {
|
headers: {
|
||||||
Origin: baseUrl,
|
Origin: baseUrl,
|
||||||
|
|
|
||||||
414
e2e/tests/showcase_admin_flow.spec.js
Normal file
414
e2e/tests/showcase_admin_flow.spec.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
410
e2e/tests/showcase_client_flow.spec.js
Normal file
410
e2e/tests/showcase_client_flow.spec.js
Normal 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("");
|
||||||
|
});
|
||||||
364
e2e/tests/showcase_lawyer_flow.spec.js
Normal file
364
e2e/tests/showcase_lawyer_flow.spec.js
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue