fix client task view

This commit is contained in:
TronoSfera 2026-02-27 19:43:34 +03:00
parent ff169cb42d
commit 69055921cd
21 changed files with 1618 additions and 728 deletions

View file

@ -31,6 +31,7 @@ SMS_PROVIDER=smsaero
SMSAERO_EMAIL=your_email@example.com SMSAERO_EMAIL=your_email@example.com
SMSAERO_API_KEY=your_api_key SMSAERO_API_KEY=your_api_key
OTP_SMS_TEMPLATE=Your verification code: {code} OTP_SMS_TEMPLATE=Your verification code: {code}
OTP_DEV_MODE=false
``` ```
For local/dev mock mode: For local/dev mock mode:
@ -39,5 +40,11 @@ SMS_PROVIDER=dummy
``` ```
In this mode OTP code is printed to backend logs. In this mode OTP code is printed to backend logs.
You can also force mock mode with real provider settings:
```bash
OTP_DEV_MODE=true
```
When enabled, real SMS sending is disabled and OTP code is printed to backend logs.
Admin health-check endpoint (no SMS send): Admin health-check endpoint (no SMS send):
`GET /api/admin/system/sms-provider-health` `GET /api/admin/system/sms-provider-health`

View file

@ -7,6 +7,7 @@ from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Response from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.exc import SQLAlchemyError
from app.core.config import settings from app.core.config import settings
from app.core.deps import get_public_session from app.core.deps import get_public_session
@ -33,6 +34,7 @@ from app.services.notifications import (
) )
from app.services.request_read_markers import clear_unread_for_client from app.services.request_read_markers import clear_unread_for_client
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.api.admin.requests_modules.status_flow import get_request_status_route_service
from app.schemas.public import ( from app.schemas.public import (
PublicAttachmentRead, PublicAttachmentRead,
PublicMessageCreate, PublicMessageCreate,
@ -263,6 +265,30 @@ def get_request_by_track(
session: dict = Depends(get_public_session), session: dict = Depends(get_public_session),
): ):
req = _request_for_track_or_404(db, session, track_number) req = _request_for_track_or_404(db, session, track_number)
topic_name = None
if str(req.topic_code or "").strip():
try:
topic = db.query(Topic).filter(Topic.code == req.topic_code).first()
topic_name = topic.name if topic and topic.name else None
except SQLAlchemyError:
topic_name = None
lawyer_name = None
lawyer_phone = None
lawyer_id = str(req.assigned_lawyer_id or "").strip()
if lawyer_id:
try:
lawyer_uuid = UUID(lawyer_id)
except ValueError:
lawyer_uuid = None
if lawyer_uuid is not None:
try:
lawyer = db.get(AdminUser, lawyer_uuid)
except SQLAlchemyError:
lawyer = None
if lawyer is not None:
lawyer_name = lawyer.name
lawyer_phone = lawyer.phone
markers_cleared = clear_unread_for_client(req) markers_cleared = clear_unread_for_client(req)
notifications_cleared = mark_client_notifications_read( notifications_cleared = mark_client_notifications_read(
db, db,
@ -282,10 +308,17 @@ def get_request_by_track(
"client_name": req.client_name, "client_name": req.client_name,
"client_phone": req.client_phone, "client_phone": req.client_phone,
"topic_code": req.topic_code, "topic_code": req.topic_code,
"topic_name": topic_name,
"status_code": req.status_code, "status_code": req.status_code,
"important_date_at": _to_iso(req.important_date_at),
"description": req.description, "description": req.description,
"extra_fields": req.extra_fields, "extra_fields": req.extra_fields,
"assigned_lawyer_id": req.assigned_lawyer_id, "assigned_lawyer_id": req.assigned_lawyer_id,
"assigned_lawyer_name": lawyer_name or req.assigned_lawyer_id,
"assigned_lawyer_phone": lawyer_phone,
"request_cost": float(req.request_cost) if req.request_cost is not None else None,
"effective_rate": float(req.effective_rate) if req.effective_rate is not None else None,
"paid_at": _to_iso(req.paid_at),
"client_has_unread_updates": req.client_has_unread_updates, "client_has_unread_updates": req.client_has_unread_updates,
"client_unread_event_type": req.client_unread_event_type, "client_unread_event_type": req.client_unread_event_type,
"lawyer_has_unread_updates": req.lawyer_has_unread_updates, "lawyer_has_unread_updates": req.lawyer_has_unread_updates,
@ -295,6 +328,59 @@ def get_request_by_track(
} }
@router.get("/{track_number}/status-route")
def get_status_route_by_track(
track_number: str,
db: Session = Depends(get_db),
session: dict = Depends(get_public_session),
):
req = _request_for_track_or_404(db, session, track_number)
try:
payload = get_request_status_route_service(
str(req.id),
db,
{"role": "ADMIN", "sub": "", "email": "Клиент"},
)
payload["available_statuses"] = []
return payload
except Exception:
current = str(req.status_code or "").strip()
changed_at = _to_iso(req.updated_at or req.created_at)
return {
"request_id": str(req.id),
"track_number": req.track_number,
"topic_code": req.topic_code,
"current_status": current or None,
"current_important_date_at": _to_iso(req.important_date_at),
"available_statuses": [],
"history": [
{
"id": "current",
"from_status": None,
"to_status": current or None,
"to_status_name": current or None,
"changed_at": changed_at,
"important_date_at": _to_iso(req.important_date_at),
"comment": None,
"duration_seconds": None,
}
],
"nodes": [
{
"code": current or "",
"name": current or "-",
"kind": "DEFAULT",
"state": "current",
"changed_at": changed_at,
"sla_hours": None,
"note": "Текущий этап обработки заявки",
}
]
if current
else [],
}
@router.get("/{track_number}/messages", response_model=list[PublicMessageRead]) @router.get("/{track_number}/messages", response_model=list[PublicMessageRead])
def list_messages_by_track( def list_messages_by_track(
track_number: str, track_number: str,

View file

@ -12,6 +12,7 @@ from app.core.config import settings
from app.core.deps import get_public_session from app.core.deps import get_public_session
from app.db.session import get_db from app.db.session import get_db
from app.models.attachment import Attachment from app.models.attachment import Attachment
from app.models.message import Message
from app.models.request import Request from app.models.request import Request
from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, UploadInitPayload, UploadInitResponse, UploadScope from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, UploadInitPayload, UploadInitResponse, UploadScope
from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event
@ -189,9 +190,18 @@ def upload_complete(
if int(request.total_attachments_bytes or 0) + actual_size > _max_case_bytes(): if int(request.total_attachments_bytes or 0) + actual_size > _max_case_bytes():
raise HTTPException(status_code=400, detail=f"Превышен лимит вложений заявки ({settings.MAX_CASE_MB} МБ)") raise HTTPException(status_code=400, detail=f"Превышен лимит вложений заявки ({settings.MAX_CASE_MB} МБ)")
message_uuid = None
if payload.message_id:
message_uuid = _uuid_or_400(payload.message_id, "message_id")
message = db.get(Message, message_uuid)
if message is None or message.request_id != request.id:
raise HTTPException(status_code=400, detail="Сообщение не найдено для указанной заявки")
if bool(message.immutable):
raise HTTPException(status_code=400, detail="Нельзя прикрепить файл к зафиксированному сообщению")
row = Attachment( row = Attachment(
request_id=request.id, request_id=request.id,
message_id=None, message_id=message_uuid,
file_name=payload.file_name, file_name=payload.file_name,
mime_type=payload.mime_type, mime_type=payload.mime_type,
size_bytes=actual_size, size_bytes=actual_size,

View file

@ -35,6 +35,7 @@ class Settings(BaseSettings):
OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300 OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300
OTP_SEND_RATE_LIMIT: int = 8 OTP_SEND_RATE_LIMIT: int = 8
OTP_VERIFY_RATE_LIMIT: int = 20 OTP_VERIFY_RATE_LIMIT: int = 20
OTP_DEV_MODE: bool = False
ADMIN_BOOTSTRAP_ENABLED: bool = True ADMIN_BOOTSTRAP_ENABLED: bool = True
ADMIN_BOOTSTRAP_EMAIL: str = "admin@example.com" ADMIN_BOOTSTRAP_EMAIL: str = "admin@example.com"
ADMIN_BOOTSTRAP_PASSWORD: str = "admin123" ADMIN_BOOTSTRAP_PASSWORD: str = "admin123"

View file

@ -11,6 +11,10 @@ class SmsDeliveryError(Exception):
pass pass
def _otp_dev_mode_enabled() -> bool:
return bool(getattr(settings, "OTP_DEV_MODE", False))
def _module_available(module_name: str) -> bool: def _module_available(module_name: str) -> bool:
return importlib.util.find_spec(module_name) is not None return importlib.util.find_spec(module_name) is not None
@ -111,11 +115,26 @@ def _get_sms_aero_balance() -> tuple[float | None, dict[str, Any] | None, str |
def sms_provider_health() -> dict[str, Any]: def sms_provider_health() -> dict[str, Any]:
provider = str(settings.SMS_PROVIDER or "dummy").strip().lower() provider = str(settings.SMS_PROVIDER or "dummy").strip().lower()
if _otp_dev_mode_enabled():
return {
"provider": provider or "dummy",
"effective_provider": "mock_sms",
"status": "ok",
"mode": "mock",
"dev_mode": True,
"can_send": True,
"balance_available": False,
"balance_amount": None,
"balance_currency": "RUB",
"checks": {"otp_dev_mode": True},
"issues": ["OTP_DEV_MODE включен: реальная SMS-рассылка отключена"],
}
if provider in {"", "dummy", "mock", "console"}: if provider in {"", "dummy", "mock", "console"}:
return { return {
"provider": "dummy", "provider": "dummy",
"status": "ok", "status": "ok",
"mode": "mock", "mode": "mock",
"dev_mode": False,
"can_send": True, "can_send": True,
"balance_available": False, "balance_available": False,
"balance_amount": None, "balance_amount": None,
@ -156,6 +175,7 @@ def sms_provider_health() -> dict[str, Any]:
"provider": "smsaero", "provider": "smsaero",
"status": "ok" if can_send and balance_available else "degraded", "status": "ok" if can_send and balance_available else "degraded",
"mode": "real", "mode": "real",
"dev_mode": False,
"can_send": can_send, "can_send": can_send,
"balance_available": balance_available, "balance_available": balance_available,
"balance_amount": balance_amount, "balance_amount": balance_amount,
@ -169,6 +189,7 @@ def sms_provider_health() -> dict[str, Any]:
"provider": provider, "provider": provider,
"status": "error", "status": "error",
"mode": "unknown", "mode": "unknown",
"dev_mode": False,
"can_send": False, "can_send": False,
"balance_available": False, "balance_available": False,
"balance_amount": None, "balance_amount": None,
@ -179,6 +200,11 @@ def sms_provider_health() -> dict[str, Any]:
def send_otp_message(*, phone: str, code: str, purpose: str, track_number: str | None = None) -> dict[str, Any]: def send_otp_message(*, phone: str, code: str, purpose: str, track_number: str | None = None) -> dict[str, Any]:
if _otp_dev_mode_enabled():
payload = _mock_sms_send(phone=phone, code=code, purpose=purpose, track_number=track_number)
payload["dev_mode"] = True
return payload
provider = str(settings.SMS_PROVIDER or "dummy").strip().lower() provider = str(settings.SMS_PROVIDER or "dummy").strip().lower()
if provider in {"", "dummy", "mock", "console"}: if provider in {"", "dummy", "mock", "console"}:
return _mock_sms_send(phone=phone, code=code, purpose=purpose, track_number=track_number) return _mock_sms_send(phone=phone, code=code, purpose=purpose, track_number=track_number)

View file

@ -38,8 +38,11 @@ export function RequestWorkspace({
onLoadRequestDataTemplateDetails, onLoadRequestDataTemplateDetails,
onSaveRequestDataTemplate, onSaveRequestDataTemplate,
onSaveRequestDataBatch, onSaveRequestDataBatch,
onSaveRequestDataValues,
onUploadRequestAttachment,
onChangeStatus, onChangeStatus,
onConsumePendingStatusChangePreset, onConsumePendingStatusChangePreset,
domIds,
AttachmentPreviewModalComponent, AttachmentPreviewModalComponent,
StatusLineComponent, StatusLineComponent,
}) { }) {
@ -85,8 +88,33 @@ export function RequestWorkspace({
templateStatus: "", templateStatus: "",
error: "", error: "",
}); });
const [clientDataModal, setClientDataModal] = useState({
open: false,
loading: false,
saving: false,
messageId: "",
items: [],
status: "",
error: "",
});
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
const statusChangeFileInputRef = useRef(null); const statusChangeFileInputRef = useRef(null);
const idMap = useMemo(
() => ({
messagesList: "request-modal-messages",
filesList: "request-modal-files",
messageBody: "request-modal-message-body",
sendButton: "request-modal-message-send",
fileInput: "request-modal-file-input",
fileUploadButton: "",
dataRequestOverlay: "data-request-overlay",
dataRequestItems: "data-request-items",
dataRequestStatus: "data-request-status",
dataRequestSave: "data-request-save",
...(domIds || {}),
}),
[domIds]
);
const requestDataTypeOptions = useMemo( const requestDataTypeOptions = useMemo(
() => [ () => [
{ value: "string", label: "Строка" }, { value: "string", label: "Строка" },
@ -130,6 +158,7 @@ export function RequestWorkspace({
const finance = financeSummary && typeof financeSummary === "object" ? financeSummary : null; const finance = financeSummary && typeof financeSummary === "object" ? financeSummary : null;
const viewerRoleCode = String(viewerRole || "").toUpperCase(); const viewerRoleCode = String(viewerRole || "").toUpperCase();
const canRequestData = viewerRoleCode === "LAWYER" || viewerRoleCode === "ADMIN"; const canRequestData = viewerRoleCode === "LAWYER" || viewerRoleCode === "ADMIN";
const canFillRequestData = viewerRoleCode === "CLIENT";
const canSeeRate = viewerRoleCode !== "CLIENT"; const canSeeRate = viewerRoleCode !== "CLIENT";
const safeMessages = Array.isArray(messages) ? messages : []; const safeMessages = Array.isArray(messages) ? messages : [];
const safeAttachments = Array.isArray(attachments) ? attachments : []; const safeAttachments = Array.isArray(attachments) ? attachments : [];
@ -142,6 +171,7 @@ export function RequestWorkspace({
const lawyerPhone = String(row?.assigned_lawyer_phone || "").trim(); const lawyerPhone = String(row?.assigned_lawyer_phone || "").trim();
const clientHasPhone = Boolean(clientPhone); const clientHasPhone = Boolean(clientPhone);
const lawyerHasPhone = Boolean(lawyerPhone); const lawyerHasPhone = Boolean(lawyerPhone);
const messagePlaceholder = canFillRequestData ? "Введите сообщение для юриста" : "Введите сообщение для клиента";
const selectedRequestTemplateCandidate = useMemo( const selectedRequestTemplateCandidate = useMemo(
() => () =>
@ -692,6 +722,123 @@ export function RequestWorkspace({
} }
}; };
const closeClientDataModal = () => {
setClientDataModal({
open: false,
loading: false,
saving: false,
messageId: "",
items: [],
status: "",
error: "",
});
};
const openClientDataRequestModal = async (messageId) => {
if (!canFillRequestData || typeof onLoadRequestDataBatch !== "function" || !messageId) return;
setClientDataModal({
open: true,
loading: true,
saving: false,
messageId: String(messageId),
items: [],
status: "",
error: "",
});
try {
const data = await onLoadRequestDataBatch(String(messageId));
const items = Array.isArray(data?.items)
? data.items
.slice()
.sort((a, b) => Number(a?.sort_order || 0) - Number(b?.sort_order || 0))
.map((item, index) => ({
localId: "client-data-" + String(item?.id || item?.key || index),
id: String(item?.id || ""),
key: String(item?.key || ""),
label: String(item?.label || item?.key || "Поле"),
field_type: String(item?.field_type || "string").toLowerCase(),
value_text: item?.value_text == null ? "" : String(item.value_text),
value_file: item?.value_file || null,
pendingFile: null,
}))
: [];
setClientDataModal((prev) => ({ ...prev, loading: false, items }));
} catch (error) {
setClientDataModal((prev) => ({ ...prev, loading: false, error: error?.message || "Не удалось открыть запрос данных" }));
}
};
const updateClientDataItem = (localId, patch) => {
setClientDataModal((prev) => ({
...prev,
status: "",
error: "",
items: (prev.items || []).map((item) => (item.localId === localId ? { ...item, ...(patch || {}) } : item)),
}));
};
const submitClientDataModal = async (event) => {
if (event && typeof event.preventDefault === "function") event.preventDefault();
if (!canFillRequestData || typeof onSaveRequestDataValues !== "function") return;
const currentMessageId = String(clientDataModal.messageId || "").trim();
if (!currentMessageId) return;
setClientDataModal((prev) => ({ ...prev, saving: true, status: "", error: "" }));
try {
const payloadItems = [];
for (const item of clientDataModal.items || []) {
const fieldType = String(item?.field_type || "string").toLowerCase();
if (fieldType === "file") {
let attachmentId = String(item?.value_text || "").trim();
if (item?.pendingFile) {
if (typeof onUploadRequestAttachment !== "function") {
throw new Error("Загрузка файла для поля недоступна");
}
const uploadResult = await onUploadRequestAttachment(item.pendingFile, {
source: "data_request",
message_id: currentMessageId,
key: String(item?.key || ""),
});
attachmentId = String(
(uploadResult && (uploadResult.attachment_id || uploadResult.id || uploadResult.value || uploadResult)) || ""
).trim();
if (!attachmentId) throw new Error("Не удалось сохранить файл для поля запроса");
}
payloadItems.push({
id: String(item?.id || ""),
key: String(item?.key || ""),
attachment_id: attachmentId || "",
value_text: attachmentId || "",
});
continue;
}
payloadItems.push({
id: String(item?.id || ""),
key: String(item?.key || ""),
value_text: String(item?.value_text || ""),
});
}
await onSaveRequestDataValues({
message_id: currentMessageId,
items: payloadItems,
});
setClientDataModal((prev) => ({
...prev,
saving: false,
status: "Данные сохранены.",
items: (prev.items || []).map((item) => ({
...item,
pendingFile: null,
})),
}));
} catch (error) {
setClientDataModal((prev) => ({
...prev,
saving: false,
error: error?.message || "Не удалось сохранить данные",
}));
}
};
const handleRequestRowDragStart = (event, rowItem, rowLocked) => { const handleRequestRowDragStart = (event, rowItem, rowLocked) => {
if (rowLocked || dataRequestModal.loading || dataRequestModal.saving || dataRequestModal.savingTemplate) { if (rowLocked || dataRequestModal.loading || dataRequestModal.saving || dataRequestModal.savingTemplate) {
event.preventDefault(); event.preventDefault();
@ -1032,7 +1179,7 @@ export function RequestWorkspace({
</div> </div>
<input <input
id="request-modal-file-input" id={idMap.fileInput}
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
multiple multiple
@ -1043,7 +1190,7 @@ export function RequestWorkspace({
{chatTab === "chat" ? ( {chatTab === "chat" ? (
<> <>
<ul className="simple-list request-modal-list request-chat-list" id="request-modal-messages"> <ul className="simple-list request-modal-list request-chat-list" id={idMap.messagesList}>
{chatTimelineItems.length ? ( {chatTimelineItems.length ? (
chatTimelineItems.map((entry) => chatTimelineItems.map((entry) =>
entry.type === "date" ? ( entry.type === "date" ? (
@ -1077,33 +1224,41 @@ export function RequestWorkspace({
</div> </div>
</li> </li>
) : ( ) : (
<li (() => {
key={entry.key} const messageKind = String(entry.payload?.message_kind || "");
className={ const isRequestDataMessage = messageKind === "REQUEST_DATA";
const requestDataInteractive = isRequestDataMessage && (canRequestData || canFillRequestData);
const bubbleClass =
"chat-message-bubble" +
(isRequestDataMessage ? " chat-request-data-bubble" : "") +
(entry.payload?.request_data_all_filled ? " all-filled" : "") +
(isRequestDataMessage && canFillRequestData ? " request-data-message-btn" : "");
const itemClass =
"chat-message " + "chat-message " +
(String(entry.payload?.author_type || "").toUpperCase() === "CLIENT" ? "incoming" : "outgoing") (String(entry.payload?.author_type || "").toUpperCase() === "CLIENT" ? "incoming" : "outgoing") +
} (isRequestDataMessage && canFillRequestData ? " request-data-item" + (entry.payload?.request_data_all_filled ? " done" : "") : "");
> return (
<li key={entry.key} className={itemClass}>
<div className="chat-message-author">{String(entry.payload?.author_name || entry.payload?.author_type || "Система")}</div> <div className="chat-message-author">{String(entry.payload?.author_name || entry.payload?.author_type || "Система")}</div>
<div <div
className={ className={bubbleClass}
"chat-message-bubble" +
(String(entry.payload?.message_kind || "") === "REQUEST_DATA" ? " chat-request-data-bubble" : "") +
(entry.payload?.request_data_all_filled ? " all-filled" : "")
}
onClick={ onClick={
String(entry.payload?.message_kind || "") === "REQUEST_DATA" && canRequestData requestDataInteractive
? () => openEditDataRequestModal(String(entry.payload?.id || "")) ? () =>
canRequestData
? openEditDataRequestModal(String(entry.payload?.id || ""))
: openClientDataRequestModal(String(entry.payload?.id || ""))
: undefined : undefined
} }
role={String(entry.payload?.message_kind || "") === "REQUEST_DATA" && canRequestData ? "button" : undefined} role={requestDataInteractive ? "button" : undefined}
tabIndex={String(entry.payload?.message_kind || "") === "REQUEST_DATA" && canRequestData ? 0 : undefined} tabIndex={requestDataInteractive ? 0 : undefined}
onKeyDown={ onKeyDown={
String(entry.payload?.message_kind || "") === "REQUEST_DATA" && canRequestData requestDataInteractive
? (event) => { ? (event) => {
if (event.key === "Enter" || event.key === " ") { if (event.key === "Enter" || event.key === " ") {
event.preventDefault(); event.preventDefault();
openEditDataRequestModal(String(entry.payload?.id || "")); if (canRequestData) openEditDataRequestModal(String(entry.payload?.id || ""));
else openClientDataRequestModal(String(entry.payload?.id || ""));
} }
} }
: undefined : undefined
@ -1145,6 +1300,8 @@ export function RequestWorkspace({
<div className="chat-message-time">{fmtTimeOnly(entry.payload?.created_at)}</div> <div className="chat-message-time">{fmtTimeOnly(entry.payload?.created_at)}</div>
</div> </div>
</li> </li>
);
})()
) )
) )
) : ( ) : (
@ -1164,10 +1321,10 @@ export function RequestWorkspace({
}} }}
onDrop={onDropFiles} onDrop={onDropFiles}
> >
<label htmlFor="request-modal-message-body">Новое сообщение</label> <label htmlFor={idMap.messageBody}>Новое сообщение</label>
<textarea <textarea
id="request-modal-message-body" id={idMap.messageBody}
placeholder="Введите сообщение для клиента" placeholder={messagePlaceholder}
value={messageDraft} value={messageDraft}
onChange={onMessageChange} onChange={onMessageChange}
disabled={loading || fileUploading} disabled={loading || fileUploading}
@ -1208,6 +1365,17 @@ export function RequestWorkspace({
Запросить Запросить
</button> </button>
) : null} ) : null}
{canFillRequestData && idMap.fileUploadButton ? (
<button
className="btn secondary btn-sm"
type="button"
id={idMap.fileUploadButton}
onClick={onSendMessage}
disabled={loading || fileUploading || !hasPendingFiles}
>
Загрузить файл
</button>
) : null}
<button <button
className="icon-btn file-action-btn composer-attach-btn" className="icon-btn file-action-btn composer-attach-btn"
type="button" type="button"
@ -1225,7 +1393,7 @@ export function RequestWorkspace({
</button> </button>
<button <button
className="btn" className="btn"
id="request-modal-message-send" id={idMap.sendButton}
type="submit" type="submit"
disabled={loading || fileUploading || !canSubmit} disabled={loading || fileUploading || !canSubmit}
> >
@ -1236,7 +1404,7 @@ export function RequestWorkspace({
</> </>
) : ( ) : (
<div className="request-files-tab"> <div className="request-files-tab">
<ul className="simple-list request-modal-list" id="request-modal-files"> <ul className="simple-list request-modal-list" id={idMap.filesList}>
{safeAttachments.length ? ( {safeAttachments.length ? (
safeAttachments.map((item) => ( safeAttachments.map((item) => (
<li key={String(item.id)}> <li key={String(item.id)}>
@ -1251,7 +1419,7 @@ export function RequestWorkspace({
type="button" type="button"
data-tooltip="Предпросмотр" data-tooltip="Предпросмотр"
onClick={() => openPreview(item)} onClick={() => openPreview(item)}
aria-label={"Предпросмотр: " + String(item.file_name || "файл")} aria-label="Предпросмотр"
> >
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false"> <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
<path <path
@ -1305,6 +1473,126 @@ export function RequestWorkspace({
onClose={closePreview} onClose={closePreview}
/> />
) : null} ) : null}
<div
className={"overlay" + (clientDataModal.open ? " open" : "")}
onClick={closeClientDataModal}
aria-hidden={clientDataModal.open ? "false" : "true"}
id={idMap.dataRequestOverlay}
>
<div className="modal request-data-summary-modal data-request-modal" onClick={(event) => event.stopPropagation()}>
<div className="modal-head">
<div>
<h3>Запрос данных</h3>
<p className="muted request-finance-subtitle">
{row?.track_number ? "Заявка " + String(row.track_number) : "Заполните данные по запросу юриста"}
</p>
</div>
<button className="close" type="button" onClick={closeClientDataModal} aria-label="Закрыть">
×
</button>
</div>
<form className="stack" onSubmit={submitClientDataModal}>
<div className="request-data-summary-list" id={idMap.dataRequestItems}>
{clientDataModal.loading ? (
<p className="muted">Загрузка...</p>
) : (clientDataModal.items || []).length ? (
(clientDataModal.items || []).map((item, index) => {
const fieldType = String(item?.field_type || "string").toLowerCase();
const fileMeta = item?.value_file;
return (
<div className="request-data-summary-row" key={String(item.localId || index)}>
<div className="request-data-summary-label">
{String(index + 1) + ". " + String(item?.label || item?.key || "Поле")}
</div>
<div className="request-data-summary-value">
{fieldType === "text" ? (
<textarea
value={String(item?.value_text || "")}
onChange={(event) =>
updateClientDataItem(item.localId, { value_text: event.target.value })
}
rows={3}
disabled={clientDataModal.saving || clientDataModal.loading}
/>
) : fieldType === "date" ? (
<input
type="date"
value={String(item?.value_text || "").slice(0, 10)}
onChange={(event) =>
updateClientDataItem(item.localId, { value_text: event.target.value })
}
disabled={clientDataModal.saving || clientDataModal.loading}
/>
) : fieldType === "number" ? (
<input
type="number"
step="any"
value={String(item?.value_text || "")}
onChange={(event) =>
updateClientDataItem(item.localId, { value_text: event.target.value })
}
disabled={clientDataModal.saving || clientDataModal.loading}
/>
) : fieldType === "file" ? (
<div className="stack">
{fileMeta && fileMeta.download_url ? (
<button
type="button"
className="chat-message-file-chip"
onClick={() => openAttachmentFromMessage(fileMeta)}
>
<span className="chat-message-file-icon" aria-hidden="true">📎</span>
<span className="chat-message-file-name">{String(fileMeta.file_name || "Файл")}</span>
</button>
) : null}
<input
type="file"
onChange={(event) =>
updateClientDataItem(item.localId, {
pendingFile: event.target.files && event.target.files[0] ? event.target.files[0] : null,
})
}
disabled={clientDataModal.saving || clientDataModal.loading}
/>
{item?.pendingFile ? (
<span className="muted">{String(item.pendingFile.name || "")}</span>
) : null}
</div>
) : (
<input
type="text"
value={String(item?.value_text || "")}
onChange={(event) =>
updateClientDataItem(item.localId, { value_text: event.target.value })
}
disabled={clientDataModal.saving || clientDataModal.loading}
/>
)}
</div>
</div>
);
})
) : (
<p className="muted">Нет полей для заполнения.</p>
)}
</div>
{clientDataModal.error ? <div className="status error">{clientDataModal.error}</div> : null}
<div className={"status" + (clientDataModal.status ? " ok" : "")} id={idMap.dataRequestStatus}>
{clientDataModal.status || ""}
</div>
<div className="modal-actions modal-actions-right">
<button
type="submit"
className="btn btn-sm request-data-submit-btn"
id={idMap.dataRequestSave}
disabled={clientDataModal.loading || clientDataModal.saving}
>
{clientDataModal.saving ? "Сохранение..." : "Сохранить"}
</button>
</div>
</form>
</div>
</div>
<div <div
className={"overlay" + (statusChangeModal.open ? " open" : "")} className={"overlay" + (statusChangeModal.open ? " open" : "")}
onClick={closeStatusChangeModal} onClick={closeStatusChangeModal}

View file

@ -1,601 +1,86 @@
:root { .client-page-shell {
--bg: #0d1217; min-height: 100vh;
--surface: #171f29;
--surface-2: #1f2a37;
--text: #f4f7fb;
--muted: #a8b2c2;
--accent: #d4a968;
--line: rgba(207, 217, 231, 0.18);
--ok: #49b68e;
--danger: #ff7b7b;
--radius: 18px;
--shadow: 0 30px 70px rgba(0, 0, 0, 0.32);
--maxw: 1180px;
} }
* { box-sizing: border-box; } .client-main {
width: min(1400px, calc(100% - 1.6rem));
html,
body {
margin: 0;
padding: 0;
background: radial-gradient(circle at 12% 0%, #1a2430 0, var(--bg) 48%), var(--bg);
color: var(--text);
font-family: "Manrope", sans-serif;
}
.wrap {
width: min(var(--maxw), calc(100% - 1.5rem));
margin: 0 auto; margin: 0 auto;
padding: 1rem 0 1.6rem;
} }
.topbar { .client-topbar p {
position: sticky; margin: 0.35rem 0 0;
top: 0;
z-index: 20;
backdrop-filter: blur(10px);
background: rgba(13, 18, 23, 0.78);
border-bottom: 1px solid var(--line);
} }
.topbar-inner { .client-section {
min-height: 76px; display: block;
display: flex;
align-items: center;
justify-content: space-between;
} }
.brand { .client-request-toolbar {
font-size: 0.84rem;
letter-spacing: 0.12em;
text-transform: uppercase;
font-weight: 800;
color: #eef4ff;
}
.nav a {
text-decoration: none;
color: #d6deea;
font-size: 0.93rem;
font-weight: 600;
}
.client-shell {
padding: 2rem 0 2.5rem;
}
.section-head {
margin-bottom: 1rem;
}
h1 {
margin: 0;
font-family: "Prata", serif;
font-size: clamp(1.75rem, 4vw, 2.7rem);
}
h2 {
margin: 0 0 0.65rem;
font-size: 1.03rem;
}
.subtitle {
margin: 0.65rem 0 0;
color: var(--muted);
line-height: 1.6;
}
.cabinet-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.9rem;
margin-top: 0.9rem;
}
.cabinet-card {
border: 1px solid var(--line);
border-radius: 16px;
background: linear-gradient(160deg, rgba(23, 32, 42, 0.9), rgba(17, 24, 33, 0.95));
padding: 1rem;
box-shadow: var(--shadow);
}
.request-switcher {
display: flex; display: flex;
gap: 0.6rem; gap: 0.6rem;
align-items: end; align-items: flex-end;
margin-bottom: 0.85rem;
} }
.field { .client-request-toolbar .field.grow {
display: flex;
flex-direction: column;
gap: 0.34rem;
}
.field.grow {
flex: 1; flex: 1;
} }
label { .client-summary {
font-size: 0.76rem; margin-bottom: 0.85rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #9fb0c6;
font-weight: 700;
} }
input, .client-summary-grid {
textarea,
select {
width: 100%;
border-radius: 12px;
border: 1px solid #3b4b5f;
background: rgba(255, 255, 255, 0.03);
color: #ecf2fb;
font: inherit;
font-size: 16px;
padding: 0.72rem 0.8rem;
}
textarea {
min-height: 84px;
resize: vertical;
}
.btn {
border: 1px solid transparent;
border-radius: 999px;
padding: 0.82rem 1.25rem;
font-family: inherit;
font-size: 0.93rem;
font-weight: 700;
cursor: pointer;
}
.btn-ghost {
border-color: var(--line);
color: #dde6f2;
background: rgba(255, 255, 255, 0.04);
}
.cabinet-meta {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.65rem;
gap: 0.5rem; grid-template-columns: repeat(4, minmax(0, 1fr));
margin-top: 0.7rem;
} }
.service-request-actions { .client-summary-actions {
margin-top: 0.75rem; margin-top: 0.75rem;
display: flex; display: flex;
gap: 0.55rem;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.45rem;
} }
.meta-row { .client-service-requests {
border: 1px solid var(--line); margin-top: 0.85rem;
border-radius: 12px;
padding: 0.58rem 0.65rem;
background: rgba(255, 255, 255, 0.02);
} }
.meta-row small { .client-service-requests h3 {
display: block; margin: 0 0 0.65rem;
color: #9fb0c6;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 0.2rem;
}
.meta-row b {
display: block;
color: #eaf2ff;
font-size: 0.9rem;
font-weight: 700;
line-height: 1.4;
}
.simple-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.5rem;
max-height: 280px;
overflow: auto;
}
.simple-item {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.58rem 0.65rem;
background: rgba(255, 255, 255, 0.02);
}
.simple-item p {
margin: 0.24rem 0 0;
color: #d8e3f3;
line-height: 1.5;
font-size: 0.92rem;
overflow-wrap: anywhere;
}
.simple-item time {
color: #9eb1ca;
font-size: 0.78rem;
}
.request-data-item {
border-color: rgba(212, 168, 106, 0.35);
background: linear-gradient(160deg, rgba(76, 56, 20, 0.28), rgba(39, 29, 14, 0.34));
}
.request-data-item.done {
border-color: rgba(73, 182, 142, 0.35);
background: linear-gradient(160deg, rgba(40, 86, 66, 0.26), rgba(26, 55, 43, 0.32));
}
.request-data-item-author {
color: #a7b8cf;
font-size: 0.78rem;
}
.request-data-message-btn {
width: 100%;
margin-top: 0.35rem;
text-align: left;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
color: #eef3fb;
padding: 0.55rem 0.65rem;
cursor: pointer;
}
.request-data-message-btn:hover {
border-color: rgba(212, 168, 106, 0.42);
background: rgba(212, 168, 106, 0.1);
}
.request-data-item.done .request-data-message-btn:hover {
border-color: rgba(73, 182, 142, 0.42);
background: rgba(73, 182, 142, 0.09);
}
.request-data-message-title {
font-weight: 800;
color: #ffe0ac;
}
.request-data-item.done .request-data-message-title {
color: #c8eed8;
}
.request-data-message-list {
margin-top: 0.35rem;
display: grid;
gap: 0.16rem;
max-height: 11.6rem;
overflow: hidden;
}
.request-data-message-row {
display: flex;
gap: 0.3rem;
align-items: baseline;
color: #e0e9f7;
font-size: 0.84rem;
}
.request-data-message-row.filled .request-data-message-row-label {
text-decoration: line-through;
color: #b8c4d6;
}
.request-data-message-row-index {
min-width: 1.9rem;
color: #ffd5a1;
font-weight: 700;
display: inline-flex;
align-items: center;
gap: 0.18rem;
}
.request-data-message-row-check {
color: #59d182;
font-weight: 800;
}
.request-data-message-more {
color: #bac7da;
font-size: 0.8rem;
font-weight: 700;
padding-left: 1.95rem;
}
.muted-inline {
margin: 0;
color: var(--muted);
}
.file-actions {
margin-top: 0.45rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.file-link-btn {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
color: #f6d7a8;
text-decoration: none;
font-size: 0.82rem;
padding: 0.32rem 0.68rem;
font: inherit;
}
.file-link-btn:hover {
border-color: rgba(212, 168, 106, 0.45);
background: rgba(212, 168, 106, 0.14);
}
.preview-body .file-link-btn {
margin: 0.7rem;
}
.data-request-modal {
width: min(760px, 100%);
} }
.service-request-modal { .service-request-modal {
width: min(700px, 100%); width: min(720px, 100%);
} }
.service-request-body { .service-request-form textarea {
width: 100%; min-height: 150px;
min-height: 220px;
max-height: calc(92vh - 90px);
overflow: auto;
border: 1px solid var(--line);
border-radius: 12px;
background: #0f1722;
padding: 0.85rem;
display: block;
} }
.service-request-form { #client-page-status {
display: grid;
gap: 0.65rem;
}
.data-request-body {
width: 100%;
min-height: 280px;
max-height: calc(92vh - 76px);
overflow: auto;
border: 1px solid var(--line);
border-radius: 12px;
background: #0f1722;
padding: 0.8rem;
display: block;
}
.data-request-form {
display: grid;
gap: 0.65rem;
}
.data-request-form-row {
display: grid;
grid-template-columns: 28px minmax(180px, 0.9fr) minmax(0, 1.4fr);
gap: 0.55rem;
align-items: start;
padding: 0.45rem 0;
border-bottom: 1px solid rgba(207, 217, 231, 0.08);
}
.data-request-form-row:last-child {
border-bottom: none;
}
.data-request-form-index {
color: #9fb0c6;
font-weight: 700;
padding-top: 0.8rem;
text-align: center;
}
.data-request-form-label {
color: #e9f1fe;
line-height: 1.4;
padding-top: 0.72rem;
overflow-wrap: anywhere;
}
.data-request-form textarea {
min-height: 92px;
}
.data-request-actions {
display: flex;
justify-content: flex-end;
}
.preview-overlay {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(6, 10, 14, 0.82);
backdrop-filter: blur(4px);
z-index: 60;
}
.preview-overlay.open {
display: flex;
}
.preview-modal {
width: min(980px, 100%);
max-height: 92vh;
border: 1px solid var(--line);
border-radius: 16px;
background: linear-gradient(160deg, rgba(21, 31, 42, 0.96), rgba(14, 21, 28, 0.98));
box-shadow: var(--shadow);
padding: 0.85rem;
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.preview-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.6rem;
}
.preview-head h3 {
margin: 0;
font-family: "Prata", serif;
font-size: 1.15rem;
}
.close-btn {
border: 1px solid var(--line);
width: 34px;
height: 34px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.04);
color: #d7e4f5;
cursor: pointer;
font-size: 1.05rem;
}
.preview-body {
width: 100%;
min-height: 280px;
max-height: calc(92vh - 76px);
overflow: auto;
border: 1px solid var(--line);
border-radius: 12px;
background: #0f1722;
display: grid;
place-items: center;
}
.preview-frame {
width: 100%;
height: min(72vh, 760px);
border: none;
}
.preview-image {
max-width: 100%;
max-height: 72vh;
object-fit: contain;
}
.preview-video {
width: min(100%, 860px);
max-height: 72vh;
}
.preview-note {
padding: 0.9rem;
color: var(--muted);
text-align: center;
}
.preview-text {
width: 100%;
min-height: min(60vh, 520px);
margin: 0;
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(10, 16, 24, 0.88);
color: #dbe7f8;
padding: 0.7rem;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font: 500 0.86rem/1.45 "JetBrains Mono", "Fira Code", monospace;
}
.chat-form {
margin-top: 0.7rem; margin-top: 0.7rem;
display: grid;
gap: 0.55rem;
} }
.file-row { @media (max-width: 1120px) {
margin-top: 0.7rem; .client-summary-grid {
display: flex; grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.status {
margin: 0.7rem 0 0;
color: #9bafc8;
font-size: 0.9rem;
min-height: 1.2rem;
}
.status.ok { color: var(--ok); }
.status.error { color: var(--danger); }
@media (max-width: 860px) {
.cabinet-layout {
grid-template-columns: 1fr;
}
.cabinet-meta {
grid-template-columns: 1fr;
}
.request-switcher {
flex-direction: column;
align-items: stretch;
}
.data-request-form-row {
grid-template-columns: 1fr;
gap: 0.35rem;
}
.data-request-form-index,
.data-request-form-label {
padding-top: 0;
text-align: left;
} }
} }
@media (max-width: 520px) { @media (max-width: 760px) {
.wrap { .client-main {
width: calc(100% - 1rem); width: calc(100% - 1rem);
} }
.topbar { .client-request-toolbar {
position: static;
}
.cabinet-card {
padding: 0.85rem;
}
.file-row {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
.client-summary-grid {
grid-template-columns: 1fr;
}
} }

View file

@ -4,150 +4,13 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Страница клиента • Правовой трекер</title> <title>Страница клиента • Правовой трекер</title>
<link rel="stylesheet" href="/admin.css?v=20260227-01">
<link rel="stylesheet" href="/client.css"> <link rel="stylesheet" href="/client.css">
</head> </head>
<body> <body>
<header class="topbar"> <div id="client-root"></div>
<div class="wrap topbar-inner"> <script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script>
<div class="brand">Кабинет клиента</div> <script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
<nav class="nav"> <script src="/client.js?v=20260227-01"></script>
<a href="/">На лендинг</a>
</nav>
</div>
</header>
<main class="wrap">
<section class="client-shell">
<div class="section-head">
<div>
<h1>Работа с заявками</h1>
<p class="subtitle">Выберите заявку, следите за статусом, перепиской, файлами и счетами.</p>
</div>
</div>
<article class="cabinet-card">
<h2>Мои заявки</h2>
<div class="request-switcher">
<div class="field grow">
<label for="client-request-select">Номер заявки</label>
<select id="client-request-select"></select>
</div>
<button class="btn btn-ghost" id="client-refresh" type="button">Обновить</button>
</div>
<p class="status" id="client-page-status"></p>
<div id="cabinet-summary" hidden>
<div class="cabinet-meta">
<div class="meta-row">
<small>Статус</small>
<b id="cabinet-request-status">-</b>
</div>
<div class="meta-row">
<small>Тема</small>
<b id="cabinet-request-topic">-</b>
</div>
<div class="meta-row">
<small>Создана</small>
<b id="cabinet-request-created">-</b>
</div>
<div class="meta-row">
<small>Обновлена</small>
<b id="cabinet-request-updated">-</b>
</div>
</div>
<div class="service-request-actions">
<button class="btn btn-ghost" id="cabinet-curator-request-open" type="button" disabled>Обратиться к куратору</button>
<button class="btn btn-ghost" id="cabinet-lawyer-change-open" type="button" disabled>Запросить смену юриста</button>
</div>
</div>
</article>
<div class="cabinet-layout">
<article class="cabinet-card">
<h2>Чат с юристом</h2>
<ul class="simple-list" id="cabinet-messages"></ul>
<form class="chat-form" id="cabinet-chat-form">
<textarea id="cabinet-chat-body" placeholder="Введите сообщение" disabled></textarea>
<button class="btn btn-ghost" type="submit" id="cabinet-chat-send" disabled>Отправить сообщение</button>
</form>
</article>
<article class="cabinet-card">
<h2>Файлы по заявке</h2>
<ul class="simple-list" id="cabinet-files"></ul>
<div class="file-row">
<input id="cabinet-file-input" type="file" disabled>
<button class="btn btn-ghost" id="cabinet-file-upload" type="button" disabled>Загрузить файл</button>
</div>
</article>
<article class="cabinet-card">
<h2>Мои обращения</h2>
<ul class="simple-list" id="cabinet-service-requests"></ul>
</article>
<article class="cabinet-card">
<h2>Счета и оплата</h2>
<ul class="simple-list" id="cabinet-invoices"></ul>
</article>
<article class="cabinet-card">
<h2>История изменений</h2>
<ul class="simple-list" id="cabinet-timeline"></ul>
</article>
</div>
</section>
</main>
<div class="preview-overlay" id="file-preview-overlay" aria-hidden="true">
<div class="preview-modal" role="dialog" aria-modal="true" aria-labelledby="file-preview-title">
<div class="preview-head">
<h3 id="file-preview-title">Предпросмотр файла</h3>
<button class="close-btn" id="file-preview-close" type="button" aria-label="Закрыть">×</button>
</div>
<div class="preview-body" id="file-preview-body"></div>
</div>
</div>
<div class="preview-overlay" id="data-request-overlay" aria-hidden="true">
<div class="preview-modal data-request-modal" role="dialog" aria-modal="true" aria-labelledby="data-request-title">
<div class="preview-head">
<h3 id="data-request-title">Запрос данных</h3>
<button class="close-btn" id="data-request-close" type="button" aria-label="Закрыть">×</button>
</div>
<div class="preview-body data-request-body">
<form id="data-request-form" class="data-request-form">
<div id="data-request-items"></div>
<div class="data-request-actions">
<button class="btn btn-ghost" id="data-request-save" type="submit">Сохранить</button>
</div>
</form>
<p class="status" id="data-request-status"></p>
</div>
</div>
</div>
<div class="preview-overlay" id="service-request-overlay" aria-hidden="true">
<div class="preview-modal service-request-modal" role="dialog" aria-modal="true" aria-labelledby="service-request-title">
<div class="preview-head">
<h3 id="service-request-title">Новое обращение</h3>
<button class="close-btn" id="service-request-close" type="button" aria-label="Закрыть">×</button>
</div>
<div class="preview-body service-request-body">
<form id="service-request-form" class="service-request-form">
<input id="service-request-type" type="hidden" value="">
<div class="field">
<label for="service-request-body">Сообщение</label>
<textarea id="service-request-body" maxlength="4000" placeholder="Опишите обращение"></textarea>
</div>
<div class="data-request-actions">
<button class="btn btn-ghost" id="service-request-send" type="submit">Отправить</button>
</div>
</form>
<p class="status" id="service-request-status"></p>
</div>
</div>
</div>
<script src="/client.js"></script>
</body> </body>
</html> </html>

842
app/web/client.jsx Normal file
View file

@ -0,0 +1,842 @@
import { RequestWorkspace } from "./admin/features/requests/RequestWorkspace.jsx";
import { createRequestModalState } from "./admin/shared/state.js";
import { detectAttachmentPreviewKind, fmtShortDateTime } from "./admin/shared/utils.js";
(function () {
const { useCallback, useEffect, useMemo, useRef, useState } = React;
const SERVICE_REQUEST_TYPE_LABELS = {
CURATOR_CONTACT: "Запрос к куратору",
LAWYER_CHANGE_REQUEST: "Смена юриста",
};
const SERVICE_REQUEST_STATUS_LABELS = {
NEW: "Новый",
IN_PROGRESS: "В работе",
RESOLVED: "Решен",
REJECTED: "Отклонен",
};
function StatusLine({ status }) {
return <p className={"status" + (status?.kind ? " " + status.kind : "")}>{status?.message || ""}</p>;
}
function Overlay({ open, id, onClose, children }) {
return (
<div className={"overlay" + (open ? " open" : "")} id={id} onClick={onClose}>
{children}
</div>
);
}
function GlobalTooltipLayer() {
const [tooltip, setTooltip] = useState({ open: false, text: "", x: 0, y: 0, maxWidth: 320 });
const activeRef = useRef(null);
useEffect(() => {
const getTarget = (node) => {
if (!(node instanceof Element)) return null;
const el = node.closest("[data-tooltip]");
if (!el) return null;
const text = String(el.getAttribute("data-tooltip") || "").trim();
return text ? el : null;
};
const reposition = (el) => {
if (!(el instanceof Element)) return;
const text = String(el.getAttribute("data-tooltip") || "").trim();
if (!text) return;
const rect = el.getBoundingClientRect();
const vw = window.innerWidth || 0;
const maxWidth = Math.min(360, Math.max(140, vw - 24));
const approxWidth = Math.min(maxWidth, Math.max(80, text.length * 7.1 + 22));
const centerX = rect.left + rect.width / 2;
const x = Math.max(12 + approxWidth / 2, Math.min(vw - 12 - approxWidth / 2, centerX));
const y = Math.max(8, rect.top - 8);
setTooltip({ open: true, text, x, y, maxWidth });
};
const open = (node) => {
const target = getTarget(node);
if (!target) return;
activeRef.current = target;
reposition(target);
};
const closeIfNeeded = (related) => {
const current = activeRef.current;
if (!current) return;
if (related instanceof Element) {
if (related === current || current.contains(related)) return;
const nextTarget = getTarget(related);
if (nextTarget === current) return;
}
activeRef.current = null;
setTooltip((prev) => ({ ...prev, open: false }));
};
const onMouseOver = (event) => open(event.target);
const onFocusIn = (event) => open(event.target);
const onMouseOut = (event) => closeIfNeeded(event.relatedTarget);
const onFocusOut = (event) => closeIfNeeded(event.relatedTarget);
const onUpdatePosition = () => {
if (activeRef.current) reposition(activeRef.current);
};
document.addEventListener("mouseover", onMouseOver, true);
document.addEventListener("focusin", onFocusIn, true);
document.addEventListener("mouseout", onMouseOut, true);
document.addEventListener("focusout", onFocusOut, true);
window.addEventListener("scroll", onUpdatePosition, true);
window.addEventListener("resize", onUpdatePosition);
return () => {
document.removeEventListener("mouseover", onMouseOver, true);
document.removeEventListener("focusin", onFocusIn, true);
document.removeEventListener("mouseout", onMouseOut, true);
document.removeEventListener("focusout", onFocusOut, true);
window.removeEventListener("scroll", onUpdatePosition, true);
window.removeEventListener("resize", onUpdatePosition);
};
}, []);
return (
<div
className={"global-tooltip-layer" + (tooltip.open ? " open" : "")}
style={{ left: tooltip.x + "px", top: tooltip.y + "px", maxWidth: tooltip.maxWidth + "px" }}
role="tooltip"
aria-hidden={tooltip.open ? "false" : "true"}
>
{tooltip.text}
</div>
);
}
function AttachmentPreviewModal({ open, title, url, fileName, mimeType, onClose }) {
const [resolvedUrl, setResolvedUrl] = useState("");
const [resolvedText, setResolvedText] = useState("");
const [resolvedKind, setResolvedKind] = useState("");
const [hint, setHint] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const decodeTextPreview = (arrayBuffer) => {
const bytes = new Uint8Array(arrayBuffer || new ArrayBuffer(0));
const sampleLength = Math.min(bytes.length, 4096);
let suspicious = 0;
for (let i = 0; i < sampleLength; i += 1) {
const byte = bytes[i];
if (byte === 0) suspicious += 4;
else if (byte < 9 || (byte > 13 && byte < 32)) suspicious += 1;
}
if (sampleLength && suspicious / sampleLength > 0.08) return null;
const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes).replace(/\u0000/g, "");
return text.length > 200000 ? text.slice(0, 200000) + "\n\n[Текст обрезан для предпросмотра]" : text;
};
useEffect(() => {
if (!open || !url) {
setResolvedUrl("");
setResolvedText("");
setResolvedKind("");
setHint("");
setLoading(false);
setError("");
return;
}
const kind = detectAttachmentPreviewKind(fileName, mimeType);
setResolvedKind(kind);
setResolvedText("");
setHint("");
if (kind === "none") {
setResolvedUrl("");
setLoading(false);
setError("");
return;
}
let cancelled = false;
let objectUrl = "";
setLoading(true);
setError("");
setResolvedUrl("");
(async () => {
try {
const response = await fetch(url, { credentials: "same-origin" });
if (!response.ok) throw new Error("Не удалось загрузить файл для предпросмотра");
const buffer = await response.arrayBuffer();
if (cancelled) return;
if (kind === "pdf") {
const header = new Uint8Array(buffer.slice(0, 5));
const isPdf =
header.length >= 5 &&
header[0] === 0x25 &&
header[1] === 0x50 &&
header[2] === 0x44 &&
header[3] === 0x46 &&
header[4] === 0x2d;
if (isPdf) {
setResolvedUrl(String(url));
setResolvedKind("pdf");
setLoading(false);
return;
}
const textPreview = decodeTextPreview(buffer);
if (textPreview != null) {
setResolvedUrl("");
setResolvedText(textPreview);
setResolvedKind("text");
setHint("Файл помечен как PDF, но не является валидным PDF. Показан текстовый предпросмотр.");
setLoading(false);
return;
}
throw new Error("Файл помечен как PDF, но не является валидным PDF-документом.");
}
if (kind === "text") {
const textPreview = decodeTextPreview(buffer);
if (textPreview == null) throw new Error("Не удалось распознать текстовый файл для предпросмотра.");
setResolvedUrl("");
setResolvedText(textPreview);
setResolvedKind("text");
setLoading(false);
return;
}
const blob = new Blob([buffer], { type: response.headers.get("content-type") || mimeType || "application/octet-stream" });
objectUrl = URL.createObjectURL(blob);
if (cancelled) {
URL.revokeObjectURL(objectUrl);
return;
}
setResolvedUrl(objectUrl);
setResolvedKind(kind);
setLoading(false);
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : "Не удалось открыть предпросмотр");
setLoading(false);
}
})();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [fileName, mimeType, open, url]);
if (!open || !url) return null;
const kind = resolvedKind || detectAttachmentPreviewKind(fileName, mimeType);
return (
<Overlay open={open} id="file-preview-overlay" onClose={(event) => event.target.id === "file-preview-overlay" && onClose()}>
<div className="modal request-preview-modal" onClick={(event) => event.stopPropagation()}>
<div className="modal-head">
<h3>{title || fileName || "Предпросмотр файла"}</h3>
<div className="request-preview-head-actions">
<a className="icon-btn file-action-btn request-preview-download-icon" href={url} target="_blank" rel="noreferrer" aria-label="Скачать файл" data-tooltip="Скачать">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
<path
d="M12 3a1 1 0 0 1 1 1v8.17l2.58-2.58a1 1 0 1 1 1.42 1.42l-4.3 4.3a1 1 0 0 1-1.4 0l-4.3-4.3a1 1 0 0 1 1.42-1.42L11 12.17V4a1 1 0 0 1 1-1zm-7 14a1 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>
</a>
<button className="close" type="button" id="file-preview-close" onClick={onClose}>
×
</button>
</div>
</div>
<div className="request-preview-body" id="file-preview-body">
{loading ? <p className="request-preview-note">Загрузка предпросмотра...</p> : null}
{!loading && !error && hint ? <p className="request-preview-note">{hint}</p> : null}
{error ? <p className="request-preview-note">{error}</p> : null}
{!loading && !error && kind === "image" && resolvedUrl ? (
<img className="request-preview-image" src={resolvedUrl} alt={fileName || "attachment"} />
) : null}
{!loading && !error && kind === "video" && resolvedUrl ? (
<video className="request-preview-video" src={resolvedUrl} controls preload="metadata" />
) : null}
{!loading && !error && kind === "pdf" && resolvedUrl ? (
<iframe className="request-preview-frame" src={resolvedUrl} title={fileName || "preview"} />
) : null}
{!loading && !error && kind === "text" ? (
<pre className="request-preview-text">{resolvedText || "Файл пуст."}</pre>
) : null}
{kind === "none" ? <p className="request-preview-note">Для этого типа файла доступно только открытие или скачивание.</p> : null}
</div>
</div>
</Overlay>
);
}
function ServiceRequestModal({ open, type, body, status, loading, onBodyChange, onClose, onSubmit }) {
const title = type === "LAWYER_CHANGE_REQUEST" ? "Запрос на смену юриста" : "Обращение к куратору";
return (
<div className={"overlay" + (open ? " open" : "")} id="service-request-overlay" onClick={(event) => event.target.id === "service-request-overlay" && onClose()}>
<div className="modal service-request-modal" onClick={(event) => event.stopPropagation()}>
<div className="modal-head">
<div>
<h3 id="service-request-title">{title}</h3>
</div>
<button className="close" type="button" id="service-request-close" onClick={onClose} aria-label="Закрыть">
×
</button>
</div>
<form id="service-request-form" className="stack service-request-form" onSubmit={onSubmit}>
<div className="field">
<label htmlFor="service-request-body">Сообщение</label>
<textarea
id="service-request-body"
value={body}
onChange={onBodyChange}
maxLength={4000}
placeholder="Опишите обращение"
disabled={loading}
/>
</div>
<div className="modal-actions modal-actions-right">
<button className="btn btn-sm" id="service-request-send" type="submit" disabled={loading}>
{loading ? "Отправка..." : "Отправить"}
</button>
</div>
<StatusLine status={status} />
</form>
</div>
</div>
);
}
function ServiceRequestList({ rows }) {
const safeRows = Array.isArray(rows) ? rows : [];
return (
<ul className="simple-list request-modal-list" id="cabinet-service-requests">
{safeRows.length ? (
safeRows.map((item) => {
const typeCode = String(item?.type || "").toUpperCase();
const statusCode = String(item?.status || "").toUpperCase();
return (
<li key={String(item.id)} className="simple-item">
<div>{(SERVICE_REQUEST_TYPE_LABELS[typeCode] || typeCode || "Запрос") + " • " + (SERVICE_REQUEST_STATUS_LABELS[statusCode] || statusCode || "NEW")}</div>
<div className="muted request-modal-item-meta">{fmtShortDateTime(item?.created_at)}</div>
{item?.body ? <p>{String(item.body)}</p> : null}
</li>
);
})
) : (
<li className="muted">Обращений пока нет</li>
)}
</ul>
);
}
function App() {
const [requestModal, setRequestModal] = useState(createRequestModalState());
const [requestsList, setRequestsList] = useState([]);
const [activeTrack, setActiveTrack] = useState("");
const [status, setStatus] = useState({ message: "", kind: "" });
const [serviceRequests, setServiceRequests] = useState([]);
const [serviceRequestModal, setServiceRequestModal] = useState({ open: false, type: "", body: "", loading: false, status: { message: "", kind: "" } });
const setPageStatus = useCallback((message, kind) => {
setStatus({ message: String(message || ""), kind: kind || "" });
}, []);
const setServiceStatus = useCallback((message, kind) => {
setServiceRequestModal((prev) => ({ ...prev, status: { message: String(message || ""), kind: kind || "" } }));
}, []);
const apiError = (data, fallback) => {
if (data && typeof data.detail === "string" && data.detail.trim()) return data.detail;
return fallback;
};
const parseJsonSafe = async (response) => {
try {
return await response.json();
} catch (_) {
return null;
}
};
const apiJson = useCallback(async (url, options, fallbackMessage) => {
const response = await fetch(url, options || undefined);
const data = await parseJsonSafe(response);
if (response.status === 401 || response.status === 403) {
window.location.href = "/";
throw new Error("Нет доступа");
}
if (!response.ok) throw new Error(apiError(data, fallbackMessage || "Ошибка запроса"));
return data;
}, []);
const uploadPublicRequestAttachment = useCallback(async (file, extra = {}) => {
const requestId = String(requestModal.requestId || "").trim();
if (!requestId) throw new Error("Не выбрана заявка");
const mimeType = String(file?.type || "application/octet-stream");
const initData = await apiJson(
"/api/public/uploads/init",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: requestId,
}),
},
"Не удалось начать загрузку файла"
);
const putResponse = await fetch(initData.presigned_url, {
method: "PUT",
headers: { "Content-Type": mimeType },
body: file,
});
if (!putResponse.ok) throw new Error("Ошибка передачи файла в хранилище");
const completeData = await apiJson(
"/api/public/uploads/complete",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: initData.key,
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: requestId,
message_id: extra?.message_id || null,
}),
},
"Не удалось завершить загрузку файла"
);
return completeData;
}, [apiJson, requestModal.requestId]);
const loadRequestWorkspace = useCallback(
async (trackNumber, showLoading) => {
const track = String(trackNumber || "").trim().toUpperCase();
if (!track) return;
if (showLoading) {
setRequestModal((prev) => ({ ...prev, loading: true }));
}
const [requestData, messagesData, attachmentsData, invoicesData, statusRouteData, serviceRequestsData] = await Promise.all([
apiJson("/api/public/requests/" + encodeURIComponent(track), null, "Не удалось открыть заявку"),
apiJson("/api/public/chat/requests/" + encodeURIComponent(track) + "/messages", null, "Не удалось загрузить сообщения"),
apiJson("/api/public/requests/" + encodeURIComponent(track) + "/attachments", null, "Не удалось загрузить файлы"),
apiJson("/api/public/requests/" + encodeURIComponent(track) + "/invoices", null, "Не удалось загрузить счета"),
apiJson("/api/public/requests/" + encodeURIComponent(track) + "/status-route", null, "Не удалось загрузить маршрут статусов"),
apiJson("/api/public/requests/" + encodeURIComponent(track) + "/service-requests", null, "Не удалось загрузить обращения"),
]);
const invoices = Array.isArray(invoicesData) ? invoicesData : [];
const paidInvoices = invoices.filter((item) => String(item?.status || "").toUpperCase() === "PAID");
const paidTotal = paidInvoices.reduce((acc, item) => {
const amount = Number(item?.amount || 0);
return Number.isFinite(amount) ? acc + amount : acc;
}, 0);
const lastPaidAt = paidInvoices.reduce((latest, item) => {
const raw = String(item?.paid_at || "").trim();
if (!raw) return latest;
if (!latest) return raw;
const currentTs = new Date(raw).getTime();
const latestTs = new Date(latest).getTime();
return Number.isFinite(currentTs) && currentTs > latestTs ? raw : latest;
}, "");
setActiveTrack(track);
setServiceRequests(Array.isArray(serviceRequestsData) ? serviceRequestsData : []);
setRequestModal((prev) => ({
...prev,
loading: false,
requestId: String(requestData?.id || ""),
trackNumber: String(requestData?.track_number || track),
requestData: requestData || null,
financeSummary: {
request_cost: requestData?.request_cost ?? null,
effective_rate: requestData?.effective_rate ?? null,
paid_total: Math.round((paidTotal + Number.EPSILON) * 100) / 100,
last_paid_at: lastPaidAt || requestData?.paid_at || null,
},
statusRouteNodes: Array.isArray(statusRouteData?.nodes) ? statusRouteData.nodes : [],
statusHistory: Array.isArray(statusRouteData?.history) ? statusRouteData.history : [],
availableStatuses: [],
currentImportantDateAt: String(statusRouteData?.current_important_date_at || requestData?.important_date_at || ""),
messages: Array.isArray(messagesData) ? messagesData : [],
attachments: Array.isArray(attachmentsData) ? attachmentsData : [],
fileUploading: false,
}));
},
[apiJson]
);
const loadMyRequests = useCallback(
async (preferredTrack) => {
const data = await apiJson("/api/public/requests/my", null, "Не удалось загрузить список заявок");
const rows = Array.isArray(data?.rows) ? data.rows : [];
setRequestsList(rows);
if (!rows.length) {
setRequestModal((prev) => ({
...prev,
loading: false,
requestId: null,
requestData: null,
trackNumber: "",
financeSummary: null,
statusRouteNodes: [],
statusHistory: [],
messages: [],
attachments: [],
fileUploading: false,
selectedFiles: [],
messageDraft: "",
}));
setServiceRequests([]);
setPageStatus("По вашему номеру пока нет заявок.", "");
return;
}
const tracks = rows.map((row) => String(row.track_number || "").trim()).filter(Boolean);
const selected = tracks.includes(String(preferredTrack || "").trim().toUpperCase())
? String(preferredTrack || "").trim().toUpperCase()
: tracks[0];
await loadRequestWorkspace(selected, true);
setPageStatus("Открыта заявка: " + selected, "ok");
},
[apiJson, loadRequestWorkspace, setPageStatus]
);
const updateMessageDraft = useCallback((event) => {
const value = event?.target?.value || "";
setRequestModal((prev) => ({ ...prev, messageDraft: value }));
}, []);
const appendFiles = useCallback((files) => {
const list = Array.isArray(files) ? files.filter(Boolean) : [];
if (!list.length) return;
setRequestModal((prev) => {
const existing = Array.isArray(prev.selectedFiles) ? prev.selectedFiles : [];
const next = [...existing];
list.forEach((file) => {
const duplicate = next.some(
(item) =>
item &&
item.name === file.name &&
Number(item.size || 0) === Number(file.size || 0) &&
Number(item.lastModified || 0) === Number(file.lastModified || 0)
);
if (!duplicate) next.push(file);
});
return { ...prev, selectedFiles: next };
});
}, []);
const removeFile = useCallback((index) => {
setRequestModal((prev) => {
const files = Array.isArray(prev.selectedFiles) ? [...prev.selectedFiles] : [];
files.splice(index, 1);
return { ...prev, selectedFiles: files };
});
}, []);
const clearFiles = useCallback(() => {
setRequestModal((prev) => ({ ...prev, selectedFiles: [] }));
}, []);
const submitMessage = useCallback(
async (event) => {
if (event && typeof event.preventDefault === "function") event.preventDefault();
const track = String(activeTrack || "").trim();
const requestId = String(requestModal.requestId || "").trim();
if (!track || !requestId) {
setPageStatus("Сначала выберите заявку.", "error");
return;
}
const body = String(requestModal.messageDraft || "").trim();
const files = Array.isArray(requestModal.selectedFiles) ? requestModal.selectedFiles : [];
if (!body && !files.length) return;
try {
setRequestModal((prev) => ({ ...prev, fileUploading: true }));
setPageStatus(files.length ? "Отправка сообщения и файлов..." : "Отправка сообщения...", "");
let messageId = null;
if (body) {
const messageData = await apiJson(
"/api/public/chat/requests/" + encodeURIComponent(track) + "/messages",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body }),
},
"Не удалось отправить сообщение"
);
messageId = String(messageData?.id || "").trim() || null;
}
for (const file of files) {
await uploadPublicRequestAttachment(file, { message_id: messageId });
}
setRequestModal((prev) => ({ ...prev, messageDraft: "", selectedFiles: [], fileUploading: false }));
await loadRequestWorkspace(track, false);
if (body && files.length) setPageStatus("Сообщение и файлы отправлены.", "ok");
else if (files.length) setPageStatus(files.length === 1 ? "Файл загружен." : "Файлы загружены.", "ok");
else setPageStatus("Сообщение отправлено.", "ok");
} catch (error) {
setRequestModal((prev) => ({ ...prev, fileUploading: false }));
setPageStatus(error?.message || "Ошибка отправки сообщения", "error");
}
},
[activeTrack, apiJson, loadRequestWorkspace, requestModal.messageDraft, requestModal.requestId, requestModal.selectedFiles, setPageStatus, uploadPublicRequestAttachment]
);
const loadRequestDataBatch = useCallback(
async (messageId) => {
const track = String(activeTrack || "").trim();
if (!track || !messageId) throw new Error("Не выбрана заявка");
return apiJson(
"/api/public/chat/requests/" + encodeURIComponent(track) + "/data-requests/" + encodeURIComponent(String(messageId)),
null,
"Не удалось открыть запрос данных"
);
},
[activeTrack, apiJson]
);
const saveRequestDataValues = useCallback(
async ({ message_id, items }) => {
const track = String(activeTrack || "").trim();
const messageId = String(message_id || "").trim();
if (!track || !messageId) throw new Error("Не выбрана заявка");
await apiJson(
"/api/public/chat/requests/" + encodeURIComponent(track) + "/data-requests/" + encodeURIComponent(messageId),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: Array.isArray(items) ? items : [] }),
},
"Не удалось сохранить данные"
);
await loadRequestWorkspace(track, false);
},
[activeTrack, apiJson, loadRequestWorkspace]
);
const openServiceRequestModal = useCallback((type) => {
const normalized = String(type || "").trim().toUpperCase();
if (!normalized) return;
setServiceRequestModal({
open: true,
type: normalized,
body: "",
loading: false,
status: { message: "", kind: "" },
});
}, []);
const closeServiceRequestModal = useCallback(() => {
setServiceRequestModal({ open: false, type: "", body: "", loading: false, status: { message: "", kind: "" } });
}, []);
const submitServiceRequest = useCallback(
async (event) => {
if (event && typeof event.preventDefault === "function") event.preventDefault();
const track = String(activeTrack || "").trim();
if (!track) {
setServiceStatus("Сначала выберите заявку.", "error");
return;
}
const requestType = String(serviceRequestModal.type || "").trim().toUpperCase();
const body = String(serviceRequestModal.body || "").trim();
if (!requestType) {
setServiceStatus("Выберите тип обращения.", "error");
return;
}
if (body.length < 3) {
setServiceStatus("Сообщение должно содержать минимум 3 символа.", "error");
return;
}
try {
setServiceRequestModal((prev) => ({ ...prev, loading: true, status: { message: "", kind: "" } }));
await apiJson(
"/api/public/requests/" + encodeURIComponent(track) + "/service-requests",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: requestType, body }),
},
"Не удалось отправить обращение"
);
await loadRequestWorkspace(track, false);
setPageStatus("Обращение отправлено.", "ok");
closeServiceRequestModal();
} catch (error) {
setServiceRequestModal((prev) => ({ ...prev, loading: false }));
setServiceStatus(error?.message || "Не удалось отправить обращение", "error");
}
},
[activeTrack, apiJson, closeServiceRequestModal, loadRequestWorkspace, serviceRequestModal.body, serviceRequestModal.type, setPageStatus, setServiceStatus]
);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const preferredTrack = String(params.get("track") || "").trim().toUpperCase();
void loadMyRequests(preferredTrack).catch((error) => {
setPageStatus(error?.message || "Не удалось открыть страницу клиента", "error");
});
}, [loadMyRequests, setPageStatus]);
const summary = requestModal.requestData || null;
const canInteract = Boolean(summary && !requestModal.loading);
return (
<div className="client-page-shell">
<main className="main client-main">
<div className="topbar client-topbar">
<div>
<h1>Кабинет клиента</h1>
<p className="muted">Работа с заявками: статусы, чат, файлы и обращения.</p>
</div>
<div style={{ display: "flex", gap: "0.45rem", flexWrap: "wrap" }}>
<a className="btn secondary btn-sm" href="/">На лендинг</a>
</div>
</div>
<section className="section active client-section">
<div className="section-head">
<div>
<h2>Мои заявки</h2>
</div>
</div>
<div className="client-request-toolbar">
<div className="field grow">
<label htmlFor="client-request-select">Номер заявки</label>
<select
id="client-request-select"
value={activeTrack}
onChange={(event) => {
const track = String(event.target.value || "").trim();
if (!track) return;
void loadRequestWorkspace(track, true)
.then(() => setPageStatus("Открыта заявка: " + track, "ok"))
.catch((error) => setPageStatus(error?.message || "Не удалось открыть заявку", "error"));
}}
disabled={requestModal.loading || !requestsList.length}
>
{requestsList.map((row) => (
<option value={String(row.track_number || "")} key={String(row.id || row.track_number || "")}>
{String(row.track_number || "Без номера") + " • " + String(row.status_code || "-")}
</option>
))}
</select>
</div>
<button
className="btn secondary"
id="client-refresh"
type="button"
onClick={() => {
void loadMyRequests(activeTrack).catch((error) => setPageStatus(error?.message || "Не удалось обновить список", "error"));
}}
>
Обновить
</button>
</div>
<div className="client-summary block" id="cabinet-summary" hidden={!summary}>
<div className="client-summary-grid">
<div className="request-field">
<span className="request-field-label">Статус</span>
<span className="request-field-value" id="cabinet-request-status">{summary ? String(summary.status_code || "-") : "-"}</span>
</div>
<div className="request-field">
<span className="request-field-label">Тема</span>
<span className="request-field-value" id="cabinet-request-topic">{summary ? String(summary.topic_name || summary.topic_code || "-") : "-"}</span>
</div>
<div className="request-field">
<span className="request-field-label">Создана</span>
<span className="request-field-value" id="cabinet-request-created">{summary ? fmtShortDateTime(summary.created_at) : "-"}</span>
</div>
<div className="request-field">
<span className="request-field-label">Обновлена</span>
<span className="request-field-value" id="cabinet-request-updated">{summary ? fmtShortDateTime(summary.updated_at) : "-"}</span>
</div>
</div>
<div className="client-summary-actions">
<button className="btn secondary btn-sm" id="cabinet-curator-request-open" type="button" disabled={!canInteract} onClick={() => openServiceRequestModal("CURATOR_CONTACT")}>
Обратиться к куратору
</button>
<button className="btn secondary btn-sm" id="cabinet-lawyer-change-open" type="button" disabled={!canInteract} onClick={() => openServiceRequestModal("LAWYER_CHANGE_REQUEST")}>
Запросить смену юриста
</button>
</div>
</div>
<RequestWorkspace
viewerRole="CLIENT"
viewerUserId=""
loading={requestModal.loading}
trackNumber={requestModal.trackNumber}
requestData={requestModal.requestData}
financeSummary={requestModal.financeSummary}
statusRouteNodes={requestModal.statusRouteNodes || []}
statusHistory={requestModal.statusHistory || []}
availableStatuses={[]}
currentImportantDateAt={requestModal.currentImportantDateAt || ""}
pendingStatusChangePreset={null}
messages={requestModal.messages || []}
attachments={requestModal.attachments || []}
messageDraft={requestModal.messageDraft || ""}
selectedFiles={requestModal.selectedFiles || []}
fileUploading={Boolean(requestModal.fileUploading)}
status={status}
onMessageChange={updateMessageDraft}
onSendMessage={submitMessage}
onFilesSelect={appendFiles}
onRemoveSelectedFile={removeFile}
onClearSelectedFiles={clearFiles}
onLoadRequestDataBatch={loadRequestDataBatch}
onSaveRequestDataValues={saveRequestDataValues}
onUploadRequestAttachment={uploadPublicRequestAttachment}
onChangeStatus={() => Promise.resolve(null)}
AttachmentPreviewModalComponent={AttachmentPreviewModal}
StatusLineComponent={StatusLine}
domIds={{
messagesList: "cabinet-messages",
filesList: "cabinet-files",
messageBody: "cabinet-chat-body",
sendButton: "cabinet-chat-send",
fileInput: "cabinet-file-input",
fileUploadButton: "cabinet-file-upload",
dataRequestOverlay: "data-request-overlay",
dataRequestItems: "data-request-items",
dataRequestStatus: "data-request-status",
dataRequestSave: "data-request-save",
}}
/>
<div className="block client-service-requests">
<h3>Мои обращения</h3>
<ServiceRequestList rows={serviceRequests} />
</div>
</section>
<p className="status" id="client-page-status">{status.message}</p>
</main>
<ServiceRequestModal
open={serviceRequestModal.open}
type={serviceRequestModal.type}
body={serviceRequestModal.body}
status={serviceRequestModal.status}
loading={serviceRequestModal.loading}
onBodyChange={(event) => setServiceRequestModal((prev) => ({ ...prev, body: event.target.value }))}
onClose={closeServiceRequestModal}
onSubmit={submitServiceRequest}
/>
<GlobalTooltipLayer />
</div>
);
}
const root = document.getElementById("client-root");
if (root) {
ReactDOM.createRoot(root).render(<App />);
}
})();

1
app/web/client/index.jsx Normal file
View file

@ -0,0 +1 @@
import "../client.jsx";

View file

@ -236,6 +236,29 @@
</div> </div>
</div> </div>
<div class="modal-backdrop" id="otp-modal" aria-hidden="true">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="otp-title">
<div class="modal-head">
<div>
<h3 id="otp-title">Подтверждение телефона</h3>
<p id="otp-modal-hint">Введите OTP-код из SMS.</p>
</div>
<button class="close" type="button" data-close-otp aria-label="Закрыть">×</button>
</div>
<form id="otp-modal-form" class="form">
<div class="field full">
<label for="otp-modal-code">OTP-код</label>
<input id="otp-modal-code" name="otp-modal-code" type="text" inputmode="numeric" pattern="[0-9]{4,8}" placeholder="Введите код из SMS">
</div>
<div class="form-foot field full">
<button class="btn btn-ghost" type="button" id="otp-modal-cancel">Отмена</button>
<button class="btn btn-primary" type="submit" id="otp-modal-submit">Подтвердить</button>
<p class="status" id="otp-modal-status"></p>
</div>
</form>
</div>
</div>
<script src="/landing.js"></script> <script src="/landing.js"></script>
</body> </body>
</html> </html>

View file

@ -5,6 +5,8 @@
const requestCloseButtons = document.querySelectorAll("[data-close-modal]"); const requestCloseButtons = document.querySelectorAll("[data-close-modal]");
const accessOpenButtons = document.querySelectorAll("[data-open-access]"); const accessOpenButtons = document.querySelectorAll("[data-open-access]");
const accessCloseButtons = document.querySelectorAll("[data-close-access]"); const accessCloseButtons = document.querySelectorAll("[data-close-access]");
const otpModal = document.getElementById("otp-modal");
const otpCloseButtons = document.querySelectorAll("[data-close-otp]");
const requestForm = document.getElementById("request-form"); const requestForm = document.getElementById("request-form");
const requestStatus = document.getElementById("form-status"); const requestStatus = document.getElementById("form-status");
@ -15,6 +17,11 @@
const accessCodeInput = document.getElementById("access-code"); const accessCodeInput = document.getElementById("access-code");
const accessSendOtpButton = document.getElementById("access-send-otp"); const accessSendOtpButton = document.getElementById("access-send-otp");
const accessStatus = document.getElementById("access-status"); const accessStatus = document.getElementById("access-status");
const otpModalForm = document.getElementById("otp-modal-form");
const otpModalCodeInput = document.getElementById("otp-modal-code");
const otpModalCancelButton = document.getElementById("otp-modal-cancel");
const otpModalStatus = document.getElementById("otp-modal-status");
const otpModalHint = document.getElementById("otp-modal-hint");
const quoteText = document.getElementById("quote-text"); const quoteText = document.getElementById("quote-text");
const quoteMeta = document.getElementById("quote-meta"); const quoteMeta = document.getElementById("quote-meta");
@ -24,6 +31,7 @@
const featuredTeamDots = document.getElementById("featured-team-dots"); const featuredTeamDots = document.getElementById("featured-team-dots");
const featuredTeamPrev = document.getElementById("featured-team-prev"); const featuredTeamPrev = document.getElementById("featured-team-prev");
const featuredTeamNext = document.getElementById("featured-team-next"); const featuredTeamNext = document.getElementById("featured-team-next");
let otpModalResolver = null;
function setStatus(el, message, kind) { function setStatus(el, message, kind) {
if (!el) return; if (!el) return;
@ -85,6 +93,16 @@
accessCloseButtons.forEach((button) => { accessCloseButtons.forEach((button) => {
button.addEventListener("click", () => closeModal(accessModal)); button.addEventListener("click", () => closeModal(accessModal));
}); });
otpCloseButtons.forEach((button) => {
button.addEventListener("click", () => {
if (otpModalResolver) {
const resolve = otpModalResolver;
otpModalResolver = null;
resolve("");
}
closeModal(otpModal);
});
});
[requestModal, accessModal].forEach((modal) => { [requestModal, accessModal].forEach((modal) => {
if (!modal) return; if (!modal) return;
@ -92,9 +110,68 @@
if (event.target === modal) closeModal(modal); if (event.target === modal) closeModal(modal);
}); });
}); });
if (otpModal) {
otpModal.addEventListener("click", (event) => {
if (event.target !== otpModal) return;
if (otpModalResolver) {
const resolve = otpModalResolver;
otpModalResolver = null;
resolve("");
}
closeModal(otpModal);
});
}
function requestOtpCode(hintText) {
return new Promise((resolve) => {
otpModalResolver = resolve;
if (otpModalCodeInput) otpModalCodeInput.value = "";
setStatus(otpModalStatus, "", null);
if (otpModalHint) otpModalHint.textContent = hintText || "Введите OTP-код из SMS.";
openModal(otpModal);
setTimeout(() => {
if (otpModalCodeInput && typeof otpModalCodeInput.focus === "function") otpModalCodeInput.focus();
}, 10);
});
}
if (otpModalCancelButton) {
otpModalCancelButton.addEventListener("click", () => {
if (otpModalResolver) {
const resolve = otpModalResolver;
otpModalResolver = null;
resolve("");
}
closeModal(otpModal);
});
}
if (otpModalForm) {
otpModalForm.addEventListener("submit", (event) => {
event.preventDefault();
const code = String(otpModalCodeInput?.value || "").trim();
if (!code) {
setStatus(otpModalStatus, "Введите OTP-код.", "error");
return;
}
setStatus(otpModalStatus, "", null);
if (otpModalResolver) {
const resolve = otpModalResolver;
otpModalResolver = null;
resolve(code);
}
closeModal(otpModal);
});
}
document.addEventListener("keydown", (event) => { document.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return; if (event.key !== "Escape") return;
if (otpModalResolver && otpModal && otpModal.classList.contains("open")) {
const resolve = otpModalResolver;
otpModalResolver = null;
resolve("");
}
closeModal(otpModal);
closeModal(requestModal); closeModal(requestModal);
closeModal(accessModal); closeModal(accessModal);
}); });
@ -367,7 +444,10 @@
const otpSendData = await parseJsonSafe(otpSend); const otpSendData = await parseJsonSafe(otpSend);
if (!otpSend.ok) throw new Error(apiErrorDetail(otpSendData, "Не удалось отправить OTP")); if (!otpSend.ok) throw new Error(apiErrorDetail(otpSendData, "Не удалось отправить OTP"));
const code = window.prompt("Введите OTP-код из SMS (в dev-режиме смотрите backend console):"); const isMocked = Boolean(otpSendData?.sms_response?.mocked) || String(otpSendData?.sms_response?.provider || "") === "mock_sms";
const code = await requestOtpCode(
isMocked ? "Введите OTP-код из SMS (dev-режим: смотрите backend console)." : "Введите OTP-код из SMS."
);
if (!code) throw new Error("Код OTP не введен"); if (!code) throw new Error("Код OTP не введен");
setStatus(requestStatus, "Проверяем OTP...", null); setStatus(requestStatus, "Проверяем OTP...", null);

Binary file not shown.

View file

@ -5,7 +5,7 @@ services:
dockerfile: frontend/Dockerfile dockerfile: frontend/Dockerfile
container_name: law-frontend container_name: law-frontend
depends_on: [backend] depends_on: [backend]
ports: ["8081:80"] ports: ["8081:80", "8080:80"]
e2e: e2e:
build: build:

View file

@ -245,6 +245,20 @@ async function createRequestViaLanding(page, options = {}) {
await page.locator("#description").fill(description); await page.locator("#description").fill(description);
await page.getByRole("button", { name: "Отправить заявку" }).click(); 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("Заявка принята. Номер:"); await expect(page.locator("#form-status")).toContainText("Заявка принята. Номер:");
const statusText = await page.locator("#form-status").innerText(); const statusText = await page.locator("#form-status").innerText();
const match = statusText.match(/TRK-[A-Z0-9-]+/); const match = statusText.match(/TRK-[A-Z0-9-]+/);
@ -298,6 +312,10 @@ async function uploadCabinetFile(page, fileName = "e2e.txt", bodyText = "E2E fil
} }
} }
if (lastError) throw lastError; if (lastError) throw lastError;
const filesTab = page.getByRole("tab", { name: /Файлы/ });
if (await filesTab.count()) {
await filesTab.click();
}
await expect(page.locator("#cabinet-files")).toContainText(fileName); await expect(page.locator("#cabinet-files")).toContainText(fileName);
} }

View file

@ -35,7 +35,7 @@ test("public flow via UI: landing -> create request -> cabinet -> chat -> upload
const uploadedFile = `public-${Date.now()}.pdf`; const uploadedFile = `public-${Date.now()}.pdf`;
await uploadCabinetFile(page, uploadedFile, "public file content"); await uploadCabinetFile(page, uploadedFile, "public file content");
const fileRow = page.locator("#cabinet-files .simple-item").filter({ hasText: uploadedFile }).first(); const fileRow = page.locator("#cabinet-files li").filter({ hasText: uploadedFile }).first();
await fileRow.getByRole("button", { name: "Предпросмотр" }).click(); await fileRow.getByRole("button", { name: "Предпросмотр" }).click();
await page.locator("#file-preview-overlay #file-preview-body").waitFor(); await page.locator("#file-preview-overlay #file-preview-body").waitFor();
await page.locator("#file-preview-close").click(); await page.locator("#file-preview-close").click();

View file

@ -1,13 +1,17 @@
FROM node:22-alpine AS admin-build FROM node:22-alpine AS frontend-build
WORKDIR /build WORKDIR /build
COPY app/web/admin ./admin COPY app/web/admin ./admin
COPY app/web/admin.jsx ./admin.jsx COPY app/web/admin.jsx ./admin.jsx
COPY app/web/client ./client
COPY app/web/client.jsx ./client.jsx
RUN npm init -y >/dev/null 2>&1 \ RUN npm init -y >/dev/null 2>&1 \
&& npm install --silent esbuild@0.25.10 \ && npm install --silent esbuild@0.25.10 \
&& npx esbuild admin/index.jsx --bundle --loader:.jsx=jsx --format=iife --target=es2018 --outfile=admin.js && npx esbuild admin/index.jsx --bundle --loader:.jsx=jsx --format=iife --target=es2018 --outfile=admin.js \
&& npx esbuild client/index.jsx --bundle --loader:.jsx=jsx --format=iife --target=es2018 --outfile=client.js
FROM nginx:1.27-alpine FROM nginx:1.27-alpine
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
COPY app/web/ /usr/share/nginx/html/ COPY app/web/ /usr/share/nginx/html/
COPY --from=admin-build /build/admin.js /usr/share/nginx/html/admin.js COPY --from=frontend-build /build/admin.js /usr/share/nginx/html/admin.js
COPY --from=frontend-build /build/client.js /usr/share/nginx/html/client.js
RUN cp /usr/share/nginx/html/landing.html /usr/share/nginx/html/index.html RUN cp /usr/share/nginx/html/landing.html /usr/share/nginx/html/index.html

View file

@ -20,6 +20,19 @@ server {
return 302 /admin.html; return 302 /admin.html;
} }
location = /admin-panel.html {
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "credentialless" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self' https://unpkg.com; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
expires 10m;
return 302 /admin.html;
}
location ~* \.jsx$ { location ~* \.jsx$ {
add_header X-Frame-Options "DENY" always; add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;

View file

@ -42,6 +42,15 @@ class _FakeS3Storage:
def __init__(self): def __init__(self):
self.objects = {} self.objects = {}
def create_presigned_put_url(self, key: str, mime_type: str):
return f"http://s3.local/{key}?mime={mime_type}"
def head_object(self, key: str) -> dict:
row = self.objects.get(key)
if row is None:
raise ClientError({"Error": {"Code": "404", "Message": "Not Found"}}, "HeadObject")
return {"ContentLength": row["size"]}
def get_object(self, key: str) -> dict: def get_object(self, key: str) -> dict:
row = self.objects.get(key) row = self.objects.get(key)
if row is None: if row is None:
@ -299,3 +308,82 @@ class PublicCabinetTests(unittest.TestCase):
cookies=self._public_cookies("TRK-OTHER"), cookies=self._public_cookies("TRK-OTHER"),
) )
self.assertEqual(denied.status_code, 403) self.assertEqual(denied.status_code, 403)
def test_public_upload_complete_links_attachment_to_message_when_message_id_provided(self):
fake_s3 = _FakeS3Storage()
with self.SessionLocal() as db:
req = Request(
track_number="TRK-PUBLIC-UPL-1",
client_name="Клиент Файл Сообщение",
client_phone="+79995550000",
topic_code="consulting",
status_code="NEW",
description="Проверка привязки файла к сообщению",
extra_fields={},
)
db.add(req)
db.flush()
msg = Message(
request_id=req.id,
author_type="CLIENT",
author_name=req.client_name,
body="Сообщение клиента",
)
db.add(msg)
db.commit()
db.refresh(req)
db.refresh(msg)
request_id = str(req.id)
message_id = str(msg.id)
key = f"requests/{request_id}/chat/file.txt"
fake_s3.objects[key] = {
"content": b"hello",
"mime": "text/plain",
"size": 5,
}
with patch("app.api.public.uploads.get_s3_storage", return_value=fake_s3):
response = self.client.post(
"/api/public/uploads/complete",
cookies=self._public_cookies("TRK-PUBLIC-UPL-1"),
json={
"key": key,
"file_name": "file.txt",
"mime_type": "text/plain",
"size_bytes": 5,
"scope": "REQUEST_ATTACHMENT",
"request_id": request_id,
"message_id": message_id,
},
)
self.assertEqual(response.status_code, 200)
attachment_id = response.json().get("attachment_id")
self.assertIsNotNone(attachment_id)
with self.SessionLocal() as db:
row = db.get(Attachment, UUID(attachment_id))
self.assertIsNotNone(row)
self.assertEqual(str(row.message_id), message_id)
def test_public_status_route_endpoint_is_available_for_client(self):
with self.SessionLocal() as db:
req = Request(
track_number="TRK-ROUTE-001",
client_name="Клиент Маршрут",
client_phone="+79996660000",
topic_code="consulting",
status_code="IN_PROGRESS",
description="Проверка маршрута",
extra_fields={},
)
db.add(req)
db.commit()
response = self.client.get("/api/public/requests/TRK-ROUTE-001/status-route", cookies=self._public_cookies("TRK-ROUTE-001"))
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload.get("track_number"), "TRK-ROUTE-001")
self.assertEqual(payload.get("current_status"), "IN_PROGRESS")
self.assertTrue(isinstance(payload.get("nodes"), list))

View file

@ -25,6 +25,7 @@ class SmsProviderHealthTests(unittest.TestCase):
"SMS_PROVIDER": settings.SMS_PROVIDER, "SMS_PROVIDER": settings.SMS_PROVIDER,
"SMSAERO_EMAIL": settings.SMSAERO_EMAIL, "SMSAERO_EMAIL": settings.SMSAERO_EMAIL,
"SMSAERO_API_KEY": settings.SMSAERO_API_KEY, "SMSAERO_API_KEY": settings.SMSAERO_API_KEY,
"OTP_DEV_MODE": settings.OTP_DEV_MODE,
} }
def tearDown(self): def tearDown(self):
@ -113,3 +114,16 @@ class SmsProviderHealthTests(unittest.TestCase):
body = response.json() body = response.json()
self.assertEqual(body.get("status"), "error") self.assertEqual(body.get("status"), "error")
self.assertFalse(bool(body.get("can_send"))) self.assertFalse(bool(body.get("can_send")))
def test_sms_provider_health_dev_mode_forces_mock(self):
settings.SMS_PROVIDER = "smsaero"
settings.SMSAERO_EMAIL = ""
settings.SMSAERO_API_KEY = ""
settings.OTP_DEV_MODE = True
response = self.client.get("/api/admin/system/sms-provider-health", headers=self._headers("ADMIN"))
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body.get("status"), "ok")
self.assertEqual(body.get("mode"), "mock")
self.assertTrue(bool(body.get("dev_mode")))
self.assertEqual(body.get("effective_provider"), "mock_sms")

41
tests/test_sms_service.py Normal file
View file

@ -0,0 +1,41 @@
import os
import unittest
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
os.environ.setdefault("S3_ACCESS_KEY", "test")
os.environ.setdefault("S3_SECRET_KEY", "test")
os.environ.setdefault("S3_BUCKET", "test")
from app.core.config import settings
from app.services.sms_service import SmsDeliveryError, send_otp_message
class SmsServiceTests(unittest.TestCase):
def setUp(self):
self._settings_backup = {
"SMS_PROVIDER": settings.SMS_PROVIDER,
"SMSAERO_EMAIL": settings.SMSAERO_EMAIL,
"SMSAERO_API_KEY": settings.SMSAERO_API_KEY,
"OTP_DEV_MODE": settings.OTP_DEV_MODE,
}
def tearDown(self):
for key, value in self._settings_backup.items():
setattr(settings, key, value)
def test_dev_mode_forces_mock_send(self):
settings.SMS_PROVIDER = "smsaero"
settings.SMSAERO_EMAIL = ""
settings.SMSAERO_API_KEY = ""
settings.OTP_DEV_MODE = True
payload = send_otp_message(phone="+79990000000", code="111111", purpose="CREATE_REQUEST")
self.assertEqual(payload.get("provider"), "mock_sms")
self.assertTrue(bool(payload.get("dev_mode")))
def test_unknown_provider_raises(self):
settings.SMS_PROVIDER = "unknown"
settings.OTP_DEV_MODE = False
with self.assertRaises(SmsDeliveryError):
send_otp_message(phone="+79990000000", code="111111", purpose="CREATE_REQUEST")