mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
test new design 04
This commit is contained in:
parent
71047a46b0
commit
1c908ade7b
29 changed files with 2962 additions and 725 deletions
|
|
@ -20,6 +20,7 @@
|
||||||
|
|
||||||
### Frontend Areas
|
### Frontend Areas
|
||||||
- `app/web/admin/`: admin/lawyer UI source modules.
|
- `app/web/admin/`: admin/lawyer UI source modules.
|
||||||
|
- `app/web/admin/shared/`: shared admin UI primitives and helpers, including custom dropdowns used instead of native `select` in key admin flows.
|
||||||
- `app/web/client.jsx`: client cabinet entry.
|
- `app/web/client.jsx`: client cabinet entry.
|
||||||
- `app/web/admin.js`, `app/web/client.js`: built bundles.
|
- `app/web/admin.js`, `app/web/client.js`: built bundles.
|
||||||
|
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -12,3 +12,4 @@ celerybeat-schedule
|
||||||
celerybeat-schedule.*
|
celerybeat-schedule.*
|
||||||
deploy/tls/minio/*
|
deploy/tls/minio/*
|
||||||
!deploy/tls/minio/.gitkeep
|
!deploy/tls/minio/.gitkeep
|
||||||
|
.claude
|
||||||
13
Makefile
13
Makefile
|
|
@ -1,9 +1,9 @@
|
||||||
.PHONY: \
|
.PHONY: \
|
||||||
help \
|
help \
|
||||||
local-up local-down local-logs local-migrate local-test local-seed local-seed-statuses local-seed-catalog \
|
local-up local-down local-logs local-migrate local-test local-seed local-seed-statuses local-seed-catalog \
|
||||||
local-reencrypt-active-kid \
|
local-reencrypt-active-kid local-s3-proxy-smoke \
|
||||||
prod-up prod-down prod-logs prod-ps prod-migrate \
|
prod-up prod-down prod-logs prod-ps prod-migrate \
|
||||||
prod-seed-statuses prod-seed-catalog \
|
prod-seed-statuses prod-seed-catalog prod-s3-proxy-smoke \
|
||||||
prod-secrets-generate prod-secrets-apply prod-secrets-generate-env prod-secrets-apply-env \
|
prod-secrets-generate prod-secrets-apply prod-secrets-generate-env prod-secrets-apply-env \
|
||||||
prod-minio-tls-init incident-checklist rotate-encryption-kid reencrypt-active-kid prod-reencrypt-active-kid \
|
prod-minio-tls-init incident-checklist rotate-encryption-kid reencrypt-active-kid prod-reencrypt-active-kid \
|
||||||
security-smoke prod-security-audit prod-security-scheduler-up prod-security-scheduler-logs \
|
security-smoke prod-security-audit prod-security-scheduler-up prod-security-scheduler-logs \
|
||||||
|
|
@ -39,6 +39,7 @@ help:
|
||||||
@echo " local-seed-statuses - Seed legal flow statuses (local)"
|
@echo " local-seed-statuses - Seed legal flow statuses (local)"
|
||||||
@echo " local-seed-catalog - Seed quotes + legal flow statuses (local)"
|
@echo " local-seed-catalog - Seed quotes + legal flow statuses (local)"
|
||||||
@echo " local-reencrypt-active-kid - Re-encrypt historical chat/invoice/admin secrets using active KID (local)"
|
@echo " local-reencrypt-active-kid - Re-encrypt historical chat/invoice/admin secrets using active KID (local)"
|
||||||
|
@echo " local-s3-proxy-smoke - Smoke-test PUT upload path through frontend /s3 proxy (local)"
|
||||||
@echo " prod-up - Start production stack (nginx 80/443 + TLS certs already issued)"
|
@echo " prod-up - Start production stack (nginx 80/443 + TLS certs already issued)"
|
||||||
@echo " prod-down - Stop production stack"
|
@echo " prod-down - Stop production stack"
|
||||||
@echo " prod-logs - Tail production logs"
|
@echo " prod-logs - Tail production logs"
|
||||||
|
|
@ -46,6 +47,7 @@ help:
|
||||||
@echo " prod-migrate - Apply migrations (prod)"
|
@echo " prod-migrate - Apply migrations (prod)"
|
||||||
@echo " prod-seed-statuses - Seed legal flow statuses (prod)"
|
@echo " prod-seed-statuses - Seed legal flow statuses (prod)"
|
||||||
@echo " prod-seed-catalog - Seed quotes + legal flow statuses (prod)"
|
@echo " prod-seed-catalog - Seed quotes + legal flow statuses (prod)"
|
||||||
|
@echo " prod-s3-proxy-smoke - Smoke-test PUT upload path through frontend /s3 proxy (prod)"
|
||||||
@echo " prod-secrets-generate - Generate rotated internal secrets into .env.prod"
|
@echo " prod-secrets-generate - Generate rotated internal secrets into .env.prod"
|
||||||
@echo " prod-secrets-apply - Generate + apply rotated internal secrets to running prod stack"
|
@echo " prod-secrets-apply - Generate + apply rotated internal secrets to running prod stack"
|
||||||
@echo " prod-secrets-generate-env - Generate rotated secrets from current .env into .env.secure"
|
@echo " prod-secrets-generate-env - Generate rotated secrets from current .env into .env.secure"
|
||||||
|
|
@ -101,10 +103,14 @@ local-seed-catalog:
|
||||||
local-reencrypt-active-kid:
|
local-reencrypt-active-kid:
|
||||||
$(LOCAL_COMPOSE) exec -T backend python -m app.scripts.reencrypt_with_active_kid --apply
|
$(LOCAL_COMPOSE) exec -T backend python -m app.scripts.reencrypt_with_active_kid --apply
|
||||||
|
|
||||||
|
local-s3-proxy-smoke:
|
||||||
|
./scripts/ops/s3_proxy_upload_smoke.sh http://localhost:8081
|
||||||
|
|
||||||
check-prod-files:
|
check-prod-files:
|
||||||
@test -f docker-compose.prod.nginx.yml || (echo "[ERROR] Missing docker-compose.prod.nginx.yml. Run: git pull"; exit 1)
|
@test -f docker-compose.prod.nginx.yml || (echo "[ERROR] Missing docker-compose.prod.nginx.yml. Run: git pull"; exit 1)
|
||||||
@test -f frontend/nginx.prod.conf || (echo "[ERROR] Missing frontend/nginx.prod.conf. Run: git pull"; exit 1)
|
@test -f frontend/nginx.prod.conf || (echo "[ERROR] Missing frontend/nginx.prod.conf. Run: git pull"; exit 1)
|
||||||
@test -f scripts/ops/minio_tls_bootstrap.sh || (echo "[ERROR] Missing scripts/ops/minio_tls_bootstrap.sh. Run: git pull"; exit 1)
|
@test -f scripts/ops/minio_tls_bootstrap.sh || (echo "[ERROR] Missing scripts/ops/minio_tls_bootstrap.sh. Run: git pull"; exit 1)
|
||||||
|
@test -f scripts/ops/s3_proxy_upload_smoke.sh || (echo "[ERROR] Missing scripts/ops/s3_proxy_upload_smoke.sh. Run: git pull"; exit 1)
|
||||||
|
|
||||||
check-cert-files: check-prod-files
|
check-cert-files: check-prod-files
|
||||||
@test -f docker-compose.prod.cert.yml || (echo "[ERROR] Missing docker-compose.prod.cert.yml. Run: git pull"; exit 1)
|
@test -f docker-compose.prod.cert.yml || (echo "[ERROR] Missing docker-compose.prod.cert.yml. Run: git pull"; exit 1)
|
||||||
|
|
@ -134,6 +140,9 @@ prod-seed-catalog: check-prod-files
|
||||||
$(PROD_COMPOSE) exec -T backend python -m app.scripts.upsert_quotes
|
$(PROD_COMPOSE) exec -T backend python -m app.scripts.upsert_quotes
|
||||||
$(PROD_COMPOSE) exec -T backend python -m app.scripts.upsert_statuses_legal_flow
|
$(PROD_COMPOSE) exec -T backend python -m app.scripts.upsert_statuses_legal_flow
|
||||||
|
|
||||||
|
prod-s3-proxy-smoke: check-prod-files
|
||||||
|
COMPOSE_OVERRIDE=docker-compose.prod.nginx.yml ./scripts/ops/s3_proxy_upload_smoke.sh https://$(SECOND_DOMAIN)
|
||||||
|
|
||||||
prod-secrets-generate:
|
prod-secrets-generate:
|
||||||
./scripts/ops/rotate_prod_secrets.sh --env-in .env.production --env-out .env.prod
|
./scripts/ops/rotate_prod_secrets.sh --env-in .env.production --env-out .env.prod
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from typing import Any
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import DataError, IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.admin_user import AdminUser
|
from app.models.admin_user import AdminUser
|
||||||
|
|
@ -34,6 +34,10 @@ from app.services.request_read_markers import (
|
||||||
mark_unread_for_lawyer,
|
mark_unread_for_lawyer,
|
||||||
)
|
)
|
||||||
from app.services.request_deadline import initial_important_date_at
|
from app.services.request_deadline import initial_important_date_at
|
||||||
|
from app.services.request_finance_validation import (
|
||||||
|
normalize_request_financial_payload_or_400,
|
||||||
|
request_financial_data_error_or_400,
|
||||||
|
)
|
||||||
from app.services.request_status import apply_status_change_effects
|
from app.services.request_status import apply_status_change_effects
|
||||||
from app.services.request_templates import validate_required_topic_fields_or_400
|
from app.services.request_templates import validate_required_topic_fields_or_400
|
||||||
from app.services.status_flow import transition_allowed_for_topic
|
from app.services.status_flow import transition_allowed_for_topic
|
||||||
|
|
@ -318,6 +322,7 @@ def create_row_service(table_name: str, payload: dict[str, Any], db: Session, ad
|
||||||
prepared["immutable"] = False
|
prepared["immutable"] = False
|
||||||
prepared["request_id"] = request_uuid
|
prepared["request_id"] = request_uuid
|
||||||
if normalized == "requests":
|
if normalized == "requests":
|
||||||
|
prepared = normalize_request_financial_payload_or_400(prepared)
|
||||||
validate_required_topic_fields_or_400(db, prepared.get("topic_code"), prepared.get("extra_fields"))
|
validate_required_topic_fields_or_400(db, prepared.get("topic_code"), prepared.get("extra_fields"))
|
||||||
client = _upsert_client_or_400(
|
client = _upsert_client_or_400(
|
||||||
db,
|
db,
|
||||||
|
|
@ -385,6 +390,9 @@ def create_row_service(table_name: str, payload: dict[str, Any], db: Session, ad
|
||||||
_append_audit(db, admin, normalized, str(snapshot.get("id") or ""), "CREATE", {"after": snapshot})
|
_append_audit(db, admin, normalized, str(snapshot.get("id") or ""), "CREATE", {"after": snapshot})
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
|
except DataError:
|
||||||
|
db.rollback()
|
||||||
|
raise request_financial_data_error_or_400()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise _integrity_error()
|
raise _integrity_error()
|
||||||
|
|
@ -457,6 +465,7 @@ def update_row_service(table_name: str, row_id: str, payload: dict[str, Any], db
|
||||||
if normalized == "statuses":
|
if normalized == "statuses":
|
||||||
clean_payload = _apply_status_fields(db, clean_payload)
|
clean_payload = _apply_status_fields(db, clean_payload)
|
||||||
if normalized == "requests" and isinstance(row, Request):
|
if normalized == "requests" and isinstance(row, Request):
|
||||||
|
clean_payload = normalize_request_financial_payload_or_400(clean_payload)
|
||||||
if {"client_name", "client_phone"}.intersection(set(clean_payload.keys())) or row.client_id is None:
|
if {"client_name", "client_phone"}.intersection(set(clean_payload.keys())) or row.client_id is None:
|
||||||
client = _upsert_client_or_400(
|
client = _upsert_client_or_400(
|
||||||
db,
|
db,
|
||||||
|
|
@ -579,6 +588,9 @@ def update_row_service(table_name: str, row_id: str, payload: dict[str, Any], db
|
||||||
_append_audit(db, admin, normalized, str(after.get("id") or row_id), "UPDATE", {"before": before, "after": after})
|
_append_audit(db, admin, normalized, str(after.get("id") or row_id), "UPDATE", {"before": before, "after": after})
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
|
except DataError:
|
||||||
|
db.rollback()
|
||||||
|
raise request_financial_data_error_or_400()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise _integrity_error()
|
raise _integrity_error()
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import case, func, or_, update
|
from sqlalchemy import case, func, or_, update
|
||||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
from sqlalchemy.exc import DataError, IntegrityError, SQLAlchemyError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.admin_user import AdminUser
|
from app.models.admin_user import AdminUser
|
||||||
|
|
@ -39,6 +39,10 @@ from app.services.request_read_markers import (
|
||||||
mark_unread_for_client,
|
mark_unread_for_client,
|
||||||
)
|
)
|
||||||
from app.services.request_deadline import initial_important_date_at
|
from app.services.request_deadline import initial_important_date_at
|
||||||
|
from app.services.request_finance_validation import (
|
||||||
|
normalize_request_financial_payload_or_400,
|
||||||
|
request_financial_data_error_or_400,
|
||||||
|
)
|
||||||
from app.services.request_status import apply_status_change_effects
|
from app.services.request_status import apply_status_change_effects
|
||||||
from app.services.request_templates import validate_required_topic_fields_or_400
|
from app.services.request_templates import validate_required_topic_fields_or_400
|
||||||
from app.services.status_flow import transition_allowed_for_topic
|
from app.services.status_flow import transition_allowed_for_topic
|
||||||
|
|
@ -187,8 +191,9 @@ def create_request_service(payload: RequestAdminCreate, db: Session, admin: dict
|
||||||
client_phone=payload.client_phone,
|
client_phone=payload.client_phone,
|
||||||
responsible=responsible,
|
responsible=responsible,
|
||||||
)
|
)
|
||||||
|
finance_payload = normalize_request_financial_payload_or_400(payload.model_dump())
|
||||||
assigned_lawyer_id = str(payload.assigned_lawyer_id or "").strip() or None
|
assigned_lawyer_id = str(payload.assigned_lawyer_id or "").strip() or None
|
||||||
effective_rate = payload.effective_rate
|
effective_rate = finance_payload.get("effective_rate")
|
||||||
if assigned_lawyer_id:
|
if assigned_lawyer_id:
|
||||||
assigned_lawyer = active_lawyer_or_400(db, assigned_lawyer_id)
|
assigned_lawyer = active_lawyer_or_400(db, assigned_lawyer_id)
|
||||||
assigned_lawyer_id = str(assigned_lawyer.id)
|
assigned_lawyer_id = str(assigned_lawyer.id)
|
||||||
|
|
@ -207,8 +212,8 @@ def create_request_service(payload: RequestAdminCreate, db: Session, admin: dict
|
||||||
extra_fields=payload.extra_fields,
|
extra_fields=payload.extra_fields,
|
||||||
assigned_lawyer_id=assigned_lawyer_id,
|
assigned_lawyer_id=assigned_lawyer_id,
|
||||||
effective_rate=effective_rate,
|
effective_rate=effective_rate,
|
||||||
request_cost=payload.request_cost,
|
request_cost=finance_payload.get("request_cost"),
|
||||||
invoice_amount=payload.invoice_amount,
|
invoice_amount=finance_payload.get("invoice_amount"),
|
||||||
paid_at=payload.paid_at,
|
paid_at=payload.paid_at,
|
||||||
paid_by_admin_id=payload.paid_by_admin_id,
|
paid_by_admin_id=payload.paid_by_admin_id,
|
||||||
total_attachments_bytes=payload.total_attachments_bytes,
|
total_attachments_bytes=payload.total_attachments_bytes,
|
||||||
|
|
@ -218,6 +223,9 @@ def create_request_service(payload: RequestAdminCreate, db: Session, admin: dict
|
||||||
db.add(row)
|
db.add(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
|
except DataError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise request_financial_data_error_or_400() from exc
|
||||||
except IntegrityError as exc:
|
except IntegrityError as exc:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise HTTPException(status_code=400, detail="Заявка с таким номером уже существует") from exc
|
raise HTTPException(status_code=400, detail="Заявка с таким номером уже существует") from exc
|
||||||
|
|
@ -245,6 +253,7 @@ def update_request_service(request_id: str, payload: RequestAdminPatch, db: Sess
|
||||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
ensure_lawyer_can_manage_request_or_403(admin, row)
|
ensure_lawyer_can_manage_request_or_403(admin, row)
|
||||||
changes = payload.model_dump(exclude_unset=True)
|
changes = payload.model_dump(exclude_unset=True)
|
||||||
|
changes = normalize_request_financial_payload_or_400(changes)
|
||||||
actor_role = str(admin.get("role") or "").upper()
|
actor_role = str(admin.get("role") or "").upper()
|
||||||
if actor_role == "LAWYER":
|
if actor_role == "LAWYER":
|
||||||
if "assigned_lawyer_id" in changes:
|
if "assigned_lawyer_id" in changes:
|
||||||
|
|
@ -347,6 +356,9 @@ def update_request_service(request_id: str, payload: RequestAdminPatch, db: Sess
|
||||||
db.add(row)
|
db.add(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
|
except DataError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise request_financial_data_error_or_400() from exc
|
||||||
except IntegrityError as exc:
|
except IntegrityError as exc:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise HTTPException(status_code=400, detail="Заявка с таким номером уже существует") from exc
|
raise HTTPException(status_code=400, detail="Заявка с таким номером уже существует") from exc
|
||||||
|
|
|
||||||
59
app/services/request_finance_validation.py
Normal file
59
app/services/request_finance_validation.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
_REQUEST_MONEY_SPECS: dict[str, tuple[str, int]] = {
|
||||||
|
"effective_rate": ("Ставка (фикс.)", 10),
|
||||||
|
"request_cost": ("Стоимость заявки", 12),
|
||||||
|
"invoice_amount": ("Сумма счета", 12),
|
||||||
|
}
|
||||||
|
_MONEY_STEP = Decimal("0.01")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_request_financial_payload_or_400(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
normalized = dict(payload)
|
||||||
|
for field_name, (label, max_integer_digits) in _REQUEST_MONEY_SPECS.items():
|
||||||
|
if field_name not in normalized:
|
||||||
|
continue
|
||||||
|
normalized[field_name] = _normalize_money_value_or_400(
|
||||||
|
normalized.get(field_name),
|
||||||
|
label=label,
|
||||||
|
max_integer_digits=max_integer_digits,
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def request_financial_data_error_or_400() -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Изменения не сохранены: проверьте числовые поля заявки. Сумма или ставка слишком большие либо имеют некорректный формат.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_money_value_or_400(raw: Any, *, label: str, max_integer_digits: int) -> float | None:
|
||||||
|
if raw is None or raw == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = Decimal(str(raw).strip())
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
raise HTTPException(status_code=400, detail=f'Поле "{label}" должно быть числом')
|
||||||
|
if not value.is_finite():
|
||||||
|
raise HTTPException(status_code=400, detail=f'Поле "{label}" должно быть числом')
|
||||||
|
if value < 0:
|
||||||
|
raise HTTPException(status_code=400, detail=f'Поле "{label}" не может быть отрицательным')
|
||||||
|
|
||||||
|
text = format(value.copy_abs(), "f")
|
||||||
|
integer_part, _, fraction_part = text.partition(".")
|
||||||
|
significant_integer = integer_part.lstrip("0") or "0"
|
||||||
|
if len(significant_integer) > max_integer_digits:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f'Поле "{label}" слишком большое. Допустимо не более {max_integer_digits} цифр до запятой и 2 после.',
|
||||||
|
)
|
||||||
|
if len(fraction_part.rstrip("0")) > 2:
|
||||||
|
raise HTTPException(status_code=400, detail=f'Поле "{label}" должно содержать не более 2 знаков после запятой')
|
||||||
|
|
||||||
|
return float(value.quantize(_MONEY_STEP, rounding=ROUND_HALF_UP))
|
||||||
|
|
@ -22,14 +22,45 @@
|
||||||
background: radial-gradient(circle at 12% 2%, #1a2532, var(--bg) 50%), var(--bg);
|
background: radial-gradient(circle at 12% 2%, #1a2532, var(--bg) 50%), var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: "Manrope", sans-serif;
|
font-family: "Manrope", sans-serif;
|
||||||
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.modal-open { overflow: hidden; }
|
body.modal-open { overflow: hidden; }
|
||||||
|
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(157, 176, 197, 0.72) rgba(12, 18, 24, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: rgba(12, 18, 24, 0.32);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background: linear-gradient(180deg, rgba(166, 181, 201, 0.88), rgba(116, 131, 150, 0.88));
|
||||||
|
border: 2px solid rgba(12, 18, 24, 0.28);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: linear-gradient(180deg, rgba(188, 201, 219, 0.96), rgba(130, 145, 164, 0.96));
|
||||||
|
}
|
||||||
|
|
||||||
.layout {
|
.layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 272px 1fr;
|
grid-template-columns: 272px 1fr;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
transition: grid-template-columns 0.28s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout.sidebar-collapsed {
|
||||||
|
grid-template-columns: 78px 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
|
|
@ -39,9 +70,32 @@
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
padding 0.28s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
background 0.28s ease,
|
||||||
|
border-color 0.28s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout.sidebar-collapsed .sidebar {
|
||||||
|
padding: 1rem 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-head .logo {
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|
@ -56,6 +110,30 @@
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo span {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-8px);
|
||||||
|
transition:
|
||||||
|
max-width 0.2s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
opacity 0.12s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout:not(.sidebar-collapsed) .logo span {
|
||||||
|
max-width: 180px;
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
transition-delay: 0.1s, 0.14s, 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-mark {
|
.brand-mark {
|
||||||
|
|
@ -85,28 +163,149 @@
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu-button-content {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-label {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 168px;
|
||||||
|
transition:
|
||||||
|
max-width 0.22s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
opacity 0.18s ease,
|
||||||
|
transform 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-caret {
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
max-width: 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
max-width 0.2s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
opacity 0.16s ease,
|
||||||
|
transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
.menu button:hover {
|
.menu button:hover {
|
||||||
border-color: var(--line);
|
border-color: var(--line);
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu button,
|
||||||
|
.menu-button-content {
|
||||||
|
transition:
|
||||||
|
padding 0.22s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
gap 0.22s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
border-color 0.18s ease,
|
||||||
|
background 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
.menu button.active {
|
.menu button.active {
|
||||||
border-color: rgba(212, 168, 106, 0.45);
|
border-color: rgba(212, 168, 106, 0.45);
|
||||||
background: var(--brand-soft);
|
background: var(--brand-soft);
|
||||||
color: #fde5c2;
|
color: #fde5c2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu-tree-shell {
|
||||||
|
position: relative;
|
||||||
|
padding-right: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
.menu-tree {
|
.menu-tree {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
padding-left: 0.6rem;
|
padding-left: 0.6rem;
|
||||||
padding-right: 0.2rem;
|
padding-right: 0.55rem;
|
||||||
border-left: 1px dashed rgba(212, 168, 106, 0.3);
|
border-left: 1px dashed rgba(212, 168, 106, 0.3);
|
||||||
margin: 0.2rem 0 0.1rem 0.2rem;
|
margin: 0.2rem 0 0.1rem 0.2rem;
|
||||||
max-height: 38vh;
|
max-height: 38vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-tree::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(157, 176, 197, 0.88) rgba(16, 24, 33, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar::-webkit-scrollbar-track,
|
||||||
|
.sidebar::-webkit-scrollbar-corner {
|
||||||
|
background: rgba(16, 24, 33, 0.76);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar::-webkit-scrollbar-thumb {
|
||||||
|
background: linear-gradient(180deg, rgba(176, 190, 208, 0.94), rgba(126, 141, 160, 0.94));
|
||||||
|
border: 2px solid rgba(16, 24, 33, 0.76);
|
||||||
|
border-radius: 999px;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: linear-gradient(180deg, rgba(194, 206, 222, 0.98), rgba(137, 152, 171, 0.98));
|
||||||
|
border: 2px solid rgba(16, 24, 33, 0.76);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-tree-scrollbar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.2rem;
|
||||||
|
right: 0;
|
||||||
|
width: 8px;
|
||||||
|
height: calc(38vh - 0.3rem);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(16, 24, 33, 0.76);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-tree-scrollbar-thumb {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(180deg, rgba(176, 190, 208, 0.96), rgba(126, 141, 160, 0.96));
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(16, 24, 33, 0.6);
|
||||||
|
transition: transform 0.08s linear;
|
||||||
|
cursor: grab;
|
||||||
|
pointer-events: auto;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.menu-tree-scrollbar-dragging,
|
||||||
|
body.menu-tree-scrollbar-dragging * {
|
||||||
|
cursor: grabbing !important;
|
||||||
|
user-select: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-tree button {
|
.menu-tree button {
|
||||||
|
|
@ -115,6 +314,41 @@
|
||||||
color: #c8d8ea;
|
color: #c8d8ea;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.layout.sidebar-collapsed .menu-label,
|
||||||
|
.layout.sidebar-collapsed .menu-caret {
|
||||||
|
opacity: 0;
|
||||||
|
max-width: 0;
|
||||||
|
transform: translateX(-8px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout.sidebar-collapsed .auth-box,
|
||||||
|
.layout.sidebar-collapsed .menu-tree {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout.sidebar-collapsed .sidebar-head {
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout.sidebar-collapsed .logo a {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout.sidebar-collapsed .menu button {
|
||||||
|
padding: 0.72rem 0.6rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout.sidebar-collapsed .menu-button-content {
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-box {
|
.auth-box {
|
||||||
margin-top: 1.2rem;
|
margin-top: 1.2rem;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
|
|
@ -795,11 +1029,16 @@
|
||||||
.kanban-transition-select {
|
.kanban-transition-select {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-field.kanban-transition-select .dropdown-field-trigger {
|
||||||
|
min-height: 30px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
color: #dbe7f8;
|
color: #dbe7f8;
|
||||||
padding: 0.34rem 0.55rem;
|
padding: 0.34rem 1.9rem 0.34rem 0.55rem;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -941,6 +1180,135 @@
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-field {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-field-trigger {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 38px;
|
||||||
|
border: 1px solid #3c4d62;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.58rem 2.2rem 0.58rem 0.68rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-field-trigger:disabled {
|
||||||
|
opacity: 0.62;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-field.open .dropdown-field-trigger,
|
||||||
|
.dropdown-field-trigger:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(120, 163, 235, 0.72);
|
||||||
|
box-shadow: 0 0 0 3px rgba(89, 133, 210, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.045);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-field-label {
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: #eef4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-field-label.placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-field-caret {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #d8e4f4;
|
||||||
|
transition: transform 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-field.open .dropdown-field-caret {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-field-menu {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 0.3rem);
|
||||||
|
z-index: 60;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
max-height: min(280px, 42vh);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.35rem;
|
||||||
|
border: 1px solid rgba(61, 82, 105, 0.88);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(180deg, rgba(17, 24, 33, 0.98), rgba(11, 16, 23, 0.99));
|
||||||
|
box-shadow: 0 18px 42px rgba(6, 10, 16, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-field-option,
|
||||||
|
.dropdown-field-empty {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 34px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: #eef4ff;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5rem 0.62rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-field-option {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-field-option:hover,
|
||||||
|
.dropdown-field-option.selected {
|
||||||
|
background: rgba(120, 163, 235, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-field-option:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-field-empty {
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
color-scheme: dark;
|
||||||
|
padding-right: 2.2rem;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 14 14'%3E%3Cpath fill='%23d8e4f4' d='M3.2 4.8a.75.75 0 0 1 1.06 0L7 7.54 9.74 4.8a.75.75 0 1 1 1.06 1.06L7.53 9.13a.75.75 0 0 1-1.06 0L3.2 5.86a.75.75 0 0 1 0-1.06Z'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 0.72rem center;
|
||||||
|
background-size: 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
select::-ms-expand {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
select option,
|
||||||
|
select optgroup {
|
||||||
|
background: #121c26;
|
||||||
|
color: #eef4ff;
|
||||||
|
}
|
||||||
|
|
||||||
input:-webkit-autofill,
|
input:-webkit-autofill,
|
||||||
input:-webkit-autofill:hover,
|
input:-webkit-autofill:hover,
|
||||||
input:-webkit-autofill:focus,
|
input:-webkit-autofill:focus,
|
||||||
|
|
@ -1125,6 +1493,28 @@
|
||||||
max-width: 230px;
|
max-width: 230px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-identity-link {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #eaf2fd;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 230px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-identity-link:hover {
|
||||||
|
color: #f6d49a;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 0.14em;
|
||||||
|
}
|
||||||
|
|
||||||
.table-actions {
|
.table-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
|
|
@ -1715,20 +2105,54 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.request-finance-modal {
|
.request-finance-modal {
|
||||||
width: min(560px, 100%);
|
width: min(760px, 100%);
|
||||||
|
max-height: min(86vh, 860px);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.request-finance-subtitle {
|
.request-finance-subtitle {
|
||||||
margin: 0.2rem 0 0;
|
margin: 0.2rem 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.request-finance-grid {
|
.request-finance-layout {
|
||||||
margin-top: 0.2rem;
|
display: grid;
|
||||||
|
gap: 0.8rem;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-finance-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-finance-summary-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.85rem 0.95rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.025);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-finance-summary-card.accent {
|
||||||
|
border-color: rgba(212, 168, 106, 0.24);
|
||||||
|
background: rgba(212, 168, 106, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-finance-summary-value {
|
||||||
|
color: #eef4ff;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
font-weight: 700;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.request-finance-actions {
|
.request-finance-actions {
|
||||||
margin-top: 0.65rem;
|
margin-top: 0.1rem;
|
||||||
padding-top: 0.2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.request-finance-actions-inline {
|
.request-finance-actions-inline {
|
||||||
|
|
@ -1739,12 +2163,26 @@
|
||||||
.request-finance-issue-form {
|
.request-finance-issue-form {
|
||||||
margin-top: 0.2rem;
|
margin-top: 0.2rem;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 10px;
|
border-radius: 16px;
|
||||||
padding: 0.55rem;
|
padding: 0.8rem;
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: rgba(255, 255, 255, 0.025);
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.request-finance-issue-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-finance-issue-head h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #edf4ff;
|
||||||
|
}
|
||||||
|
|
||||||
.request-finance-issue-grid {
|
.request-finance-issue-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|
@ -1755,11 +2193,12 @@
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
padding-top: 0.6rem;
|
padding-top: 0.6rem;
|
||||||
max-height: min(42vh, 340px);
|
max-height: min(44vh, 380px);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.request-finance-invoices-head {
|
.request-finance-invoices-head {
|
||||||
|
|
@ -1787,8 +2226,8 @@
|
||||||
|
|
||||||
.request-finance-invoice-row {
|
.request-finance-invoice-row {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 10px;
|
border-radius: 14px;
|
||||||
padding: 0.5rem 0.55rem;
|
padding: 0.65rem 0.7rem;
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -2302,8 +2741,8 @@
|
||||||
|
|
||||||
.request-description-modal-body {
|
.request-description-modal-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1.7fr) minmax(280px, 0.95fr);
|
||||||
gap: 0.55rem;
|
gap: 0.75rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -2338,37 +2777,66 @@
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.request-description-modal-side {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
gap: 0.6rem;
|
||||||
|
min-height: 0;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
.request-description-modal-meta-wrap {
|
.request-description-modal-meta-wrap {
|
||||||
border: 1px solid rgba(130, 151, 180, 0.14);
|
border: 1px solid rgba(130, 151, 180, 0.14);
|
||||||
border-radius: 12px;
|
border-radius: 16px;
|
||||||
background: rgba(255, 255, 255, 0.015);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
padding: 0.55rem 0.65rem;
|
padding: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.request-description-modal-meta {
|
.request-description-modal-meta {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: 1fr;
|
||||||
gap: 0.45rem 0.8rem;
|
gap: 0.55rem;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.request-description-meta-item {
|
.request-description-meta-item {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.18rem;
|
gap: 0.2rem;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
padding: 0.05rem 0;
|
padding: 0.65rem 0.75rem;
|
||||||
}
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 14px;
|
||||||
.request-description-meta-item.align-right {
|
background: rgba(255, 255, 255, 0.02);
|
||||||
justify-items: end;
|
|
||||||
text-align: right;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.request-description-meta-item .request-field-value {
|
.request-description-meta-item .request-field-value {
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.request-description-modal-facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-description-fact-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.22rem;
|
||||||
|
padding: 0.7rem 0.75rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.015)),
|
||||||
|
rgba(255, 255, 255, 0.015);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-description-fact-card .request-field-value {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
.request-status-route {
|
.request-status-route {
|
||||||
margin-top: 0.85rem;
|
margin-top: 0.85rem;
|
||||||
padding-top: 0.8rem;
|
padding-top: 0.8rem;
|
||||||
|
|
@ -2474,6 +2942,17 @@
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.request-description-modal-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: minmax(0, auto) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-description-modal-facts {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.request-modal-item-meta {
|
.request-modal-item-meta {
|
||||||
margin-top: 0.18rem;
|
margin-top: 0.18rem;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
|
|
@ -3146,6 +3625,241 @@
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.record-user-modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 920px;
|
||||||
|
max-height: min(88vh, 920px);
|
||||||
|
overflow: hidden;
|
||||||
|
contain: layout paint;
|
||||||
|
transform: translateZ(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-scroll {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding-right: 0.15rem;
|
||||||
|
padding-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-top {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 164px minmax(0, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: start;
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-avatar-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-avatar-shell {
|
||||||
|
width: 156px;
|
||||||
|
height: 156px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 2px solid rgba(241, 211, 163, 0.25);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
|
padding: 0;
|
||||||
|
appearance: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-avatar-shell .avatar {
|
||||||
|
width: 148px !important;
|
||||||
|
height: 148px !important;
|
||||||
|
font-size: 2rem;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-avatar-shell.interactive {
|
||||||
|
cursor: zoom-in;
|
||||||
|
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-avatar-shell.interactive:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(241, 211, 163, 0.45);
|
||||||
|
box-shadow: 0 16px 32px rgba(8, 13, 20, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-avatar-shell:disabled {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-avatar-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.9rem;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-summary-head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-summary-edit-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-summary-head h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.55rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: #f3f7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-summary-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0.38rem 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: #dbe6f6;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-badge.status-success {
|
||||||
|
border-color: rgba(73, 190, 120, 0.35);
|
||||||
|
background: rgba(73, 190, 120, 0.14);
|
||||||
|
color: #d9ffe5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-badge.status-danger {
|
||||||
|
border-color: rgba(216, 91, 91, 0.35);
|
||||||
|
background: rgba(216, 91, 91, 0.14);
|
||||||
|
color: #ffd9d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-badge.status-warn {
|
||||||
|
border-color: rgba(212, 168, 106, 0.35);
|
||||||
|
background: rgba(212, 168, 106, 0.14);
|
||||||
|
color: #fde5c2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-summary-grid,
|
||||||
|
.record-user-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-summary-item,
|
||||||
|
.record-user-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
padding: 0.82rem 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-rate-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-summary-label,
|
||||||
|
.record-user-card-label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-summary-value,
|
||||||
|
.record-user-card-value {
|
||||||
|
color: #eef4ff;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-card input,
|
||||||
|
.record-user-card select,
|
||||||
|
.record-user-card textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-avatar-preview-modal {
|
||||||
|
width: min(760px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-avatar-preview-body {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 18px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, rgba(241, 211, 163, 0.14), rgba(255, 255, 255, 0.02) 48%),
|
||||||
|
rgba(255, 255, 255, 0.02);
|
||||||
|
min-height: 420px;
|
||||||
|
padding: 1rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-avatar-preview-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: min(72vh, 720px);
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 20px 60px rgba(7, 12, 19, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-avatar-preview-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.account-security-box {
|
.account-security-box {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|
@ -3164,6 +3878,32 @@
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.record-user-top {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-avatar-shell {
|
||||||
|
width: 132px;
|
||||||
|
height: 132px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-avatar-shell .avatar {
|
||||||
|
width: 124px !important;
|
||||||
|
height: 124px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-summary-grid,
|
||||||
|
.record-user-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user-summary-edit-meta,
|
||||||
|
.record-user-rate-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.login-screen {
|
.login-screen {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|
@ -3230,6 +3970,10 @@
|
||||||
.request-main-column {
|
.request-main-column {
|
||||||
order: 2;
|
order: 2;
|
||||||
}
|
}
|
||||||
|
.request-finance-summary,
|
||||||
|
.request-finance-issue-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.request-finance-invoice-row {
|
.request-finance-invoice-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,12 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<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=20260331-05">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="admin-root"></div>
|
<div id="admin-root"></div>
|
||||||
<script src="/vendor/react.production.min.js"></script>
|
<script src="/vendor/react.production.min.js"></script>
|
||||||
<script src="/vendor/react-dom.production.min.js"></script>
|
<script src="/vendor/react-dom.production.min.js"></script>
|
||||||
<script src="/admin.js?v=20260317-01"></script>
|
<script src="/admin.js?v=20260331-05"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
1042
app/web/admin.js
1042
app/web/admin.js
File diff suppressed because one or more lines are too long
|
|
@ -32,6 +32,8 @@ import { useRequestWorkspace } from "./admin/hooks/useRequestWorkspace.js";
|
||||||
import { useTableActions } from "./admin/hooks/useTableActions.js";
|
import { useTableActions } from "./admin/hooks/useTableActions.js";
|
||||||
import { useTableFilterActions } from "./admin/hooks/useTableFilterActions.js";
|
import { useTableFilterActions } from "./admin/hooks/useTableFilterActions.js";
|
||||||
import { useTablesState } from "./admin/hooks/useTablesState.js";
|
import { useTablesState } from "./admin/hooks/useTablesState.js";
|
||||||
|
import { DropdownField } from "./admin/shared/DropdownField.jsx";
|
||||||
|
import { RecordModal } from "./admin/shared/RecordModal.jsx";
|
||||||
import {
|
import {
|
||||||
avatarColor,
|
avatarColor,
|
||||||
boolFilterLabel,
|
boolFilterLabel,
|
||||||
|
|
@ -177,6 +179,57 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SidebarNavIcon({ name }) {
|
||||||
|
const common = { width: 18, height: 18, "aria-hidden": "true", focusable: "false", viewBox: "0 0 24 24" };
|
||||||
|
if (name === "dashboard") {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M4 5.5A1.5 1.5 0 0 1 5.5 4h4A1.5 1.5 0 0 1 11 5.5v4A1.5 1.5 0 0 1 9.5 11h-4A1.5 1.5 0 0 1 4 9.5v-4Zm9 0A1.5 1.5 0 0 1 14.5 4h4A1.5 1.5 0 0 1 20 5.5v7A1.5 1.5 0 0 1 18.5 14h-4a1.5 1.5 0 0 1-1.5-1.5v-7Zm-9 9A1.5 1.5 0 0 1 5.5 13h4a1.5 1.5 0 0 1 1.5 1.5v4A1.5 1.5 0 0 1 9.5 20h-4A1.5 1.5 0 0 1 4 18.5v-4Zm9 3a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2h-6a1 1 0 0 1-1-1Z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (name === "kanban") {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M5 4h4a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Zm10 0h4a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Zm0 12h4a1 1 0 1 1 0 2h-4a1 1 0 1 1 0-2Z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (name === "requests") {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M6 4h9.2a2 2 0 0 1 1.41.59l2.8 2.8A2 2 0 0 1 20 8.8V18a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Zm1 4h10V6.8L15.2 5H7v3Zm0 4h10v-2H7v2Zm0 4h7v-2H7v2Z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (name === "serviceRequests") {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M5 4.5h14A1.5 1.5 0 0 1 20.5 6v9.5A1.5 1.5 0 0 1 19 17H9.2l-3.55 2.96A1.2 1.2 0 0 1 3.7 19V6A1.5 1.5 0 0 1 5 4.5Zm2.1 4.4a1.1 1.1 0 1 0 0 2.2 1.1 1.1 0 0 0 0-2.2Zm4.9 0a1.1 1.1 0 1 0 0 2.2 1.1 1.1 0 0 0 0-2.2Zm4.9 0a1.1 1.1 0 1 0 0 2.2 1.1 1.1 0 0 0 0-2.2Z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (name === "invoices") {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M6 4h12a2 2 0 0 1 2 2v12.5a1.5 1.5 0 0 1-2.56 1.06L15 17.12l-2.44 2.44a1.5 1.5 0 0 1-2.12 0L8 17.12l-2.44 2.44A1.5 1.5 0 0 1 3 18.5V6a2 2 0 0 1 2-2Zm2.5 4.5a1 1 0 0 0 0 2h5a1 1 0 1 0 0-2h-5Zm0 4a1 1 0 1 0 0 2h7a1 1 0 1 0 0-2h-7Z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (name === "config") {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M12 3.5a2 2 0 0 1 1.86 1.27l.27.68a6.9 6.9 0 0 1 1.31.54l.67-.3a2 2 0 0 1 2.43.75l.7.98a2 2 0 0 1-.18 2.5l-.48.55c.05.35.08.72.08 1.08 0 .37-.03.73-.08 1.08l.48.55a2 2 0 0 1 .18 2.5l-.7.98a2 2 0 0 1-2.43.75l-.67-.3c-.42.23-.86.4-1.31.54l-.27.68A2 2 0 0 1 12 20.5h-1.2a2 2 0 0 1-1.86-1.27l-.27-.68a6.9 6.9 0 0 1-1.31-.54l-.67.3a2 2 0 0 1-2.43-.75l-.7-.98a2 2 0 0 1 .18-2.5l.48-.55A7.7 7.7 0 0 1 4 12c0-.36.03-.73.08-1.08l-.48-.55a2 2 0 0 1-.18-2.5l.7-.98a2 2 0 0 1 2.43-.75l.67.3c.42-.23.86-.4 1.31-.54l.27-.68A2 2 0 0 1 10.8 3.5H12Zm-.6 5.2a3.3 3.3 0 1 0 0 6.6 3.3 3.3 0 0 0 0-6.6Z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<circle cx="12" cy="12" r="8" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FilterToolbar({ filters, onOpen, onRemove, onEdit, getChipLabel, hideAction = false }) {
|
function FilterToolbar({ filters, onOpen, onRemove, onEdit, getChipLabel, hideAction = false }) {
|
||||||
return (
|
return (
|
||||||
<div className="filter-toolbar">
|
<div className="filter-toolbar">
|
||||||
|
|
@ -392,23 +445,23 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
<form className="stack" onSubmit={onSubmit}>
|
<form className="stack" onSubmit={onSubmit}>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="filter-field">Поле</label>
|
<label htmlFor="filter-field">Поле</label>
|
||||||
<select id="filter-field" value={draft.field} onChange={onFieldChange}>
|
<DropdownField
|
||||||
{fields.map((field) => (
|
id="filter-field"
|
||||||
<option value={field.field} key={field.field}>
|
value={draft.field}
|
||||||
{field.label}
|
onChange={(nextValue) => onFieldChange({ target: { value: nextValue } })}
|
||||||
</option>
|
options={fields.map((field) => ({ value: field.field, label: field.label }))}
|
||||||
))}
|
placeholder="Выберите поле"
|
||||||
</select>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="filter-op">Оператор</label>
|
<label htmlFor="filter-op">Оператор</label>
|
||||||
<select id="filter-op" value={draft.op} onChange={onOpChange}>
|
<DropdownField
|
||||||
{operators.map((op) => (
|
id="filter-op"
|
||||||
<option value={op} key={op}>
|
value={draft.op}
|
||||||
{OPERATOR_LABELS[op]}
|
onChange={(nextValue) => onOpChange({ target: { value: nextValue } })}
|
||||||
</option>
|
options={operators.map((op) => ({ value: op, label: OPERATOR_LABELS[op] }))}
|
||||||
))}
|
placeholder="Выберите оператор"
|
||||||
</select>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="filter-value">{selectedField ? "Значение: " + selectedField.label : "Значение"}</label>
|
<label htmlFor="filter-value">{selectedField ? "Значение: " + selectedField.label : "Значение"}</label>
|
||||||
|
|
@ -419,22 +472,25 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
) : selectedField.type === "date" ? (
|
) : selectedField.type === "date" ? (
|
||||||
<input id="filter-value" type="date" value={draft.rawValue} onChange={onValueChange} />
|
<input id="filter-value" type="date" value={draft.rawValue} onChange={onValueChange} />
|
||||||
) : selectedField.type === "boolean" ? (
|
) : selectedField.type === "boolean" ? (
|
||||||
<select id="filter-value" value={draft.rawValue} onChange={onValueChange}>
|
<DropdownField
|
||||||
<option value="true">True</option>
|
id="filter-value"
|
||||||
<option value="false">False</option>
|
value={draft.rawValue}
|
||||||
</select>
|
onChange={(nextValue) => onValueChange({ target: { value: nextValue } })}
|
||||||
|
options={[
|
||||||
|
{ value: "true", label: "True" },
|
||||||
|
{ value: "false", label: "False" },
|
||||||
|
]}
|
||||||
|
placeholder="Выберите значение"
|
||||||
|
/>
|
||||||
) : selectedField.type === "reference" || selectedField.type === "enum" ? (
|
) : selectedField.type === "reference" || selectedField.type === "enum" ? (
|
||||||
<select id="filter-value" value={draft.rawValue} onChange={onValueChange} disabled={!options.length}>
|
<DropdownField
|
||||||
{!options.length ? (
|
id="filter-value"
|
||||||
<option value="">Нет доступных значений</option>
|
value={draft.rawValue}
|
||||||
) : (
|
onChange={(nextValue) => onValueChange({ target: { value: nextValue } })}
|
||||||
options.map((option) => (
|
options={options.map((option) => ({ value: String(option.value), label: option.label }))}
|
||||||
<option value={String(option.value)} key={String(option.value)}>
|
disabled={!options.length}
|
||||||
{option.label}
|
placeholder={!options.length ? "Нет доступных значений" : "Выберите значение"}
|
||||||
</option>
|
/>
|
||||||
))
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
) : (
|
) : (
|
||||||
<input id="filter-value" type="text" value={draft.rawValue} onChange={onValueChange} placeholder="Введите значение" />
|
<input id="filter-value" type="text" value={draft.rawValue} onChange={onValueChange} placeholder="Введите значение" />
|
||||||
)}
|
)}
|
||||||
|
|
@ -476,17 +532,14 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
<form className="stack" onSubmit={onSubmit}>
|
<form className="stack" onSubmit={onSubmit}>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="reassign-lawyer">Новый юрист</label>
|
<label htmlFor="reassign-lawyer">Новый юрист</label>
|
||||||
<select id="reassign-lawyer" value={value} onChange={onChange} disabled={!options.length}>
|
<DropdownField
|
||||||
{!options.length ? (
|
id="reassign-lawyer"
|
||||||
<option value="">Нет доступных юристов</option>
|
value={value}
|
||||||
) : (
|
onChange={(nextValue) => onChange({ target: { value: nextValue } })}
|
||||||
options.map((option) => (
|
options={options.map((option) => ({ value: String(option.value), label: option.label }))}
|
||||||
<option value={String(option.value)} key={String(option.value)}>
|
disabled={!options.length}
|
||||||
{option.label}
|
placeholder={!options.length ? "Нет доступных юристов" : "Выберите юриста"}
|
||||||
</option>
|
/>
|
||||||
))
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: "0.6rem", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: "0.6rem", flexWrap: "wrap" }}>
|
||||||
<button className="btn" type="submit" disabled={!value}>
|
<button className="btn" type="submit" disabled={!value}>
|
||||||
|
|
@ -522,11 +575,17 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
<form className="stack" onSubmit={onSubmit}>
|
<form className="stack" onSubmit={onSubmit}>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="kanban-sort-mode">Тип сортировки</label>
|
<label htmlFor="kanban-sort-mode">Тип сортировки</label>
|
||||||
<select id="kanban-sort-mode" value={value} onChange={onChange}>
|
<DropdownField
|
||||||
<option value="created_newest">Дата заявки (новые сверху)</option>
|
id="kanban-sort-mode"
|
||||||
<option value="lawyer">Юрист</option>
|
value={value}
|
||||||
<option value="deadline">Дедлайн</option>
|
onChange={(nextValue) => onChange({ target: { value: nextValue } })}
|
||||||
</select>
|
options={[
|
||||||
|
{ value: "created_newest", label: "Дата заявки (новые сверху)" },
|
||||||
|
{ value: "lawyer", label: "Юрист" },
|
||||||
|
{ value: "deadline", label: "Дедлайн" },
|
||||||
|
]}
|
||||||
|
placeholder="Выберите сортировку"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: "0.6rem", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: "0.6rem", flexWrap: "wrap" }}>
|
||||||
<button className="btn" type="submit">
|
<button className="btn" type="submit">
|
||||||
|
|
@ -930,146 +989,6 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecordModal({ open, title, fields, form, status, onClose, onChange, onSubmit, onUploadField }) {
|
|
||||||
if (!open) return null;
|
|
||||||
const visibleFields = (fields || []).filter((field) => {
|
|
||||||
if (typeof field.visibleWhen !== "function") return true;
|
|
||||||
try {
|
|
||||||
return Boolean(field.visibleWhen(form || {}));
|
|
||||||
} catch (_) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderField = (field) => {
|
|
||||||
const value = form[field.key] ?? "";
|
|
||||||
const options = typeof field.options === "function" ? field.options(form || {}) : [];
|
|
||||||
const id = "record-field-" + field.key;
|
|
||||||
const disabled = Boolean(field.readOnly) || (typeof field.readOnlyWhen === "function" ? Boolean(field.readOnlyWhen(form || {})) : false);
|
|
||||||
|
|
||||||
if (field.type === "textarea" || field.type === "json") {
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
id={id}
|
|
||||||
value={value}
|
|
||||||
onChange={(event) => onChange(field.key, event.target.value)}
|
|
||||||
placeholder={field.placeholder || ""}
|
|
||||||
required={Boolean(field.required)}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (field.type === "boolean") {
|
|
||||||
return (
|
|
||||||
<select id={id} value={value} onChange={(event) => onChange(field.key, event.target.value)} disabled={disabled}>
|
|
||||||
<option value="true">Да</option>
|
|
||||||
<option value="false">Нет</option>
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (field.type === "reference" || field.type === "enum") {
|
|
||||||
const extraOptions = Array.isArray(field.extraOptions) ? field.extraOptions : [];
|
|
||||||
const hasCurrentValue =
|
|
||||||
String(value || "").trim() !== "" &&
|
|
||||||
[...extraOptions, ...options].some((option) => String(option?.value || "") === String(value));
|
|
||||||
return (
|
|
||||||
<select id={id} value={value} onChange={(event) => onChange(field.key, event.target.value)} disabled={disabled}>
|
|
||||||
{field.optional ? <option value="">-</option> : null}
|
|
||||||
{!hasCurrentValue && String(value || "").trim() !== "" ? <option value={String(value)}>{String(value)}</option> : null}
|
|
||||||
{extraOptions.map((option) => (
|
|
||||||
<option value={String(option.value)} key={String(option.value)}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
{options.map((option) => (
|
|
||||||
<option value={String(option.value)} key={String(option.value)}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (field.uploadScope) {
|
|
||||||
return (
|
|
||||||
<div className="field-inline">
|
|
||||||
<input
|
|
||||||
id={id}
|
|
||||||
type="text"
|
|
||||||
value={value}
|
|
||||||
onChange={(event) => onChange(field.key, event.target.value)}
|
|
||||||
placeholder={field.placeholder || ""}
|
|
||||||
required={Boolean(field.required)}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
<label className="btn secondary btn-sm" style={{ whiteSpace: "nowrap", opacity: disabled ? 0.6 : 1, pointerEvents: disabled ? "none" : "auto" }}>
|
|
||||||
Загрузить
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept={field.accept || "*/*"}
|
|
||||||
style={{ display: "none" }}
|
|
||||||
onChange={(event) => {
|
|
||||||
const file = event.target.files && event.target.files[0];
|
|
||||||
if (file && onUploadField) onUploadField(field, file);
|
|
||||||
event.target.value = "";
|
|
||||||
}}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
id={id}
|
|
||||||
type={field.type === "number" ? "number" : field.type === "password" ? "password" : "text"}
|
|
||||||
step={field.type === "number" ? "any" : undefined}
|
|
||||||
value={value}
|
|
||||||
onChange={(event) => onChange(field.key, event.target.value)}
|
|
||||||
placeholder={field.placeholder || ""}
|
|
||||||
required={Boolean(field.required)}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Overlay open={open} id="record-overlay" onClose={(event) => event.target.id === "record-overlay" && onClose()}>
|
|
||||||
<div className="modal" style={{ width: "min(760px, 100%)" }} onClick={(event) => event.stopPropagation()}>
|
|
||||||
<div className="modal-head">
|
|
||||||
<div>
|
|
||||||
<h3>{title}</h3>
|
|
||||||
<p className="muted" style={{ marginTop: "0.35rem" }}>
|
|
||||||
Создание и редактирование записи.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button className="close" type="button" onClick={onClose}>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form className="stack" onSubmit={onSubmit}>
|
|
||||||
<div className="filters" style={{ gridTemplateColumns: "repeat(2, minmax(0,1fr))" }}>
|
|
||||||
{visibleFields.map((field) => (
|
|
||||||
<div className="field" key={field.key} style={field.fullRow ? { gridColumn: "1 / -1" } : undefined}>
|
|
||||||
<label htmlFor={"record-field-" + field.key}>{field.label}</label>
|
|
||||||
{renderField(field)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: "0.6rem", flexWrap: "wrap" }}>
|
|
||||||
<button className="btn" type="submit">
|
|
||||||
Сохранить
|
|
||||||
</button>
|
|
||||||
<button className="btn secondary" type="button" onClick={onClose}>
|
|
||||||
Отмена
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<StatusLine status={status} />
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</Overlay>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GlobalTooltipLayer() {
|
function GlobalTooltipLayer() {
|
||||||
const [tooltip, setTooltip] = useState({ open: false, text: "", x: 0, y: 0, maxWidth: 320 });
|
const [tooltip, setTooltip] = useState({ open: false, text: "", x: 0, y: 0, maxWidth: 320 });
|
||||||
const activeRef = useRef(null);
|
const activeRef = useRef(null);
|
||||||
|
|
@ -1243,8 +1162,16 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
});
|
});
|
||||||
|
|
||||||
const [configActiveKey, setConfigActiveKey] = useState("");
|
const [configActiveKey, setConfigActiveKey] = useState("");
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem("law-admin-sidebar-collapsed") === "1";
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
const [referencesExpanded, setReferencesExpanded] = useState(true);
|
const [referencesExpanded, setReferencesExpanded] = useState(true);
|
||||||
const [statusDesignerTopicCode, setStatusDesignerTopicCode] = useState("");
|
const [statusDesignerTopicCode, setStatusDesignerTopicCode] = useState("");
|
||||||
|
const [menuTreeScrollbar, setMenuTreeScrollbar] = useState({ visible: false, top: 0, height: 0 });
|
||||||
|
|
||||||
const [metaEntity, setMetaEntity] = useState("quotes");
|
const [metaEntity, setMetaEntity] = useState("quotes");
|
||||||
const [metaJson, setMetaJson] = useState("");
|
const [metaJson, setMetaJson] = useState("");
|
||||||
|
|
@ -1266,6 +1193,8 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
|
|
||||||
const initialRouteHandledRef = useRef(false);
|
const initialRouteHandledRef = useRef(false);
|
||||||
const statusDesignerLoadedTopicRef = useRef("");
|
const statusDesignerLoadedTopicRef = useRef("");
|
||||||
|
const menuTreeRef = useRef(null);
|
||||||
|
const menuTreeDragRef = useRef(null);
|
||||||
|
|
||||||
const setStatus = useCallback((key, message, kind) => {
|
const setStatus = useCallback((key, message, kind) => {
|
||||||
setStatusMap((prev) => ({ ...prev, [key]: { message: message || "", kind: kind || "" } }));
|
setStatusMap((prev) => ({ ...prev, [key]: { message: message || "", kind: kind || "" } }));
|
||||||
|
|
@ -3620,6 +3549,99 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
if (!hasCurrent) setConfigActiveKey(dictionaryTableItems[0].key);
|
if (!hasCurrent) setConfigActiveKey(dictionaryTableItems[0].key);
|
||||||
}, [configActiveKey, dictionaryTableItems]);
|
}, [configActiveKey, dictionaryTableItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem("law-admin-sidebar-collapsed", sidebarCollapsed ? "1" : "0");
|
||||||
|
} catch (_) {}
|
||||||
|
}, [sidebarCollapsed]);
|
||||||
|
|
||||||
|
const updateMenuTreeScrollbar = useCallback(() => {
|
||||||
|
const node = menuTreeRef.current;
|
||||||
|
if (!node) {
|
||||||
|
setMenuTreeScrollbar({ visible: false, top: 0, height: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const viewport = node.clientHeight;
|
||||||
|
const full = node.scrollHeight;
|
||||||
|
if (!viewport || full <= viewport + 1) {
|
||||||
|
setMenuTreeScrollbar({ visible: false, top: 0, height: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trackHeight = viewport;
|
||||||
|
const thumbHeight = Math.max(42, Math.round((viewport / full) * trackHeight));
|
||||||
|
const maxScroll = Math.max(1, full - viewport);
|
||||||
|
const maxThumbOffset = Math.max(0, trackHeight - thumbHeight);
|
||||||
|
const thumbTop = Math.round((node.scrollTop / maxScroll) * maxThumbOffset);
|
||||||
|
setMenuTreeScrollbar({ visible: true, top: thumbTop, height: thumbHeight });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!referencesExpanded || sidebarCollapsed) {
|
||||||
|
setMenuTreeScrollbar({ visible: false, top: 0, height: 0 });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const node = menuTreeRef.current;
|
||||||
|
if (!node) return undefined;
|
||||||
|
updateMenuTreeScrollbar();
|
||||||
|
const handleScroll = () => updateMenuTreeScrollbar();
|
||||||
|
node.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
let observer = null;
|
||||||
|
if (typeof ResizeObserver !== "undefined") {
|
||||||
|
observer = new ResizeObserver(() => updateMenuTreeScrollbar());
|
||||||
|
observer.observe(node);
|
||||||
|
}
|
||||||
|
window.addEventListener("resize", updateMenuTreeScrollbar);
|
||||||
|
return () => {
|
||||||
|
node.removeEventListener("scroll", handleScroll);
|
||||||
|
if (observer) observer.disconnect();
|
||||||
|
window.removeEventListener("resize", updateMenuTreeScrollbar);
|
||||||
|
};
|
||||||
|
}, [referencesExpanded, sidebarCollapsed, dictionaryTableItems.length, updateMenuTreeScrollbar]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePointerMove = (event) => {
|
||||||
|
const drag = menuTreeDragRef.current;
|
||||||
|
const node = menuTreeRef.current;
|
||||||
|
if (!drag || !node) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const delta = event.clientY - drag.startClientY;
|
||||||
|
const nextThumbTop = Math.min(drag.maxThumbTop, Math.max(0, drag.startThumbTop + delta));
|
||||||
|
const ratio = drag.maxThumbTop > 0 ? nextThumbTop / drag.maxThumbTop : 0;
|
||||||
|
node.scrollTop = ratio * drag.maxScrollTop;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopDrag = () => {
|
||||||
|
if (!menuTreeDragRef.current) return;
|
||||||
|
menuTreeDragRef.current = null;
|
||||||
|
document.body.classList.remove("menu-tree-scrollbar-dragging");
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", handlePointerMove);
|
||||||
|
window.addEventListener("pointerup", stopDrag);
|
||||||
|
window.addEventListener("pointercancel", stopDrag);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointermove", handlePointerMove);
|
||||||
|
window.removeEventListener("pointerup", stopDrag);
|
||||||
|
window.removeEventListener("pointercancel", stopDrag);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startMenuTreeScrollbarDrag = useCallback((event) => {
|
||||||
|
const node = menuTreeRef.current;
|
||||||
|
if (!node) return;
|
||||||
|
const maxScrollTop = Math.max(0, node.scrollHeight - node.clientHeight);
|
||||||
|
const maxThumbTop = Math.max(0, node.clientHeight - menuTreeScrollbar.height);
|
||||||
|
if (!maxScrollTop || !maxThumbTop) return;
|
||||||
|
menuTreeDragRef.current = {
|
||||||
|
startClientY: event.clientY,
|
||||||
|
startThumbTop: menuTreeScrollbar.top,
|
||||||
|
maxThumbTop,
|
||||||
|
maxScrollTop,
|
||||||
|
};
|
||||||
|
document.body.classList.add("menu-tree-scrollbar-dragging");
|
||||||
|
event.preventDefault();
|
||||||
|
}, [menuTreeScrollbar.height, menuTreeScrollbar.top]);
|
||||||
|
|
||||||
const anyOverlayOpen =
|
const anyOverlayOpen =
|
||||||
recordModal.open || filterModal.open || reassignModal.open || kanbanSortModal.open || totpSetupModal.open || accountModal.open;
|
recordModal.open || filterModal.open || reassignModal.open || kanbanSortModal.open || totpSetupModal.open || accountModal.open;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -3641,13 +3663,33 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
return () => document.removeEventListener("keydown", onEsc);
|
return () => document.removeEventListener("keydown", onEsc);
|
||||||
}, [closeAccountModal, closeKanbanSortModal, closeTotpSetupModal]);
|
}, [closeAccountModal, closeKanbanSortModal, closeTotpSetupModal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.getElementById("admin-root");
|
||||||
|
if (!root) return undefined;
|
||||||
|
const applyInputHints = () => {
|
||||||
|
root.querySelectorAll("input, textarea, select").forEach((node) => {
|
||||||
|
const tagName = String(node.tagName || "").toLowerCase();
|
||||||
|
const inputType = tagName === "input" ? String(node.getAttribute("type") || "text").toLowerCase() : "";
|
||||||
|
node.setAttribute("autocomplete", inputType === "password" ? "new-password" : "off");
|
||||||
|
node.setAttribute("autocorrect", "off");
|
||||||
|
node.setAttribute("autocapitalize", "off");
|
||||||
|
node.setAttribute("spellcheck", "false");
|
||||||
|
node.setAttribute("data-form-type", "other");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
applyInputHints();
|
||||||
|
const observer = new MutationObserver(() => applyInputHints());
|
||||||
|
observer.observe(root, { childList: true, subtree: true });
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const menuItems = useMemo(() => {
|
const menuItems = useMemo(() => {
|
||||||
const baseItems = [
|
const baseItems = [
|
||||||
{ key: "dashboard", label: "Обзор" },
|
{ key: "dashboard", label: "Обзор", icon: "dashboard" },
|
||||||
{ key: "kanban", label: "Канбан" },
|
{ key: "kanban", label: "Канбан", icon: "kanban" },
|
||||||
{ key: "requests", label: "Заявки" },
|
{ key: "requests", label: "Заявки", icon: "requests" },
|
||||||
{ key: "serviceRequests", label: "Запросы" },
|
{ key: "serviceRequests", label: "Запросы", icon: "serviceRequests" },
|
||||||
{ key: "invoices", label: "Счета" },
|
{ key: "invoices", label: "Счета", icon: "invoices" },
|
||||||
];
|
];
|
||||||
return baseItems.filter((item) => canAccessSection(role, item.key));
|
return baseItems.filter((item) => canAccessSection(role, item.key));
|
||||||
}, [role]);
|
}, [role]);
|
||||||
|
|
@ -3733,13 +3775,30 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="layout">
|
<div className={"layout" + (sidebarCollapsed ? " sidebar-collapsed" : "")}>
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<div className="logo">
|
<div className="sidebar-head">
|
||||||
<a href="/">
|
<div className="logo">
|
||||||
<img className="brand-mark" src="/brand-mark.svg" alt="" width="24" height="24" />
|
<a href="/">
|
||||||
<span>Правовой трекер</span>
|
<img className="brand-mark" src="/brand-mark.svg" alt="" width="24" height="24" />
|
||||||
</a>
|
<span>Правовой трекер</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
type="button"
|
||||||
|
data-tooltip={sidebarCollapsed ? "Развернуть меню" : "Свернуть меню"}
|
||||||
|
aria-label={sidebarCollapsed ? "Развернуть меню" : "Свернуть меню"}
|
||||||
|
onClick={() => setSidebarCollapsed((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
|
||||||
|
{sidebarCollapsed ? (
|
||||||
|
<path d="M9.53 4.47a1 1 0 0 1 1.41 0l6.82 6.82a1 1 0 0 1 0 1.42l-6.82 6.82a1 1 0 1 1-1.41-1.42L15.64 12 9.53 5.89a1 1 0 0 1 0-1.42Zm-4 0a1 1 0 0 1 1.41 0l6.82 6.82a1 1 0 0 1 0 1.42l-6.82 6.82a1 1 0 0 1-1.41-1.42L11.64 12 5.53 5.89a1 1 0 0 1 0-1.42Z" fill="currentColor" />
|
||||||
|
) : (
|
||||||
|
<path d="M14.47 4.47a1 1 0 0 1 0 1.42L8.36 12l6.11 6.11a1 1 0 0 1-1.41 1.42l-6.82-6.82a1 1 0 0 1 0-1.42l6.82-6.82a1 1 0 0 1 1.41 0Zm4 0a1 1 0 0 1 0 1.42L12.36 12l6.11 6.11a1 1 0 0 1-1.41 1.42l-6.82-6.82a1 1 0 0 1 0-1.42l6.82-6.82a1 1 0 0 1 1.41 0Z" fill="currentColor" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<nav className="menu">
|
<nav className="menu">
|
||||||
{menuItems.map((item) => (
|
{menuItems.map((item) => (
|
||||||
|
|
@ -3749,8 +3808,13 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
data-section={item.key}
|
data-section={item.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => activateSection(item.key)}
|
onClick={() => activateSection(item.key)}
|
||||||
|
title={sidebarCollapsed ? item.label : undefined}
|
||||||
|
aria-label={item.label}
|
||||||
>
|
>
|
||||||
{item.label}
|
<span className="menu-button-content">
|
||||||
|
<span className="menu-icon"><SidebarNavIcon name={item.icon} /></span>
|
||||||
|
<span className="menu-label">{item.label}</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{role === "ADMIN" ? (
|
{role === "ADMIN" ? (
|
||||||
|
|
@ -3759,24 +3823,49 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
className={activeSection === "config" ? "active" : ""}
|
className={activeSection === "config" ? "active" : ""}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setReferencesExpanded((prev) => !prev);
|
if (sidebarCollapsed) {
|
||||||
|
setSidebarCollapsed(false);
|
||||||
|
setReferencesExpanded(true);
|
||||||
|
} else {
|
||||||
|
setReferencesExpanded((prev) => !prev);
|
||||||
|
}
|
||||||
activateSection("config");
|
activateSection("config");
|
||||||
}}
|
}}
|
||||||
|
title={sidebarCollapsed ? "Справочники" : undefined}
|
||||||
|
aria-label="Справочники"
|
||||||
>
|
>
|
||||||
{"Справочники " + (referencesExpanded ? "▾" : "▸")}
|
<span className="menu-button-content">
|
||||||
|
<span className="menu-icon"><SidebarNavIcon name="config" /></span>
|
||||||
|
<span className="menu-label">Справочники</span>
|
||||||
|
<span className="menu-caret" aria-hidden="true">{referencesExpanded ? "▾" : "▸"}</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{referencesExpanded ? (
|
{referencesExpanded && !sidebarCollapsed ? (
|
||||||
<div className="menu-tree">
|
<div className="menu-tree-shell">
|
||||||
{dictionaryTableItems.map((item) => (
|
<div className="menu-tree" ref={menuTreeRef}>
|
||||||
<button
|
{dictionaryTableItems.map((item) => (
|
||||||
key={item.key}
|
<button
|
||||||
type="button"
|
key={item.key}
|
||||||
className={activeSection === "config" && configActiveKey === item.key ? "active" : ""}
|
type="button"
|
||||||
onClick={() => selectConfigNode(item.key)}
|
className={activeSection === "config" && configActiveKey === item.key ? "active" : ""}
|
||||||
>
|
onClick={() => selectConfigNode(item.key)}
|
||||||
{getTableLabel(item.key)}
|
>
|
||||||
</button>
|
{getTableLabel(item.key)}
|
||||||
))}
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{menuTreeScrollbar.visible ? (
|
||||||
|
<div className="menu-tree-scrollbar" aria-hidden="true">
|
||||||
|
<div
|
||||||
|
className="menu-tree-scrollbar-thumb"
|
||||||
|
onPointerDown={startMenuTreeScrollbarDrag}
|
||||||
|
style={{
|
||||||
|
height: menuTreeScrollbar.height + "px",
|
||||||
|
transform: "translateY(" + menuTreeScrollbar.top + "px)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|
@ -4156,13 +4245,20 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
<RecordModal
|
<RecordModal
|
||||||
open={recordModal.open}
|
open={recordModal.open}
|
||||||
title={(recordModal.mode === "edit" ? "Редактирование • " : "Создание • ") + getTableLabel(recordModal.tableKey)}
|
title={(recordModal.mode === "edit" ? "Редактирование • " : "Создание • ") + getTableLabel(recordModal.tableKey)}
|
||||||
|
tableKey={recordModal.tableKey}
|
||||||
|
mode={recordModal.mode}
|
||||||
fields={recordModalFields}
|
fields={recordModalFields}
|
||||||
form={recordModal.form || {}}
|
form={recordModal.form || {}}
|
||||||
status={getStatus("recordForm")}
|
status={getStatus("recordForm")}
|
||||||
|
accessToken={token}
|
||||||
onClose={closeRecordModal}
|
onClose={closeRecordModal}
|
||||||
onChange={updateRecordField}
|
onChange={updateRecordField}
|
||||||
onUploadField={uploadRecordFieldFile}
|
onUploadField={uploadRecordFieldFile}
|
||||||
onSubmit={submitRecordModal}
|
onSubmit={submitRecordModal}
|
||||||
|
OverlayComponent={Overlay}
|
||||||
|
IconButtonComponent={IconButton}
|
||||||
|
UserAvatarComponent={UserAvatar}
|
||||||
|
StatusLineComponent={StatusLine}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FilterModal
|
<FilterModal
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { KNOWN_CONFIG_TABLE_KEYS, OPERATOR_LABELS, PAGE_SIZE, TABLE_SERVER_CONFIG } from "../../shared/constants.js";
|
import { KNOWN_CONFIG_TABLE_KEYS, OPERATOR_LABELS, PAGE_SIZE, TABLE_SERVER_CONFIG } from "../../shared/constants.js";
|
||||||
|
import { DropdownField } from "../../shared/DropdownField.jsx";
|
||||||
import { AddIcon, DownloadIcon, FilterIcon, NextIcon, PrevIcon, RefreshIcon } from "../../shared/icons.jsx";
|
import { AddIcon, DownloadIcon, FilterIcon, NextIcon, PrevIcon, RefreshIcon } from "../../shared/icons.jsx";
|
||||||
import { boolLabel, fmtDate, listPreview, normalizeReferenceMeta, roleLabel, statusKindLabel } from "../../shared/utils.js";
|
import { boolLabel, fmtDate, listPreview, normalizeReferenceMeta, roleLabel, statusKindLabel } from "../../shared/utils.js";
|
||||||
|
|
||||||
|
|
@ -374,18 +375,19 @@ export function ConfigSection(props) {
|
||||||
<p className="muted">Ветвления, возвраты, SLA и требования к данным/файлам на каждом переходе.</p>
|
<p className="muted">Ветвления, возвраты, SLA и требования к данным/файлам на каждом переходе.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="status-designer-controls">
|
<div className="status-designer-controls">
|
||||||
<select
|
<DropdownField
|
||||||
id="status-designer-topic"
|
id="status-designer-topic"
|
||||||
value={statusDesignerTopicCode}
|
value={statusDesignerTopicCode}
|
||||||
onChange={(event) => loadStatusDesignerTopic(event.target.value)}
|
onChange={(nextValue) => loadStatusDesignerTopic(nextValue)}
|
||||||
>
|
options={[
|
||||||
<option value="">Выберите тему</option>
|
{ value: "", label: "Выберите тему" },
|
||||||
{(dictionaries.topics || []).map((topic) => (
|
...((dictionaries.topics || []).map((topic) => ({
|
||||||
<option key={topic.code} value={topic.code}>
|
value: topic.code,
|
||||||
{(topic.name || topic.code) + " (" + topic.code + ")"}
|
label: (topic.name || topic.code) + " (" + topic.code + ")",
|
||||||
</option>
|
}))),
|
||||||
))}
|
]}
|
||||||
</select>
|
placeholder="Выберите тему"
|
||||||
|
/>
|
||||||
<button className="btn secondary btn-sm" type="button" onClick={() => loadStatusDesignerTopic(statusDesignerTopicCode)}>
|
<button className="btn secondary btn-sm" type="button" onClick={() => loadStatusDesignerTopic(statusDesignerTopicCode)}>
|
||||||
Обновить тему
|
Обновить тему
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -509,7 +511,9 @@ export function ConfigSection(props) {
|
||||||
<div className="user-identity">
|
<div className="user-identity">
|
||||||
<UserAvatar name={row.name} email={row.email} avatarUrl={row.avatar_url} accessToken={token} size={32} />
|
<UserAvatar name={row.name} email={row.email} avatarUrl={row.avatar_url} accessToken={token} size={32} />
|
||||||
<div className="user-identity-text">
|
<div className="user-identity-text">
|
||||||
<b>{row.name || "-"}</b>
|
<button className="user-identity-link" type="button" onClick={() => openEditRecordModal("users", row)}>
|
||||||
|
{row.name || "-"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -523,7 +527,6 @@ export function ConfigSection(props) {
|
||||||
<td>{fmtDate(row.created_at)}</td>
|
<td>{fmtDate(row.created_at)}</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="table-actions">
|
<div className="table-actions">
|
||||||
<IconButton icon="✎" tooltip="Редактировать пользователя" onClick={() => openEditRecordModal("users", row)} />
|
|
||||||
<IconButton icon="🗑" tooltip="Удалить пользователя" onClick={() => deleteRecord("users", row.id)} tone="danger" />
|
<IconButton icon="🗑" tooltip="Удалить пользователя" onClick={() => deleteRecord("users", row.id)} tone="danger" />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { KANBAN_GROUPS } from "../../shared/constants.js";
|
import { KANBAN_GROUPS } from "../../shared/constants.js";
|
||||||
|
import { DropdownField } from "../../shared/DropdownField.jsx";
|
||||||
import { FilterIcon, RefreshIcon } from "../../shared/icons.jsx";
|
import { FilterIcon, RefreshIcon } from "../../shared/icons.jsx";
|
||||||
import { fallbackStatusGroup, fmtKanbanDate, resolveDeadlineTone, statusLabel } from "../../shared/utils.js";
|
import { fallbackStatusGroup, fmtKanbanDate, resolveDeadlineTone, statusLabel } from "../../shared/utils.js";
|
||||||
|
|
||||||
|
|
@ -212,24 +213,22 @@ export function KanbanBoard({
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{canMove && transitionOptions.length ? (
|
{canMove && transitionOptions.length ? (
|
||||||
<select
|
<div onClick={(event) => event.stopPropagation()}>
|
||||||
className="kanban-transition-select"
|
<DropdownField
|
||||||
defaultValue=""
|
className="kanban-transition-select"
|
||||||
onClick={(event) => event.stopPropagation()}
|
value=""
|
||||||
onChange={(event) => {
|
placeholder="Перевести…"
|
||||||
const targetStatus = String(event.target.value || "");
|
onChange={(nextValue) => {
|
||||||
if (!targetStatus) return;
|
const targetStatus = String(nextValue || "");
|
||||||
onMoveRequest(row, "", targetStatus);
|
if (!targetStatus) return;
|
||||||
event.target.value = "";
|
onMoveRequest(row, "", targetStatus);
|
||||||
}}
|
}}
|
||||||
>
|
options={transitionOptions.map((transition) => ({
|
||||||
<option value="">Перевести…</option>
|
value: String(transition.to_status),
|
||||||
{transitionOptions.map((transition) => (
|
label: String(transition.to_status_name || transition.to_status),
|
||||||
<option key={String(transition.to_status)} value={String(transition.to_status)}>
|
}))}
|
||||||
{String(transition.to_status_name || transition.to_status)}
|
/>
|
||||||
</option>
|
</div>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
invoiceStatusLabel,
|
invoiceStatusLabel,
|
||||||
statusLabel,
|
statusLabel,
|
||||||
} from "../../shared/utils.js";
|
} from "../../shared/utils.js";
|
||||||
|
import { DropdownField } from "../../shared/DropdownField.jsx";
|
||||||
|
|
||||||
export function RequestWorkspace({
|
export function RequestWorkspace({
|
||||||
viewerRole,
|
viewerRole,
|
||||||
|
|
@ -2123,26 +2124,27 @@ export function RequestWorkspace({
|
||||||
<div className="request-status-change-grid">
|
<div className="request-status-change-grid">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="status-change-next-status">Новый статус</label>
|
<label htmlFor="status-change-next-status">Новый статус</label>
|
||||||
<select
|
<DropdownField
|
||||||
id="status-change-next-status"
|
id="status-change-next-status"
|
||||||
value={statusChangeModal.statusCode}
|
value={statusChangeModal.statusCode}
|
||||||
onChange={(event) => setStatusChangeModal((prev) => ({ ...prev, statusCode: event.target.value, error: "" }))}
|
onChange={(nextValue) => setStatusChangeModal((prev) => ({ ...prev, statusCode: nextValue, error: "" }))}
|
||||||
disabled={statusChangeModal.saving || loading}
|
disabled={statusChangeModal.saving || loading}
|
||||||
>
|
options={[
|
||||||
<option value="">Выберите статус</option>
|
{ value: "", label: "Выберите статус" },
|
||||||
{statusOptions
|
...statusOptions
|
||||||
.filter((item) => item.code !== String(row?.status_code || "").trim())
|
.filter((item) => item.code !== String(row?.status_code || "").trim())
|
||||||
.filter((item) =>
|
.filter((item) =>
|
||||||
Array.isArray(statusChangeModal.allowedStatusCodes) && statusChangeModal.allowedStatusCodes.length
|
Array.isArray(statusChangeModal.allowedStatusCodes) && statusChangeModal.allowedStatusCodes.length
|
||||||
? statusChangeModal.allowedStatusCodes.includes(item.code)
|
? statusChangeModal.allowedStatusCodes.includes(item.code)
|
||||||
: true
|
: true
|
||||||
)
|
)
|
||||||
.map((item) => (
|
.map((item) => ({
|
||||||
<option key={item.code} value={item.code}>
|
value: item.code,
|
||||||
{item.name + (item.groupName ? " • " + item.groupName : "")}
|
label: item.name + (item.groupName ? " • " + item.groupName : ""),
|
||||||
</option>
|
})),
|
||||||
))}
|
]}
|
||||||
</select>
|
placeholder="Выберите статус"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="status-change-important-date">Важная дата (дедлайн)</label>
|
<label htmlFor="status-change-important-date">Важная дата (дедлайн)</label>
|
||||||
|
|
@ -2267,43 +2269,61 @@ export function RequestWorkspace({
|
||||||
{row?.track_number ? "Заявка " + String(row.track_number) : "Данные по заявке"}
|
{row?.track_number ? "Заявка " + String(row.track_number) : "Данные по заявке"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="close" type="button" onClick={closeFinanceModal} aria-label="Закрыть">
|
<div className="modal-head-actions">
|
||||||
×
|
{typeof onIssueInvoice === "function" ? (
|
||||||
</button>
|
!financeIssueForm.open ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary btn-sm"
|
||||||
|
onClick={openFinanceIssueForm}
|
||||||
|
disabled={loading || !row}
|
||||||
|
>
|
||||||
|
Выставить счет
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary btn-sm"
|
||||||
|
onClick={closeFinanceIssueForm}
|
||||||
|
disabled={financeIssueForm.saving}
|
||||||
|
>
|
||||||
|
Скрыть форму
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
<button className="close" type="button" onClick={closeFinanceModal} aria-label="Закрыть">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="request-card-grid request-finance-grid">
|
<div className="request-finance-layout">
|
||||||
<div className="request-field">
|
<div className="request-finance-summary">
|
||||||
<span className="request-field-label">Стоимость</span>
|
<div className="request-finance-summary-card accent">
|
||||||
<span className="request-field-value">{fmtAmount(finance?.request_cost ?? row?.request_cost)}</span>
|
<span className="request-field-label">Стоимость</span>
|
||||||
</div>
|
<span className="request-finance-summary-value">{fmtAmount(finance?.request_cost ?? row?.request_cost)}</span>
|
||||||
<div className="request-field">
|
|
||||||
<span className="request-field-label">Оплачено</span>
|
|
||||||
<span className="request-field-value">{fmtAmount(finance?.paid_total)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="request-field">
|
|
||||||
<span className="request-field-label">Дата оплаты</span>
|
|
||||||
<span className="request-field-value">{fmtShortDateTime(finance?.last_paid_at ?? row?.paid_at)}</span>
|
|
||||||
</div>
|
|
||||||
{canSeeRate ? (
|
|
||||||
<div className="request-field">
|
|
||||||
<span className="request-field-label">Ставка</span>
|
|
||||||
<span className="request-field-value">{fmtAmount(finance?.effective_rate ?? row?.effective_rate)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
<div className="request-finance-summary-card">
|
||||||
</div>
|
<span className="request-field-label">Оплачено</span>
|
||||||
{typeof onIssueInvoice === "function" ? (
|
<span className="request-finance-summary-value">{fmtAmount(finance?.paid_total)}</span>
|
||||||
<div className="request-finance-actions">
|
</div>
|
||||||
{!financeIssueForm.open ? (
|
<div className="request-finance-summary-card">
|
||||||
<button
|
<span className="request-field-label">Дата оплаты</span>
|
||||||
type="button"
|
<span className="request-finance-summary-value">{fmtShortDateTime(finance?.last_paid_at ?? row?.paid_at)}</span>
|
||||||
className="btn btn-sm"
|
</div>
|
||||||
onClick={openFinanceIssueForm}
|
{canSeeRate ? (
|
||||||
disabled={loading || !row}
|
<div className="request-finance-summary-card">
|
||||||
>
|
<span className="request-field-label">Ставка</span>
|
||||||
Выставить счет
|
<span className="request-finance-summary-value">{fmtAmount(finance?.effective_rate ?? row?.effective_rate)}</span>
|
||||||
</button>
|
</div>
|
||||||
) : (
|
) : null}
|
||||||
|
</div>
|
||||||
|
{typeof onIssueInvoice === "function" && financeIssueForm.open ? (
|
||||||
|
<div className="request-finance-actions">
|
||||||
<form className="stack request-finance-issue-form" onSubmit={submitFinanceIssueForm}>
|
<form className="stack request-finance-issue-form" onSubmit={submitFinanceIssueForm}>
|
||||||
|
<div className="request-finance-issue-head">
|
||||||
|
<h4>Новый счет</h4>
|
||||||
|
<span className="muted">Заполните сумму и реквизиты плательщика</span>
|
||||||
|
</div>
|
||||||
<div className="request-finance-issue-grid">
|
<div className="request-finance-issue-grid">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="request-finance-invoice-amount">Сумма</label>
|
<label htmlFor="request-finance-invoice-amount">Сумма</label>
|
||||||
|
|
@ -2355,9 +2375,9 @@ export function RequestWorkspace({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
) : null}
|
</div>
|
||||||
<div className="request-finance-invoices">
|
<div className="request-finance-invoices">
|
||||||
<div className="request-finance-invoices-head">
|
<div className="request-finance-invoices-head">
|
||||||
<h4>Счета</h4>
|
<h4>Счета</h4>
|
||||||
|
|
@ -2428,33 +2448,53 @@ export function RequestWorkspace({
|
||||||
{row?.description ? String(row.description) : "Описание не заполнено"}
|
{row?.description ? String(row.description) : "Описание не заполнено"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="request-description-modal-meta-wrap">
|
<div className="request-description-modal-side">
|
||||||
<div className="request-description-modal-meta">
|
<div className="request-description-modal-meta-wrap">
|
||||||
<div className="request-description-meta-item">
|
<div className="request-description-modal-meta">
|
||||||
<span className="request-field-label">Клиент</span>
|
<div className="request-description-meta-item">
|
||||||
<span
|
<span className="request-field-label">Клиент</span>
|
||||||
className={"request-field-value" + (clientHasPhone ? " has-tooltip request-contact-value" : "")}
|
<span
|
||||||
data-tooltip={clientHasPhone ? clientPhone : undefined}
|
className={"request-field-value" + (clientHasPhone ? " has-tooltip request-contact-value" : "")}
|
||||||
>
|
data-tooltip={clientHasPhone ? clientPhone : undefined}
|
||||||
{clientLabel}
|
>
|
||||||
</span>
|
{clientLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="request-description-meta-item">
|
||||||
|
<span className="request-field-label">Юрист</span>
|
||||||
|
<span
|
||||||
|
className={"request-field-value" + (lawyerHasPhone ? " has-tooltip request-contact-value" : "")}
|
||||||
|
data-tooltip={lawyerHasPhone ? lawyerPhone : undefined}
|
||||||
|
>
|
||||||
|
{lawyerLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="request-description-meta-item">
|
||||||
|
<span className="request-field-label">Создана</span>
|
||||||
|
<span className="request-field-value">{fmtShortDateTime(row?.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="request-description-meta-item">
|
||||||
|
<span className="request-field-label">Изменена</span>
|
||||||
|
<span className="request-field-value">{fmtShortDateTime(row?.updated_at)}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="request-description-meta-item align-right">
|
</div>
|
||||||
<span className="request-field-label">Юрист</span>
|
<div className="request-description-modal-facts">
|
||||||
<span
|
<div className="request-description-fact-card">
|
||||||
className={"request-field-value" + (lawyerHasPhone ? " has-tooltip request-contact-value" : "")}
|
<span className="request-field-label">Номер заявки</span>
|
||||||
data-tooltip={lawyerHasPhone ? lawyerPhone : undefined}
|
<span className="request-field-value">{row?.track_number ? String(row.track_number) : "—"}</span>
|
||||||
>
|
|
||||||
{lawyerLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="request-description-meta-item">
|
<div className="request-description-fact-card">
|
||||||
<span className="request-field-label">Создана</span>
|
<span className="request-field-label">Тема</span>
|
||||||
<span className="request-field-value">{fmtShortDateTime(row?.created_at)}</span>
|
<span className="request-field-value">{String(row?.topic_name || row?.topic_code || "Не указана")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="request-description-meta-item align-right">
|
<div className="request-description-fact-card">
|
||||||
<span className="request-field-label">Изменена</span>
|
<span className="request-field-label">Статус</span>
|
||||||
<span className="request-field-value">{fmtShortDateTime(row?.updated_at)}</span>
|
<span className="request-field-value">{currentStatusName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="request-description-fact-card">
|
||||||
|
<span className="request-field-label">Важная дата</span>
|
||||||
|
<span className="request-field-value">{fmtShortDateTime(row?.important_date_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2723,22 +2763,18 @@ export function RequestWorkspace({
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Тип</label>
|
<label>Тип</label>
|
||||||
<select
|
<DropdownField
|
||||||
value={rowItem.field_type || "string"}
|
value={rowItem.field_type || "string"}
|
||||||
onChange={(event) => updateDataRequestRow(rowItem.localId, { field_type: event.target.value })}
|
onChange={(nextValue) => updateDataRequestRow(rowItem.localId, { field_type: nextValue })}
|
||||||
disabled={
|
disabled={
|
||||||
dataRequestModal.loading ||
|
dataRequestModal.loading ||
|
||||||
dataRequestModal.saving ||
|
dataRequestModal.saving ||
|
||||||
dataRequestModal.savingTemplate ||
|
dataRequestModal.savingTemplate ||
|
||||||
(viewerRoleCode === "LAWYER" && rowItem?.is_filled)
|
(viewerRoleCode === "LAWYER" && rowItem?.is_filled)
|
||||||
}
|
}
|
||||||
>
|
options={requestDataTypeOptions.map((option) => ({ value: option.value, label: option.label }))}
|
||||||
{requestDataTypeOptions.map((option) => (
|
placeholder="Выберите тип"
|
||||||
<option key={option.value} value={option.value}>
|
/>
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="request-data-row-controls">
|
<div className="request-data-row-controls">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
93
app/web/admin/shared/DropdownField.jsx
Normal file
93
app/web/admin/shared/DropdownField.jsx
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
const { useEffect, useMemo, useRef, useState } = React;
|
||||||
|
|
||||||
|
export function DropdownField({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
placeholder = "Выберите значение",
|
||||||
|
disabled = false,
|
||||||
|
className = "",
|
||||||
|
ariaLabel = "",
|
||||||
|
}) {
|
||||||
|
const rootRef = useRef(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const normalizedOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
(Array.isArray(options) ? options : []).map((option) => ({
|
||||||
|
value: String(option?.value ?? ""),
|
||||||
|
label: String(option?.label ?? option?.value ?? ""),
|
||||||
|
disabled: Boolean(option?.disabled),
|
||||||
|
})),
|
||||||
|
[options]
|
||||||
|
);
|
||||||
|
const currentValue = String(value ?? "");
|
||||||
|
const currentOption = normalizedOptions.find((option) => option.value === currentValue) || null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
const handlePointerDown = (event) => {
|
||||||
|
if (rootRef.current && !rootRef.current.contains(event.target)) setOpen(false);
|
||||||
|
};
|
||||||
|
const handleKeyDown = (event) => {
|
||||||
|
if (event.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handlePointerDown);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handlePointerDown);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (disabled && open) setOpen(false);
|
||||||
|
}, [disabled, open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={"dropdown-field" + (open ? " open" : "") + (disabled ? " disabled" : "") + (className ? " " + className : "")} ref={rootRef}>
|
||||||
|
<button
|
||||||
|
id={id}
|
||||||
|
type="button"
|
||||||
|
className="dropdown-field-trigger"
|
||||||
|
aria-label={ariaLabel || placeholder}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open ? "true" : "false"}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<span className={"dropdown-field-label" + (currentOption ? "" : " placeholder")}>
|
||||||
|
{currentOption ? currentOption.label : placeholder}
|
||||||
|
</span>
|
||||||
|
<svg className="dropdown-field-caret" viewBox="0 0 14 14" width="14" height="14" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M3.2 4.8a.75.75 0 0 1 1.06 0L7 7.54 9.74 4.8a.75.75 0 1 1 1.06 1.06L7.53 9.13a.75.75 0 0 1-1.06 0L3.2 5.86a.75.75 0 0 1 0-1.06Z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div className="dropdown-field-menu" role="listbox" aria-labelledby={id}>
|
||||||
|
{normalizedOptions.length ? (
|
||||||
|
normalizedOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
className={"dropdown-field-option" + (option.value === currentValue ? " selected" : "")}
|
||||||
|
aria-selected={option.value === currentValue ? "true" : "false"}
|
||||||
|
disabled={option.disabled}
|
||||||
|
onClick={() => {
|
||||||
|
if (option.disabled) return;
|
||||||
|
setOpen(false);
|
||||||
|
if (typeof onChange === "function") onChange(option.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="dropdown-field-empty">Нет доступных значений</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
422
app/web/admin/shared/RecordModal.jsx
Normal file
422
app/web/admin/shared/RecordModal.jsx
Normal file
|
|
@ -0,0 +1,422 @@
|
||||||
|
import { DropdownField } from "./DropdownField.jsx";
|
||||||
|
import { resolveAvatarSrc, roleLabel } from "./utils.js";
|
||||||
|
|
||||||
|
const { useEffect, useRef, useState } = React;
|
||||||
|
|
||||||
|
export function RecordModal({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
tableKey,
|
||||||
|
mode,
|
||||||
|
fields,
|
||||||
|
form,
|
||||||
|
status,
|
||||||
|
accessToken,
|
||||||
|
onClose,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
onUploadField,
|
||||||
|
OverlayComponent,
|
||||||
|
IconButtonComponent,
|
||||||
|
UserAvatarComponent,
|
||||||
|
StatusLineComponent,
|
||||||
|
}) {
|
||||||
|
const Overlay = OverlayComponent;
|
||||||
|
const IconButton = IconButtonComponent;
|
||||||
|
const UserAvatar = UserAvatarComponent;
|
||||||
|
const StatusLine = StatusLineComponent;
|
||||||
|
const [avatarPreviewOpen, setAvatarPreviewOpen] = useState(false);
|
||||||
|
const [userEditing, setUserEditing] = useState(false);
|
||||||
|
const avatarUploadRef = useRef(null);
|
||||||
|
const visibleFields = (fields || []).filter((field) => {
|
||||||
|
if (typeof field.visibleWhen !== "function") return true;
|
||||||
|
try {
|
||||||
|
return Boolean(field.visibleWhen(form || {}));
|
||||||
|
} catch (_) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const isUserModal = tableKey === "users";
|
||||||
|
const avatarField = isUserModal ? visibleFields.find((field) => field.key === "avatar_url") : null;
|
||||||
|
const topicField = isUserModal ? visibleFields.find((field) => field.key === "primary_topic_code") : null;
|
||||||
|
const formFields = isUserModal ? visibleFields.filter((field) => field.key !== "avatar_url") : visibleFields;
|
||||||
|
const fieldMap = new Map(visibleFields.map((field) => [field.key, field]));
|
||||||
|
const avatarValue = String(form?.avatar_url || "").trim();
|
||||||
|
const userName = String(form?.name || "").trim();
|
||||||
|
const userEmail = String(form?.email || "").trim();
|
||||||
|
const userPhone = String(form?.phone || "").trim();
|
||||||
|
const userRole = roleLabel(form?.role);
|
||||||
|
const topicOptions = topicField && typeof topicField.options === "function" ? topicField.options(form || {}) : [];
|
||||||
|
const currentTopicValue = String(form?.primary_topic_code || "").trim();
|
||||||
|
const userTopic =
|
||||||
|
(topicOptions.find((option) => String(option?.value || "") === currentTopicValue)?.label || currentTopicValue || "").trim() ||
|
||||||
|
"Профиль не указан";
|
||||||
|
const defaultRate = String(form?.default_rate || "").trim();
|
||||||
|
const salaryPercent = String(form?.salary_percent || "").trim();
|
||||||
|
const userActiveRaw = String(form?.is_active ?? "");
|
||||||
|
const activeLabel = userActiveRaw === "false" ? "Неактивен" : userActiveRaw === "true" || !userActiveRaw ? "Активен" : "Статус не задан";
|
||||||
|
const avatarPreviewSrc = avatarValue ? resolveAvatarSrc(avatarValue, accessToken, 512) : "";
|
||||||
|
const statusTone = userActiveRaw === "false" ? "danger" : userActiveRaw === "true" || !userActiveRaw ? "success" : "warn";
|
||||||
|
const isCreateMode = isUserModal && mode === "create";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isUserModal) {
|
||||||
|
setUserEditing(false);
|
||||||
|
setAvatarPreviewOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUserEditing(isCreateMode);
|
||||||
|
setAvatarPreviewOpen(false);
|
||||||
|
}, [isCreateMode, isUserModal, open, tableKey]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const renderField = (field) => {
|
||||||
|
const value = form[field.key] ?? "";
|
||||||
|
const options = typeof field.options === "function" ? field.options(form || {}) : [];
|
||||||
|
const id = "record-field-" + field.key;
|
||||||
|
const disabled = Boolean(field.readOnly) || (typeof field.readOnlyWhen === "function" ? Boolean(field.readOnlyWhen(form || {})) : false);
|
||||||
|
|
||||||
|
if (field.type === "textarea" || field.type === "json") {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
id={id}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(field.key, event.target.value)}
|
||||||
|
placeholder={field.placeholder || ""}
|
||||||
|
required={Boolean(field.required)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (field.type === "boolean") {
|
||||||
|
return (
|
||||||
|
<DropdownField
|
||||||
|
id={id}
|
||||||
|
value={value}
|
||||||
|
onChange={(nextValue) => onChange(field.key, nextValue)}
|
||||||
|
options={[
|
||||||
|
{ value: "true", label: "Да" },
|
||||||
|
{ value: "false", label: "Нет" },
|
||||||
|
]}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="Выберите значение"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (field.type === "reference" || field.type === "enum") {
|
||||||
|
const extraOptions = Array.isArray(field.extraOptions) ? field.extraOptions : [];
|
||||||
|
const hasCurrentValue =
|
||||||
|
String(value || "").trim() !== "" &&
|
||||||
|
[...extraOptions, ...options].some((option) => String(option?.value || "") === String(value));
|
||||||
|
const selectOptions = [];
|
||||||
|
if (field.optional) selectOptions.push({ value: "", label: "-" });
|
||||||
|
if (!hasCurrentValue && String(value || "").trim() !== "") selectOptions.push({ value: String(value), label: String(value) });
|
||||||
|
extraOptions.forEach((option) => selectOptions.push({ value: String(option.value), label: option.label }));
|
||||||
|
options.forEach((option) => selectOptions.push({ value: String(option.value), label: option.label }));
|
||||||
|
return (
|
||||||
|
<DropdownField
|
||||||
|
id={id}
|
||||||
|
value={value}
|
||||||
|
onChange={(nextValue) => onChange(field.key, nextValue)}
|
||||||
|
options={selectOptions}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={field.optional ? "-" : field.placeholder || "Выберите значение"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (field.uploadScope) {
|
||||||
|
return (
|
||||||
|
<div className="field-inline">
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(field.key, event.target.value)}
|
||||||
|
placeholder={field.placeholder || ""}
|
||||||
|
required={Boolean(field.required)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<label className="btn secondary btn-sm" style={{ whiteSpace: "nowrap", opacity: disabled ? 0.6 : 1, pointerEvents: disabled ? "none" : "auto" }}>
|
||||||
|
Загрузить
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept={field.accept || "*/*"}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files && event.target.files[0];
|
||||||
|
if (file && onUploadField) onUploadField(field, file);
|
||||||
|
event.target.value = "";
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type={field.type === "number" ? "number" : field.type === "password" ? "password" : "text"}
|
||||||
|
step={field.type === "number" ? "any" : undefined}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(field.key, event.target.value)}
|
||||||
|
placeholder={field.placeholder || ""}
|
||||||
|
required={Boolean(field.required)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderUserCard = (fieldKey) => {
|
||||||
|
const field = fieldMap.get(fieldKey);
|
||||||
|
if (!field) return null;
|
||||||
|
const value = form[fieldKey] ?? "";
|
||||||
|
const isPassword = fieldKey === "password";
|
||||||
|
const inEdit = isCreateMode || userEditing;
|
||||||
|
let content = null;
|
||||||
|
|
||||||
|
if (inEdit) {
|
||||||
|
content = renderField(field);
|
||||||
|
} else if (isPassword) {
|
||||||
|
content = <span className="record-user-card-value muted">Пароль скрыт</span>;
|
||||||
|
} else {
|
||||||
|
let displayValue = value;
|
||||||
|
if (fieldKey === "role") displayValue = userRole || "Не указана";
|
||||||
|
if (fieldKey === "is_active") displayValue = activeLabel;
|
||||||
|
if (fieldKey === "primary_topic_code") displayValue = userTopic;
|
||||||
|
if (fieldKey === "default_rate") displayValue = defaultRate || "—";
|
||||||
|
if (fieldKey === "salary_percent") displayValue = salaryPercent || "—";
|
||||||
|
content = <span className="record-user-card-value">{String(displayValue || "Не указано")}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="record-user-card" key={fieldKey}>
|
||||||
|
<span className="record-user-card-label">{field.label}</span>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderUserRateCard = () => {
|
||||||
|
const inEdit = isCreateMode || userEditing;
|
||||||
|
if (inEdit) {
|
||||||
|
return (
|
||||||
|
<div className="record-user-card" key="rate-combo">
|
||||||
|
<span className="record-user-card-label">Ставка / % зарплаты</span>
|
||||||
|
<div className="record-user-rate-grid">
|
||||||
|
{fieldMap.get("default_rate") ? renderField(fieldMap.get("default_rate")) : null}
|
||||||
|
{fieldMap.get("salary_percent") ? renderField(fieldMap.get("salary_percent")) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="record-user-summary-item" key="rate-combo-view">
|
||||||
|
<span className="record-user-summary-label">Ставка / % зарплаты</span>
|
||||||
|
<span className="record-user-summary-value">{defaultRate || "—"} / {salaryPercent || "—"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Overlay open={open} id="record-overlay" onClose={(event) => event.target.id === "record-overlay" && onClose()}>
|
||||||
|
<div className={"modal" + (isUserModal ? " record-user-modal" : "")} style={{ width: isUserModal ? "min(920px, 100%)" : "min(760px, 100%)" }} onClick={(event) => event.stopPropagation()}>
|
||||||
|
<div className="modal-head">
|
||||||
|
<div>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<p className="muted" style={{ marginTop: "0.35rem" }}>
|
||||||
|
{isUserModal ? (isCreateMode ? "Создание профиля пользователя." : userEditing ? "Редактирование профиля пользователя." : "Просмотр профиля пользователя.") : "Создание и редактирование записи."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal-head-actions">
|
||||||
|
{isUserModal && !isCreateMode ? (
|
||||||
|
userEditing ? (
|
||||||
|
<>
|
||||||
|
<button className="icon-btn" type="submit" form="record-modal-form" data-tooltip="Сохранить" aria-label="Сохранить">
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M5 4h11.59a2 2 0 0 1 1.41.59l1.41 1.41A2 2 0 0 1 20 7.41V19a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a1 1 0 0 1 1-1Zm1 2v13h12V8.24L15.76 6H15v4a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V6H6Zm4 0v3h3V6h-3Z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button className="icon-btn" type="button" onClick={onClose} data-tooltip="Закрыть" aria-label="Закрыть">
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M6.7 6.7a1 1 0 0 1 1.4 0L12 10.58l3.9-3.88a1 1 0 1 1 1.4 1.42L13.42 12l3.88 3.9a1 1 0 1 1-1.42 1.4L12 13.42l-3.9 3.88a1 1 0 0 1-1.4-1.42L10.58 12 6.7 8.1a1 1 0 0 1 0-1.4Z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button className="icon-btn" type="button" onClick={() => setUserEditing(true)} data-tooltip="Редактировать" aria-label="Редактировать">
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M15.86 3.49a2 2 0 0 1 2.83 0l1.82 1.82a2 2 0 0 1 0 2.83l-9.9 9.9a1 1 0 0 1-.45.26l-4 1a1 1 0 0 1-1.21-1.21l1-4a1 1 0 0 1 .26-.45l9.9-9.9Zm1.41 1.42-9.67 9.67-.54 2.16 2.16-.54 9.67-9.67-1.62-1.62Z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
<button className="close" type="button" onClick={onClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form className={"stack" + (isUserModal ? " record-user-scroll" : "")} id="record-modal-form" onSubmit={onSubmit}>
|
||||||
|
{isUserModal ? (
|
||||||
|
<div className="record-user-top">
|
||||||
|
<div className="record-user-avatar-area">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={"record-user-avatar-shell" + (avatarPreviewSrc ? " interactive" : "")}
|
||||||
|
onClick={() => {
|
||||||
|
if (avatarPreviewSrc) setAvatarPreviewOpen(true);
|
||||||
|
}}
|
||||||
|
disabled={!avatarPreviewSrc}
|
||||||
|
aria-label={avatarPreviewSrc ? "Открыть аватар крупно" : "Аватар не загружен"}
|
||||||
|
>
|
||||||
|
<UserAvatar name={userName} email={userEmail} avatarUrl={avatarValue} accessToken={accessToken} size={148} />
|
||||||
|
</button>
|
||||||
|
{avatarField && (isCreateMode || userEditing) ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={avatarUploadRef}
|
||||||
|
type="file"
|
||||||
|
accept={avatarField.accept || "image/*"}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files && event.target.files[0];
|
||||||
|
if (file && onUploadField) onUploadField(avatarField, file);
|
||||||
|
event.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="record-user-avatar-toolbar">
|
||||||
|
<IconButton
|
||||||
|
icon={
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M12 5a1 1 0 0 1 1 1v6.17l2.59-2.58a1 1 0 1 1 1.41 1.42l-4.29 4.29a1 1 0 0 1-1.42 0L7 11.01a1 1 0 1 1 1.41-1.42L11 12.17V6a1 1 0 0 1 1-1Zm-7 12a1 1 0 0 1 1 1v1h12v-1a1 1 0 1 1 2 0v2a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1Z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
tooltip="Загрузить аватар"
|
||||||
|
onClick={() => avatarUploadRef.current?.click()}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M6.7 6.7a1 1 0 0 1 1.4 0L12 10.58l3.9-3.88a1 1 0 1 1 1.4 1.42L13.42 12l3.88 3.9a1 1 0 1 1-1.42 1.4L12 13.42l-3.9 3.88a1 1 0 0 1-1.4-1.42L10.58 12 6.7 8.1a1 1 0 0 1 0-1.4Z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
tooltip="Сбросить аватар"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(avatarField.key, "");
|
||||||
|
setAvatarPreviewOpen(false);
|
||||||
|
}}
|
||||||
|
disabled={!avatarValue}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="record-user-summary">
|
||||||
|
<div className="record-user-summary-head">
|
||||||
|
{isCreateMode || userEditing ? renderUserCard("name") : <h4>{userName || "Новый пользователь"}</h4>}
|
||||||
|
{isCreateMode || userEditing ? (
|
||||||
|
<div className="record-user-summary-edit-meta">
|
||||||
|
{renderUserCard("role")}
|
||||||
|
{renderUserCard("is_active")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="record-user-summary-badges">
|
||||||
|
<span className="record-user-badge">{userRole || "Роль не выбрана"}</span>
|
||||||
|
<span className={"record-user-badge status-" + statusTone}>{activeLabel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="record-user-summary-grid">
|
||||||
|
{isCreateMode || userEditing ? (
|
||||||
|
<>
|
||||||
|
{renderUserCard("email")}
|
||||||
|
{renderUserCard("phone")}
|
||||||
|
{renderUserCard("primary_topic_code")}
|
||||||
|
{renderUserRateCard()}
|
||||||
|
{renderUserCard("password")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="record-user-summary-item">
|
||||||
|
<span className="record-user-summary-label">Email</span>
|
||||||
|
<span className="record-user-summary-value">{userEmail || "Не указан"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="record-user-summary-item">
|
||||||
|
<span className="record-user-summary-label">Телефон</span>
|
||||||
|
<span className="record-user-summary-value">{userPhone || "Не указан"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="record-user-summary-item">
|
||||||
|
<span className="record-user-summary-label">Профиль</span>
|
||||||
|
<span className="record-user-summary-value">{userTopic}</span>
|
||||||
|
</div>
|
||||||
|
{renderUserRateCard()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!isUserModal ? (
|
||||||
|
<div className="filters" style={{ gridTemplateColumns: "repeat(2, minmax(0,1fr))" }}>
|
||||||
|
{formFields.map((field) => (
|
||||||
|
<div className="field" key={field.key} style={field.fullRow ? { gridColumn: "1 / -1" } : undefined}>
|
||||||
|
<label htmlFor={"record-field-" + field.key}>{field.label}</label>
|
||||||
|
{renderField(field)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{isUserModal && isCreateMode ? (
|
||||||
|
<div style={{ display: "flex", gap: "0.6rem", flexWrap: "wrap" }}>
|
||||||
|
<button className="btn" type="submit">
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
<button className="btn secondary" type="button" onClick={onClose}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!isUserModal ? (
|
||||||
|
<div style={{ display: "flex", gap: "0.6rem", flexWrap: "wrap" }}>
|
||||||
|
<button className="btn" type="submit">
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
<button className="btn secondary" type="button" onClick={onClose}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<StatusLine status={status} />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{isUserModal ? (
|
||||||
|
<Overlay open={avatarPreviewOpen} id="record-avatar-preview-overlay" onClose={() => setAvatarPreviewOpen(false)}>
|
||||||
|
<div className="modal record-avatar-preview-modal" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<div className="modal-head">
|
||||||
|
<div>
|
||||||
|
<h3>{userName || "Аватар пользователя"}</h3>
|
||||||
|
<p className="muted" style={{ marginTop: "0.35rem" }}>
|
||||||
|
Простомотр изображения.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button className="close" type="button" onClick={() => setAvatarPreviewOpen(false)} aria-label="Закрыть">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="record-avatar-preview-body">
|
||||||
|
{avatarPreviewSrc ? (
|
||||||
|
<img className="record-avatar-preview-image" src={avatarPreviewSrc} alt={userName || userEmail || "avatar"} />
|
||||||
|
) : (
|
||||||
|
<div className="record-avatar-preview-empty">
|
||||||
|
<UserAvatar name={userName} email={userEmail} avatarUrl="" accessToken={accessToken} size={128} />
|
||||||
|
<span>Аватар еще не загружен</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Overlay>
|
||||||
|
) : null}
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -63,6 +63,34 @@ echo $? # 0=OK, >0=ALERT
|
||||||
./scripts/ops/perf_baseline.sh http://localhost:8081
|
./scripts/ops/perf_baseline.sh http://localhost:8081
|
||||||
```
|
```
|
||||||
Отчет сохраняется в `reports/perf/perf-baseline-<timestamp>.md`. Скрипт логинится под `admin@example.com / admin123`, берет первую заявку из канбана и замеряет `kanban`, `request detail`, `chat messages/live`, `status-route`, `attachments`, `invoices`.
|
Отчет сохраняется в `reports/perf/perf-baseline-<timestamp>.md`. Скрипт логинится под `admin@example.com / admin123`, берет первую заявку из канбана и замеряет `kanban`, `request detail`, `chat messages/live`, `status-route`, `attachments`, `invoices`.
|
||||||
|
11. Smoke-тест `PUT /s3/*` через frontend proxy:
|
||||||
|
```bash
|
||||||
|
./scripts/ops/s3_proxy_upload_smoke.sh http://localhost:8081
|
||||||
|
COMPOSE_OVERRIDE=docker-compose.prod.nginx.yml ./scripts/ops/s3_proxy_upload_smoke.sh https://ruakb.online
|
||||||
|
```
|
||||||
|
Скрипт создает временный pre-signed upload для `smoke-tests/*`, выполняет `PUT` через `/s3`, сверяет размер объекта в MinIO и удаляет временный объект.
|
||||||
|
Примечание для local: host-порты `minio` вынесены в переменные `LOCAL_MINIO_API_PORT` и `LOCAL_MINIO_CONSOLE_PORT`, default: `19100/19101`, чтобы не конфликтовать с другими проектами.
|
||||||
|
|
||||||
|
## Чеклист split-деплоя upload/S3-контура
|
||||||
|
1. Пересобрать и перезапустить только затронутые сервисы:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.nginx.yml up -d --build frontend backend
|
||||||
|
```
|
||||||
|
2. Проверить конфиг frontend nginx:
|
||||||
|
```bash
|
||||||
|
docker exec law-frontend sh -lc "nginx -t && nginx -T | sed -n '/location \\/s3\\//,/}/p'"
|
||||||
|
```
|
||||||
|
3. Выполнить smoke-тест upload proxy:
|
||||||
|
```bash
|
||||||
|
COMPOSE_OVERRIDE=docker-compose.prod.nginx.yml ./scripts/ops/s3_proxy_upload_smoke.sh https://ruakb.online
|
||||||
|
```
|
||||||
|
4. При сбое снять последние логи:
|
||||||
|
```bash
|
||||||
|
docker logs law-frontend --tail 50
|
||||||
|
docker logs law-edge --tail 50
|
||||||
|
docker logs law-backend --tail 50
|
||||||
|
docker logs law-minio --tail 50
|
||||||
|
```
|
||||||
|
|
||||||
## Матрица проверок по задачам
|
## Матрица проверок по задачам
|
||||||
| ID | Что проверяем | Где тесты | Как запускать |
|
| ID | Что проверяем | Где тесты | Как запускать |
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@ services:
|
||||||
|
|
||||||
minio:
|
minio:
|
||||||
ports:
|
ports:
|
||||||
- "9000:9000"
|
- "${LOCAL_MINIO_API_PORT:-19100}:9000"
|
||||||
- "9001:9001"
|
- "${LOCAL_MINIO_CONSOLE_PORT:-19101}:9001"
|
||||||
|
|
||||||
# Local/dev: use multi-arch image (works on Apple Silicon/ARM64).
|
# Local/dev: use multi-arch image (works on Apple Silicon/ARM64).
|
||||||
clamav:
|
clamav:
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
email-service:
|
email-service:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
minio:
|
||||||
|
condition: service_started
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1/health >/dev/null 2>&1 && wget -q -O - http://127.0.0.1/chat-health >/dev/null 2>&1 && wget -q -O - http://127.0.0.1/email-health >/dev/null 2>&1"]
|
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1/health >/dev/null 2>&1 && wget -q -O - http://127.0.0.1/chat-health >/dev/null 2>&1 && wget -q -O - http://127.0.0.1/email-health >/dev/null 2>&1"]
|
||||||
interval: 20s
|
interval: 20s
|
||||||
|
|
@ -35,6 +37,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
NODE_PATH: /opt/e2e/node_modules
|
NODE_PATH: /opt/e2e/node_modules
|
||||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1"
|
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1"
|
||||||
|
E2E_BASE_URL: http://frontend
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: .
|
build: .
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ const {
|
||||||
trackCleanupTrack,
|
trackCleanupTrack,
|
||||||
trackCleanupEmail,
|
trackCleanupEmail,
|
||||||
cleanupTrackedTestData,
|
cleanupTrackedTestData,
|
||||||
|
selectDropdownOption,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
|
|
||||||
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com";
|
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com";
|
||||||
|
|
@ -33,7 +34,6 @@ test("admin flow via UI: dictionaries + users + topics + invoices", async ({ con
|
||||||
trackCleanupTrack(testInfo, trackNumber);
|
trackCleanupTrack(testInfo, trackNumber);
|
||||||
|
|
||||||
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
|
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
|
||||||
await expect(page.locator("aside .auth-box")).toContainText("Роль: Администратор");
|
|
||||||
await expect(page.locator("#section-dashboard h2")).toHaveText("Обзор метрик");
|
await expect(page.locator("#section-dashboard h2")).toHaveText("Обзор метрик");
|
||||||
await expect(page.locator("#section-dashboard")).toContainText("Загрузка юристов");
|
await expect(page.locator("#section-dashboard")).toContainText("Загрузка юристов");
|
||||||
|
|
||||||
|
|
@ -49,11 +49,11 @@ test("admin flow via UI: dictionaries + users + topics + invoices", async ({ con
|
||||||
const topicName = `Тема UI ${unique}`;
|
const topicName = `Тема UI ${unique}`;
|
||||||
|
|
||||||
await selectDictionaryNode(page, "Пользователи");
|
await selectDictionaryNode(page, "Пользователи");
|
||||||
await page.locator("#section-config .config-panel").getByRole("button", { name: "Добавить" }).click();
|
await page.locator("#section-config .section-head").getByRole("button", { name: "Добавить" }).click();
|
||||||
await expect(page.getByRole("heading", { name: /Создание • Пользователи/ })).toBeVisible();
|
await expect(page.getByRole("heading", { name: /Создание • Пользователи/ })).toBeVisible();
|
||||||
await page.locator("#record-field-name").fill(`Юрист UI ${unique}`);
|
await page.locator("#record-field-name").fill(`Юрист UI ${unique}`);
|
||||||
await page.locator("#record-field-email").fill(lawyerEmail);
|
await page.locator("#record-field-email").fill(lawyerEmail);
|
||||||
await page.locator("#record-field-role").selectOption("LAWYER");
|
await selectDropdownOption(page, "#record-field-role", "Юрист");
|
||||||
await page.locator("#record-field-default_rate").fill("5000");
|
await page.locator("#record-field-default_rate").fill("5000");
|
||||||
await page.locator("#record-field-salary_percent").fill("35");
|
await page.locator("#record-field-salary_percent").fill("35");
|
||||||
await page.locator("#record-field-password").fill("UiLawyerPass-123!");
|
await page.locator("#record-field-password").fill("UiLawyerPass-123!");
|
||||||
|
|
@ -62,7 +62,7 @@ test("admin flow via UI: dictionaries + users + topics + invoices", async ({ con
|
||||||
await expect(page.locator("#section-config table")).toContainText(lawyerEmail);
|
await expect(page.locator("#section-config table")).toContainText(lawyerEmail);
|
||||||
|
|
||||||
await selectDictionaryNode(page, "Темы");
|
await selectDictionaryNode(page, "Темы");
|
||||||
await page.locator("#section-config .config-panel").getByRole("button", { name: "Добавить" }).click();
|
await page.locator("#section-config .section-head").getByRole("button", { name: "Добавить" }).click();
|
||||||
await expect(page.getByRole("heading", { name: /Создание • Темы/ })).toBeVisible();
|
await expect(page.getByRole("heading", { name: /Создание • Темы/ })).toBeVisible();
|
||||||
await page.locator("#record-field-name").fill(topicName);
|
await page.locator("#record-field-name").fill(topicName);
|
||||||
await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
|
await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
|
||||||
|
|
@ -70,16 +70,13 @@ test("admin flow via UI: dictionaries + users + topics + invoices", async ({ con
|
||||||
|
|
||||||
const topicRow = page.locator("#section-config table tbody tr").filter({ hasText: topicName });
|
const topicRow = page.locator("#section-config table tbody tr").filter({ hasText: topicName });
|
||||||
await expect(topicRow).toHaveCount(1);
|
await expect(topicRow).toHaveCount(1);
|
||||||
const topicCode = (await topicRow.first().locator("td code").innerText()).trim();
|
|
||||||
|
|
||||||
await page.locator("aside .menu button[data-section='invoices']").click();
|
await page.locator("aside .menu button[data-section='invoices']").click();
|
||||||
await expect(page.locator("#section-invoices h2")).toHaveText("Счета");
|
await expect(page.locator("#section-invoices h2")).toHaveText("Счета");
|
||||||
await page.locator("#section-invoices").getByRole("button", { name: "Новый счет" }).click();
|
await page.locator("#section-invoices .section-head").getByRole("button", { name: "Добавить" }).click();
|
||||||
await expect(page.getByRole("heading", { name: /Создание • Счета/ })).toBeVisible();
|
await expect(page.getByRole("heading", { name: /Создание • Счета/ })).toBeVisible();
|
||||||
await page.locator("#record-field-request_track_number").fill(trackNumber);
|
await selectDropdownOption(page, "#record-field-request_track_number", trackNumber);
|
||||||
await page.locator("#record-field-amount").fill("15000");
|
await page.locator("#record-field-amount").fill("15000");
|
||||||
await page.locator("#record-field-payer_display_name").fill("Тестовый плательщик");
|
|
||||||
await page.locator("#record-field-payer_details").fill('{"inn":"7700000000"}');
|
|
||||||
await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
|
await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
|
||||||
await expect(page.locator("#section-invoices .status")).toContainText("Список обновлен");
|
await expect(page.locator("#section-invoices .status")).toContainText("Список обновлен");
|
||||||
|
|
||||||
|
|
@ -89,7 +86,7 @@ test("admin flow via UI: dictionaries + users + topics + invoices", async ({ con
|
||||||
|
|
||||||
await invoiceRow.first().getByRole("button", { name: "Редактировать счет" }).click();
|
await invoiceRow.first().getByRole("button", { name: "Редактировать счет" }).click();
|
||||||
await expect(page.getByRole("heading", { name: /Редактирование • Счета/ })).toBeVisible();
|
await expect(page.getByRole("heading", { name: /Редактирование • Счета/ })).toBeVisible();
|
||||||
await page.locator("#record-field-status").selectOption("PAID");
|
await selectDropdownOption(page, "#record-field-status", "Оплачен");
|
||||||
await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
|
await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
|
||||||
await expect(page.locator("#section-invoices .status")).toContainText("Список обновлен");
|
await expect(page.locator("#section-invoices .status")).toContainText("Список обновлен");
|
||||||
await expect(invoiceRow.first()).toContainText("Оплачен");
|
await expect(invoiceRow.first()).toContainText("Оплачен");
|
||||||
|
|
|
||||||
47
e2e/tests/admin_shell_smoke.spec.js
Normal file
47
e2e/tests/admin_shell_smoke.spec.js
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
const { test, expect } = require("@playwright/test");
|
||||||
|
const { loginAdminPanel, openDictionaryTree, selectDictionaryNode, cleanupTrackedTestData } = 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("admin shell smoke: sidebar collapse/expand and user modal opens by name", async ({ page }) => {
|
||||||
|
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
|
||||||
|
|
||||||
|
const collapseButton = page.locator("aside .sidebar-head").getByRole("button", { name: "Свернуть меню" });
|
||||||
|
await expect(collapseButton).toBeVisible();
|
||||||
|
await collapseButton.click();
|
||||||
|
await expect(page.locator(".layout.sidebar-collapsed .sidebar")).toBeVisible();
|
||||||
|
await expect(page.locator(".layout.sidebar-collapsed .menu button .menu-label").first()).toBeHidden();
|
||||||
|
await expect(page.locator(".layout.sidebar-collapsed .menu button .menu-icon").first()).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator("aside .menu").getByRole("button", { name: "Справочники" }).click();
|
||||||
|
await expect(page.locator(".layout.sidebar-collapsed")).toHaveCount(0);
|
||||||
|
await expect(page.locator("aside .menu .menu-tree")).toBeVisible();
|
||||||
|
|
||||||
|
await openDictionaryTree(page);
|
||||||
|
await selectDictionaryNode(page, "Пользователи");
|
||||||
|
|
||||||
|
const firstUserRow = page.locator("#section-config table tbody tr").first();
|
||||||
|
await expect(firstUserRow).toBeVisible();
|
||||||
|
await expect(firstUserRow.getByRole("button", { name: "Редактировать пользователя" })).toHaveCount(0);
|
||||||
|
|
||||||
|
const userNameLink = firstUserRow.locator(".user-identity-link").first();
|
||||||
|
const userName = ((await userNameLink.textContent()) || "").trim();
|
||||||
|
await userNameLink.click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: /Редактирование • Пользователи/ })).toBeVisible();
|
||||||
|
await expect(page.locator("#record-overlay")).toContainText("Просмотр профиля пользователя.");
|
||||||
|
await expect(page.locator("#record-overlay .record-user-summary")).toContainText(userName);
|
||||||
|
|
||||||
|
await page.locator("#record-overlay").getByRole("button", { name: "Редактировать" }).click();
|
||||||
|
await expect(page.locator("#record-overlay")).toContainText("Редактирование профиля пользователя.");
|
||||||
|
await expect(page.locator("#record-field-role")).toBeVisible();
|
||||||
|
await expect(page.locator("#record-overlay").getByRole("button", { name: "Сохранить" })).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator("#record-overlay .modal > .modal-head .modal-head-actions > .close").click();
|
||||||
|
await expect(page.locator("#record-overlay")).toBeHidden();
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
const { test, expect } = require("@playwright/test");
|
const { test, expect } = require("@playwright/test");
|
||||||
const { loginAdminPanel, openDictionaryTree, cleanupTrackedTestData } = require("./helpers");
|
const { loginAdminPanel, openDictionaryTree, cleanupTrackedTestData, openDropdown } = require("./helpers");
|
||||||
|
|
||||||
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com";
|
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com";
|
||||||
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || "admin123";
|
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || "admin123";
|
||||||
|
|
@ -23,15 +23,15 @@ test("admin status designer: open transitions dictionary and prefill topic in cr
|
||||||
|
|
||||||
const topicSelect = page.locator("#status-designer-topic");
|
const topicSelect = page.locator("#status-designer-topic");
|
||||||
await expect(topicSelect).toBeVisible();
|
await expect(topicSelect).toBeVisible();
|
||||||
const optionCount = await topicSelect.locator("option").count();
|
const dropdownRoot = await openDropdown(page, topicSelect);
|
||||||
expect(optionCount).toBeGreaterThan(1);
|
const realOption = dropdownRoot.locator(".dropdown-field-option").nth(1);
|
||||||
|
await expect(realOption).toBeVisible();
|
||||||
await topicSelect.selectOption({ index: 1 });
|
const selectedTopicLabel = ((await realOption.textContent()) || "").trim();
|
||||||
const selectedTopic = await topicSelect.inputValue();
|
await realOption.click();
|
||||||
expect(selectedTopic).not.toBe("");
|
expect(selectedTopicLabel).not.toBe("");
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Добавить переход" }).click();
|
await page.getByRole("button", { name: "Добавить переход" }).click();
|
||||||
await expect(page.getByRole("heading", { name: /Создание • Переходы статусов/ })).toBeVisible();
|
await expect(page.getByRole("heading", { name: /Создание • Переходы статусов/ })).toBeVisible();
|
||||||
await expect(page.locator("#record-field-topic_code")).toHaveValue(selectedTopic);
|
await expect(page.locator("#record-field-topic_code")).toContainText(selectedTopicLabel);
|
||||||
await page.locator("#record-overlay .close").click();
|
await page.locator("#record-overlay .close").click();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -227,57 +227,63 @@ async function preparePublicSession(context, page, appUrl, phone) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createRequestViaLanding(page, options = {}) {
|
async function createRequestViaLanding(page, options = {}) {
|
||||||
|
const baseUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
|
||||||
const phone = options.phone || randomPhone();
|
const phone = options.phone || randomPhone();
|
||||||
const name = options.name || `Клиент E2E ${Date.now()}`;
|
const name = options.name || `Клиент E2E ${Date.now()}`;
|
||||||
const description = options.description || "Проверка создания заявки через UI";
|
const description = options.description || "Проверка создания заявки через UI";
|
||||||
|
const topicsResponse = await page.request.get(`${baseUrl}/api/public/requests/topics`, {
|
||||||
await page.goto("/");
|
headers: {
|
||||||
await expect(page.getByRole("heading", { name: "Решаем сложные юридические задачи в интересах вашего бизнеса." })).toBeVisible();
|
Origin: baseUrl,
|
||||||
|
Referer: `${baseUrl}/`,
|
||||||
await page.getByRole("button", { name: "Оставить заявку" }).first().click();
|
},
|
||||||
await expect(page.getByRole("heading", { name: "Создание заявки" })).toBeVisible();
|
});
|
||||||
|
if (!topicsResponse.ok()) {
|
||||||
await page.locator("#name").fill(name);
|
throw new Error(`Не удалось загрузить темы: ${topicsResponse.status()} ${await topicsResponse.text().catch(() => "")}`);
|
||||||
await page.locator("#phone").fill(phone);
|
}
|
||||||
const topicSelect = page.locator("#topic");
|
const topics = await topicsResponse.json();
|
||||||
await topicSelect.waitFor();
|
const topicCode = String(topics?.[0]?.code || "").trim();
|
||||||
await topicSelect.selectOption({ index: 1 });
|
if (!topicCode) {
|
||||||
await page.locator("#description").fill(description);
|
throw new Error("Не найдена доступная тема для E2E-создания заявки");
|
||||||
await page.getByRole("button", { name: "Отправить заявку" }).click();
|
|
||||||
|
|
||||||
const otpModal = page.locator("#otp-modal");
|
|
||||||
const otpCodeInput = page.locator("#otp-modal-code");
|
|
||||||
const otpSubmit = page.locator("#otp-modal-submit");
|
|
||||||
if (await otpModal.isVisible().catch(() => false)) {
|
|
||||||
await otpCodeInput.fill("000000");
|
|
||||||
await otpSubmit.click();
|
|
||||||
} else {
|
|
||||||
await otpModal.waitFor({ state: "visible", timeout: 5000 }).catch(() => null);
|
|
||||||
if (await otpModal.isVisible().catch(() => false)) {
|
|
||||||
await otpCodeInput.fill("000000");
|
|
||||||
await otpSubmit.click();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await expect(page.locator("#form-status")).toContainText("Заявка принята. Номер:");
|
const createResponse = await page.request.post(`${baseUrl}/api/public/requests`, {
|
||||||
const statusText = await page.locator("#form-status").innerText();
|
headers: {
|
||||||
const match = statusText.match(/TRK-[A-Z0-9-]+/);
|
Origin: baseUrl,
|
||||||
if (!match) throw new Error("Track number not found in form status");
|
Referer: `${baseUrl}/`,
|
||||||
|
},
|
||||||
return { trackNumber: match[0], phone, name };
|
data: {
|
||||||
}
|
client_name: name,
|
||||||
|
client_phone: phone,
|
||||||
async function openPublicCabinet(page, trackNumber) {
|
client_email: options.email || "",
|
||||||
const baseUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
|
topic_code: topicCode,
|
||||||
|
description,
|
||||||
|
pdn_consent: true,
|
||||||
|
extra_fields: {},
|
||||||
|
},
|
||||||
|
failOnStatusCode: false,
|
||||||
|
});
|
||||||
|
const createPayload = await createResponse.json().catch(() => null);
|
||||||
|
if (!createResponse.ok()) {
|
||||||
|
throw new Error(`Не удалось создать заявку: ${createResponse.status()} ${createPayload?.detail || JSON.stringify(createPayload || {})}`);
|
||||||
|
}
|
||||||
|
const trackNumber = String(createPayload?.track_number || "").trim().toUpperCase();
|
||||||
|
if (!trackNumber) {
|
||||||
|
throw new Error("Track number not returned by public create request");
|
||||||
|
}
|
||||||
await page.context().addCookies([
|
await page.context().addCookies([
|
||||||
{
|
{
|
||||||
name: PUBLIC_COOKIE_NAME,
|
name: PUBLIC_COOKIE_NAME,
|
||||||
value: createPublicViewCookieToken(String(trackNumber || "").trim().toUpperCase()),
|
value: createPublicViewCookieToken(trackNumber),
|
||||||
url: `${baseUrl}/`,
|
url: `${baseUrl}/`,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "Lax",
|
sameSite: "Lax",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
return { trackNumber, phone, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPublicCabinet(page, trackNumber) {
|
||||||
await page.goto(`/client.html?track=${encodeURIComponent(trackNumber)}`);
|
await page.goto(`/client.html?track=${encodeURIComponent(trackNumber)}`);
|
||||||
await expect(page.locator("#cabinet-summary")).toBeVisible();
|
await expect(page.locator("#cabinet-summary")).toBeVisible();
|
||||||
await expect(page.locator("#cabinet-request-status")).not.toHaveText("-");
|
await expect(page.locator("#cabinet-request-status")).not.toHaveText("-");
|
||||||
|
|
@ -347,6 +353,35 @@ async function openRequestsSection(page) {
|
||||||
await expect(page.locator("#section-requests h2")).toHaveText("Заявки");
|
await expect(page.locator("#section-requests h2")).toHaveText("Заявки");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dropdownLocator(page, target) {
|
||||||
|
return typeof target === "string" ? page.locator(target) : target;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDropdown(page, target) {
|
||||||
|
const trigger = dropdownLocator(page, target);
|
||||||
|
await expect(trigger).toBeVisible();
|
||||||
|
await trigger.click();
|
||||||
|
const root = trigger.locator("xpath=ancestor-or-self::*[contains(concat(' ', normalize-space(@class), ' '), ' dropdown-field ')]").first();
|
||||||
|
await expect(root.locator(".dropdown-field-menu")).toBeVisible();
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectDropdownOption(page, target, optionText) {
|
||||||
|
const root = await openDropdown(page, target);
|
||||||
|
const option = root.locator(".dropdown-field-option").filter({ hasText: optionText }).first();
|
||||||
|
await expect(option).toBeVisible();
|
||||||
|
await option.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectFirstDropdownOption(page, target) {
|
||||||
|
const root = await openDropdown(page, target);
|
||||||
|
const option = root.locator(".dropdown-field-option").first();
|
||||||
|
await expect(option).toBeVisible();
|
||||||
|
const label = ((await option.textContent()) || "").trim();
|
||||||
|
await option.click();
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
function rowByTrack(page, sectionSelector, trackNumber) {
|
function rowByTrack(page, sectionSelector, trackNumber) {
|
||||||
return page.locator(`${sectionSelector} table tbody tr`).filter({ hasText: trackNumber });
|
return page.locator(`${sectionSelector} table tbody tr`).filter({ hasText: trackNumber });
|
||||||
}
|
}
|
||||||
|
|
@ -365,7 +400,7 @@ async function openDictionaryTree(page) {
|
||||||
|
|
||||||
async function selectDictionaryNode(page, label) {
|
async function selectDictionaryNode(page, label) {
|
||||||
await page.locator("aside .menu .menu-tree").getByRole("button", { name: label, exact: true }).click();
|
await page.locator("aside .menu .menu-tree").getByRole("button", { name: label, exact: true }).click();
|
||||||
await expect(page.locator("#section-config .config-panel h3")).toContainText(label);
|
await expect(page.locator("#section-config .section-head .breadcrumbs")).toContainText(label);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|
@ -382,7 +417,10 @@ module.exports = {
|
||||||
uploadCabinetFile,
|
uploadCabinetFile,
|
||||||
loginAdminPanel,
|
loginAdminPanel,
|
||||||
openRequestsSection,
|
openRequestsSection,
|
||||||
|
openDropdown,
|
||||||
rowByTrack,
|
rowByTrack,
|
||||||
|
selectDropdownOption,
|
||||||
|
selectFirstDropdownOption,
|
||||||
openDictionaryTree,
|
openDictionaryTree,
|
||||||
selectDictionaryNode,
|
selectDictionaryNode,
|
||||||
buildTinyPdfBuffer,
|
buildTinyPdfBuffer,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ const {
|
||||||
createRequestViaLanding,
|
createRequestViaLanding,
|
||||||
randomPhone,
|
randomPhone,
|
||||||
loginAdminPanel,
|
loginAdminPanel,
|
||||||
|
selectDropdownOption,
|
||||||
|
selectFirstDropdownOption,
|
||||||
trackCleanupPhone,
|
trackCleanupPhone,
|
||||||
trackCleanupTrack,
|
trackCleanupTrack,
|
||||||
cleanupTrackedTestData,
|
cleanupTrackedTestData,
|
||||||
|
|
@ -22,7 +24,7 @@ test("kanban flow via UI: lawyer sees unassigned card, claims and opens request
|
||||||
trackCleanupPhone(testInfo, phone);
|
trackCleanupPhone(testInfo, phone);
|
||||||
|
|
||||||
await preparePublicSession(context, page, appUrl, phone);
|
await preparePublicSession(context, page, appUrl, phone);
|
||||||
const { trackNumber } = await createRequestViaLanding(page, {
|
const { trackNumber, name } = await createRequestViaLanding(page, {
|
||||||
phone,
|
phone,
|
||||||
description: "Заявка для проверки канбана юриста",
|
description: "Заявка для проверки канбана юриста",
|
||||||
});
|
});
|
||||||
|
|
@ -32,18 +34,17 @@ test("kanban flow via UI: lawyer sees unassigned card, claims and opens request
|
||||||
await page.locator("aside .menu button[data-section='kanban']").click();
|
await page.locator("aside .menu button[data-section='kanban']").click();
|
||||||
await expect(page.locator("#section-kanban h2")).toHaveText("Канбан заявок");
|
await expect(page.locator("#section-kanban h2")).toHaveText("Канбан заявок");
|
||||||
|
|
||||||
await page.locator("#section-kanban .filter-toolbar").getByRole("button", { name: "Фильтр" }).click();
|
await page.locator("#section-kanban .section-head-actions").getByRole("button", { name: "Фильтр" }).click();
|
||||||
await expect(page.getByRole("heading", { name: "Фильтр таблицы" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Фильтр таблицы" })).toBeVisible();
|
||||||
await page.locator("#filter-field").selectOption("client_name");
|
await selectDropdownOption(page, "#filter-field", "Клиент");
|
||||||
await page.locator("#filter-op").selectOption("~");
|
await page.locator("#filter-value").fill(name);
|
||||||
await page.locator("#filter-value").fill("Клиент");
|
|
||||||
await page.locator("#filter-overlay").getByRole("button", { name: /Добавить|Сохранить|Добавить\/Сохранить/i }).click();
|
await page.locator("#filter-overlay").getByRole("button", { name: /Добавить|Сохранить|Добавить\/Сохранить/i }).click();
|
||||||
await expect(page.locator("#section-kanban .filter-chip")).toHaveCount(1);
|
await expect(page.locator("#section-kanban .filter-chip")).toHaveCount(1);
|
||||||
|
|
||||||
const sortButton = page.locator("#section-kanban .section-head").getByRole("button", { name: "Сортировка" });
|
const sortButton = page.locator("#section-kanban .section-head").getByRole("button", { name: "Сортировка" });
|
||||||
await sortButton.click();
|
await sortButton.click();
|
||||||
await expect(page.getByRole("heading", { name: "Сортировка канбана" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Сортировка канбана" })).toBeVisible();
|
||||||
await page.locator("#kanban-sort-mode").selectOption("deadline");
|
await selectDropdownOption(page, "#kanban-sort-mode", "Дедлайн");
|
||||||
await page.locator("#kanban-sort-overlay").getByRole("button", { name: "Ок" }).click();
|
await page.locator("#kanban-sort-overlay").getByRole("button", { name: "Ок" }).click();
|
||||||
await expect(sortButton).toHaveClass(/active-success/);
|
await expect(sortButton).toHaveClass(/active-success/);
|
||||||
|
|
||||||
|
|
@ -58,14 +59,8 @@ test("kanban flow via UI: lawyer sees unassigned card, claims and opens request
|
||||||
|
|
||||||
const transitionSelect = page.locator("#section-kanban .kanban-card").filter({ hasText: trackNumber }).first().locator(".kanban-transition-select");
|
const transitionSelect = page.locator("#section-kanban .kanban-card").filter({ hasText: trackNumber }).first().locator(".kanban-transition-select");
|
||||||
if (await transitionSelect.count()) {
|
if (await transitionSelect.count()) {
|
||||||
const targetValue = await transitionSelect
|
const selectedLabel = await selectFirstDropdownOption(page, transitionSelect.first().locator(".dropdown-field-trigger"));
|
||||||
.first()
|
if (selectedLabel) {
|
||||||
.locator("option:not([value=''])")
|
|
||||||
.first()
|
|
||||||
.getAttribute("value")
|
|
||||||
.catch(() => "");
|
|
||||||
if (targetValue) {
|
|
||||||
await transitionSelect.first().selectOption(targetValue);
|
|
||||||
await expect(page.locator("#section-kanban .status")).toContainText(/Статус заявки обновлен|Ошибка перехода|Канбан обновлен/);
|
await expect(page.locator("#section-kanban .status")).toContainText(/Статус заявки обновлен|Ошибка перехода|Канбан обновлен/);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ const {
|
||||||
trackCleanupPhone,
|
trackCleanupPhone,
|
||||||
trackCleanupTrack,
|
trackCleanupTrack,
|
||||||
cleanupTrackedTestData,
|
cleanupTrackedTestData,
|
||||||
|
selectDropdownOption,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
|
|
||||||
const LAWYER_EMAIL = process.env.E2E_LAWYER_EMAIL || "ivan@mail.ru";
|
const LAWYER_EMAIL = process.env.E2E_LAWYER_EMAIL || "ivan@mail.ru";
|
||||||
|
|
@ -41,7 +42,7 @@ test("lawyer flow via UI: claim request -> chat and files in request workspace t
|
||||||
await uploadCabinetFile(page, clientFileName, "lawyer unread marker");
|
await uploadCabinetFile(page, clientFileName, "lawyer unread marker");
|
||||||
|
|
||||||
await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
|
await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
|
||||||
await expect(page.locator("aside .auth-box")).toContainText("Роль: Юрист");
|
await expect(page.locator("#section-dashboard")).toContainText("Моя загрузка");
|
||||||
|
|
||||||
await openRequestsSection(page);
|
await openRequestsSection(page);
|
||||||
|
|
||||||
|
|
@ -106,15 +107,17 @@ test("lawyer flow via UI: claim request -> chat and files in request workspace t
|
||||||
await page.locator("#section-requests").getByRole("button", { name: "Обновить" }).click();
|
await page.locator("#section-requests").getByRole("button", { name: "Обновить" }).click();
|
||||||
await expect(row.first().locator(".request-update-empty")).toContainText("нет");
|
await expect(row.first().locator(".request-update-empty")).toContainText("нет");
|
||||||
|
|
||||||
await row.first().getByRole("button", { name: "Редактировать заявку" }).click();
|
await row.first().locator(".request-track-link").click();
|
||||||
await expect(page.getByRole("heading", { name: /Редактирование • Заявки/ })).toBeVisible();
|
await expect(page.getByRole("heading", { name: /Карточка заявки/ })).toBeVisible();
|
||||||
await page.locator("#record-field-status_code").selectOption("IN_PROGRESS");
|
await page.getByRole("button", { name: "Сменить статус" }).click();
|
||||||
await page.locator("#record-overlay").getByRole("button", { name: "Сохранить" }).click();
|
await expect(page.getByRole("heading", { name: "Смена статуса" })).toBeVisible();
|
||||||
await expect(page.locator("#section-requests .status")).toContainText("Список обновлен");
|
await selectDropdownOption(page, "#status-change-next-status", "В работе");
|
||||||
await expect(row.first()).toContainText("В работе");
|
await page.locator(".request-status-change-modal").getByRole("button", { name: "Отправить" }).click();
|
||||||
|
await expect(page.locator("#section-request-workspace .status")).toContainText(/Статус заявки обновлен|Карточка заявки загружена/);
|
||||||
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await openPublicCabinet(page, trackNumber);
|
await openPublicCabinet(page, trackNumber);
|
||||||
await expect(page.locator("#cabinet-messages")).toContainText(lawyerMessage);
|
await expect(page.locator("#cabinet-messages")).toContainText(lawyerMessage);
|
||||||
|
await page.getByRole("tab", { name: /Файлы/ }).click();
|
||||||
await expect(page.locator("#cabinet-files")).toContainText(lawyerFileName);
|
await expect(page.locator("#cabinet-files")).toContainText(lawyerFileName);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ const {
|
||||||
trackCleanupPhone,
|
trackCleanupPhone,
|
||||||
trackCleanupTrack,
|
trackCleanupTrack,
|
||||||
cleanupTrackedTestData,
|
cleanupTrackedTestData,
|
||||||
|
selectDropdownOption,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
|
|
||||||
const LAWYER_EMAIL = process.env.E2E_LAWYER_EMAIL || "ivan@mail.ru";
|
const LAWYER_EMAIL = process.env.E2E_LAWYER_EMAIL || "ivan@mail.ru";
|
||||||
|
|
@ -33,7 +34,7 @@ test("request data file field flow via UI: lawyer requests file -> client upload
|
||||||
trackCleanupTrack(testInfo, trackNumber);
|
trackCleanupTrack(testInfo, trackNumber);
|
||||||
|
|
||||||
await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
|
await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
|
||||||
await expect(page.locator("aside .auth-box")).toContainText("Роль: Юрист");
|
await expect(page.locator("#section-dashboard")).toContainText("Моя загрузка");
|
||||||
await openRequestsSection(page);
|
await openRequestsSection(page);
|
||||||
|
|
||||||
const row = rowByTrack(page, "#section-requests", trackNumber);
|
const row = rowByTrack(page, "#section-requests", trackNumber);
|
||||||
|
|
@ -53,10 +54,11 @@ test("request data file field flow via UI: lawyer requests file -> client upload
|
||||||
const catalogFieldInput = page.locator("#request-data-template-select");
|
const catalogFieldInput = page.locator("#request-data-template-select");
|
||||||
const fileFieldLabel = `Файл для проверки E2E ${Date.now()}`;
|
const fileFieldLabel = `Файл для проверки E2E ${Date.now()}`;
|
||||||
|
|
||||||
await catalogFieldInput.fill(fileFieldLabel);
|
await catalogFieldInput.click();
|
||||||
|
await catalogFieldInput.pressSequentially(fileFieldLabel);
|
||||||
await page.locator(".request-data-modal-grid").filter({ hasText: "Поле данных" }).getByRole("button").click();
|
await page.locator(".request-data-modal-grid").filter({ hasText: "Поле данных" }).getByRole("button").click();
|
||||||
await expect(page.locator(".request-data-rows .request-data-row").first().locator("input").first()).toHaveValue(fileFieldLabel);
|
await expect(page.locator(".request-data-rows .request-data-row").first().locator("input").first()).toHaveValue(fileFieldLabel);
|
||||||
await page.locator(".request-data-rows .request-data-row").first().locator("select").selectOption("file");
|
await selectDropdownOption(page, page.locator(".request-data-rows .request-data-row").first().locator(".dropdown-field-trigger"), "Файл");
|
||||||
|
|
||||||
await page.locator(".request-data-modal .modal-actions").getByRole("button", { name: "Отправить" }).click();
|
await page.locator(".request-data-modal .modal-actions").getByRole("button", { name: "Отправить" }).click();
|
||||||
const requestDataModal = page.locator(".request-data-modal");
|
const requestDataModal = page.locator(".request-data-modal");
|
||||||
|
|
|
||||||
|
|
@ -90,9 +90,13 @@ server {
|
||||||
}
|
}
|
||||||
|
|
||||||
location /s3/ {
|
location /s3/ {
|
||||||
set $minio_upstream http://minio:9000;
|
set $minio_host minio;
|
||||||
proxy_pass $minio_upstream/;
|
rewrite ^/s3/(.*)$ /$1 break;
|
||||||
|
proxy_pass http://$minio_host:9000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
access_log /dev/stdout;
|
||||||
|
proxy_request_buffering on;
|
||||||
|
proxy_set_header Connection "";
|
||||||
proxy_set_header Host minio:9000;
|
proxy_set_header Host minio:9000;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
|
||||||
|
|
@ -90,14 +90,19 @@ server {
|
||||||
}
|
}
|
||||||
|
|
||||||
location /s3/ {
|
location /s3/ {
|
||||||
set $minio_upstream https://minio:9000;
|
set $minio_host minio;
|
||||||
proxy_pass $minio_upstream/;
|
rewrite ^/s3/(.*)$ /$1 break;
|
||||||
|
proxy_pass https://$minio_host:9000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
access_log /dev/stdout;
|
||||||
|
proxy_request_buffering on;
|
||||||
|
proxy_set_header Connection "";
|
||||||
proxy_set_header Host minio:9000;
|
proxy_set_header Host minio:9000;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
|
proxy_ssl_session_reuse off;
|
||||||
proxy_ssl_server_name on;
|
proxy_ssl_server_name on;
|
||||||
proxy_ssl_name minio;
|
proxy_ssl_name minio;
|
||||||
proxy_ssl_trusted_certificate /etc/nginx/minio-ca.crt;
|
proxy_ssl_trusted_certificate /etc/nginx/minio-ca.crt;
|
||||||
|
|
|
||||||
82
scripts/ops/s3_proxy_upload_smoke.sh
Executable file
82
scripts/ops/s3_proxy_upload_smoke.sh
Executable file
|
|
@ -0,0 +1,82 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_URL="${1:-http://localhost:8081}"
|
||||||
|
COMPOSE_OVERRIDE="${COMPOSE_OVERRIDE:-docker-compose.local.yml}"
|
||||||
|
COMPOSE=(docker compose -f docker-compose.yml -f "$COMPOSE_OVERRIDE")
|
||||||
|
CONTENT_TYPE="text/plain"
|
||||||
|
PAYLOAD="s3-proxy-smoke $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
TMP_BODY="$(mktemp)"
|
||||||
|
TMP_RESP="$(mktemp)"
|
||||||
|
TMP_GET="$(mktemp)"
|
||||||
|
DELETE_URL=""
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -f "$TMP_BODY" "$TMP_RESP" "$TMP_GET"
|
||||||
|
if [ -n "$DELETE_URL" ]; then
|
||||||
|
curl -sS -o /dev/null -X DELETE "${BASE_URL%/}${DELETE_URL}" || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
printf '%s' "$PAYLOAD" > "$TMP_BODY"
|
||||||
|
|
||||||
|
JSON_PAYLOAD="$(${COMPOSE[@]} run --rm --no-deps -T backend python - <<'PY'
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from app.services.s3_storage import S3Storage
|
||||||
|
|
||||||
|
storage = S3Storage()
|
||||||
|
key = f"smoke-tests/{uuid.uuid4().hex}.txt"
|
||||||
|
put_url = storage.client.generate_presigned_url(
|
||||||
|
"put_object",
|
||||||
|
Params={"Bucket": storage.bucket, "Key": key, "ContentType": "text/plain"},
|
||||||
|
ExpiresIn=300,
|
||||||
|
HttpMethod="PUT",
|
||||||
|
)
|
||||||
|
get_url = storage.client.generate_presigned_url(
|
||||||
|
"get_object",
|
||||||
|
Params={"Bucket": storage.bucket, "Key": key},
|
||||||
|
ExpiresIn=300,
|
||||||
|
HttpMethod="GET",
|
||||||
|
)
|
||||||
|
delete_url = storage.client.generate_presigned_url(
|
||||||
|
"delete_object",
|
||||||
|
Params={"Bucket": storage.bucket, "Key": key},
|
||||||
|
ExpiresIn=300,
|
||||||
|
HttpMethod="DELETE",
|
||||||
|
)
|
||||||
|
print(json.dumps({
|
||||||
|
"key": key,
|
||||||
|
"put_url": storage._proxy_presigned_url(put_url),
|
||||||
|
"get_url": storage._proxy_presigned_url(get_url),
|
||||||
|
"delete_url": storage._proxy_presigned_url(delete_url),
|
||||||
|
}))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
KEY="$(printf '%s' "$JSON_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin)["key"])')"
|
||||||
|
PUT_URL="$(printf '%s' "$JSON_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin)["put_url"])')"
|
||||||
|
GET_URL="$(printf '%s' "$JSON_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin)["get_url"])')"
|
||||||
|
DELETE_URL="$(printf '%s' "$JSON_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin)["delete_url"])')"
|
||||||
|
|
||||||
|
HTTP_CODE="$(curl -sS -o "$TMP_RESP" -w '%{http_code}' -X PUT "${BASE_URL%/}${PUT_URL}" -H "Content-Type: $CONTENT_TYPE" --data-binary @"$TMP_BODY")"
|
||||||
|
if [ "$HTTP_CODE" != "200" ]; then
|
||||||
|
echo "[S3-SMOKE] PUT failed: HTTP $HTTP_CODE"
|
||||||
|
cat "$TMP_RESP"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
HTTP_CODE="$(curl -sS -o "$TMP_GET" -w '%{http_code}' -X GET "${BASE_URL%/}${GET_URL}")"
|
||||||
|
if [ "$HTTP_CODE" != "200" ]; then
|
||||||
|
echo "[S3-SMOKE] GET failed: HTTP $HTTP_CODE"
|
||||||
|
cat "$TMP_GET"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! cmp -s "$TMP_BODY" "$TMP_GET"; then
|
||||||
|
echo "[S3-SMOKE] downloaded body mismatch for key=$KEY"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[S3-SMOKE] OK base_url=$BASE_URL key=$KEY bytes=$(wc -c < "$TMP_BODY" | tr -d ' ')"
|
||||||
|
|
@ -570,6 +570,58 @@ class RequestRatesTests(unittest.TestCase):
|
||||||
self.assertEqual(client.full_name, "Новый клиент из админки")
|
self.assertEqual(client.full_name, "Новый клиент из админки")
|
||||||
self.assertEqual(client.phone, "+79990000101")
|
self.assertEqual(client.phone, "+79990000101")
|
||||||
|
|
||||||
|
def test_crud_request_rejects_too_large_request_cost_with_clear_message(self):
|
||||||
|
admin_headers = self._auth_headers("ADMIN", "root@example.com")
|
||||||
|
|
||||||
|
created = self.client.post(
|
||||||
|
"/api/admin/requests",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={
|
||||||
|
"client_name": "Клиент",
|
||||||
|
"client_phone": "+79990000111",
|
||||||
|
"status_code": "NEW",
|
||||||
|
"description": "oversized request cost",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(created.status_code, 201, created.text)
|
||||||
|
request_id = created.json()["id"]
|
||||||
|
|
||||||
|
response = self.client.patch(
|
||||||
|
f"/api/admin/crud/requests/{request_id}",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"request_cost": 1234567890123},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400, response.text)
|
||||||
|
self.assertIn("Стоимость заявки", response.json()["detail"])
|
||||||
|
self.assertIn("12 цифр", response.json()["detail"])
|
||||||
|
|
||||||
|
def test_legacy_request_rejects_too_large_effective_rate_with_clear_message(self):
|
||||||
|
admin_headers = self._auth_headers("ADMIN", "root@example.com")
|
||||||
|
|
||||||
|
created = self.client.post(
|
||||||
|
"/api/admin/requests",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={
|
||||||
|
"client_name": "Клиент",
|
||||||
|
"client_phone": "+79990000112",
|
||||||
|
"status_code": "NEW",
|
||||||
|
"description": "oversized rate",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(created.status_code, 201, created.text)
|
||||||
|
request_id = created.json()["id"]
|
||||||
|
|
||||||
|
response = self.client.patch(
|
||||||
|
f"/api/admin/requests/{request_id}",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"effective_rate": 12345678901},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400, response.text)
|
||||||
|
self.assertIn("Ставка", response.json()["detail"])
|
||||||
|
self.assertIn("10 цифр", response.json()["detail"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue