mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
fix client task view
This commit is contained in:
parent
ff169cb42d
commit
69055921cd
21 changed files with 1618 additions and 728 deletions
|
|
@ -31,6 +31,7 @@ SMS_PROVIDER=smsaero
|
|||
SMSAERO_EMAIL=your_email@example.com
|
||||
SMSAERO_API_KEY=your_api_key
|
||||
OTP_SMS_TEMPLATE=Your verification code: {code}
|
||||
OTP_DEV_MODE=false
|
||||
```
|
||||
|
||||
For local/dev mock mode:
|
||||
|
|
@ -39,5 +40,11 @@ SMS_PROVIDER=dummy
|
|||
```
|
||||
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):
|
||||
`GET /api/admin/system/sms-provider-health`
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from uuid import uuid4
|
|||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.core.config import settings
|
||||
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_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 (
|
||||
PublicAttachmentRead,
|
||||
PublicMessageCreate,
|
||||
|
|
@ -263,6 +265,30 @@ def get_request_by_track(
|
|||
session: dict = Depends(get_public_session),
|
||||
):
|
||||
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)
|
||||
notifications_cleared = mark_client_notifications_read(
|
||||
db,
|
||||
|
|
@ -282,10 +308,17 @@ def get_request_by_track(
|
|||
"client_name": req.client_name,
|
||||
"client_phone": req.client_phone,
|
||||
"topic_code": req.topic_code,
|
||||
"topic_name": topic_name,
|
||||
"status_code": req.status_code,
|
||||
"important_date_at": _to_iso(req.important_date_at),
|
||||
"description": req.description,
|
||||
"extra_fields": req.extra_fields,
|
||||
"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_unread_event_type": req.client_unread_event_type,
|
||||
"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])
|
||||
def list_messages_by_track(
|
||||
track_number: str,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from app.core.config import settings
|
|||
from app.core.deps import get_public_session
|
||||
from app.db.session import get_db
|
||||
from app.models.attachment import Attachment
|
||||
from app.models.message import Message
|
||||
from app.models.request import Request
|
||||
from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, UploadInitPayload, UploadInitResponse, UploadScope
|
||||
from app.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():
|
||||
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(
|
||||
request_id=request.id,
|
||||
message_id=None,
|
||||
message_id=message_uuid,
|
||||
file_name=payload.file_name,
|
||||
mime_type=payload.mime_type,
|
||||
size_bytes=actual_size,
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ class Settings(BaseSettings):
|
|||
OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300
|
||||
OTP_SEND_RATE_LIMIT: int = 8
|
||||
OTP_VERIFY_RATE_LIMIT: int = 20
|
||||
OTP_DEV_MODE: bool = False
|
||||
ADMIN_BOOTSTRAP_ENABLED: bool = True
|
||||
ADMIN_BOOTSTRAP_EMAIL: str = "admin@example.com"
|
||||
ADMIN_BOOTSTRAP_PASSWORD: str = "admin123"
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ class SmsDeliveryError(Exception):
|
|||
pass
|
||||
|
||||
|
||||
def _otp_dev_mode_enabled() -> bool:
|
||||
return bool(getattr(settings, "OTP_DEV_MODE", False))
|
||||
|
||||
|
||||
def _module_available(module_name: str) -> bool:
|
||||
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]:
|
||||
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"}:
|
||||
return {
|
||||
"provider": "dummy",
|
||||
"status": "ok",
|
||||
"mode": "mock",
|
||||
"dev_mode": False,
|
||||
"can_send": True,
|
||||
"balance_available": False,
|
||||
"balance_amount": None,
|
||||
|
|
@ -156,6 +175,7 @@ def sms_provider_health() -> dict[str, Any]:
|
|||
"provider": "smsaero",
|
||||
"status": "ok" if can_send and balance_available else "degraded",
|
||||
"mode": "real",
|
||||
"dev_mode": False,
|
||||
"can_send": can_send,
|
||||
"balance_available": balance_available,
|
||||
"balance_amount": balance_amount,
|
||||
|
|
@ -169,6 +189,7 @@ def sms_provider_health() -> dict[str, Any]:
|
|||
"provider": provider,
|
||||
"status": "error",
|
||||
"mode": "unknown",
|
||||
"dev_mode": False,
|
||||
"can_send": False,
|
||||
"balance_available": False,
|
||||
"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]:
|
||||
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()
|
||||
if provider in {"", "dummy", "mock", "console"}:
|
||||
return _mock_sms_send(phone=phone, code=code, purpose=purpose, track_number=track_number)
|
||||
|
|
|
|||
|
|
@ -38,8 +38,11 @@ export function RequestWorkspace({
|
|||
onLoadRequestDataTemplateDetails,
|
||||
onSaveRequestDataTemplate,
|
||||
onSaveRequestDataBatch,
|
||||
onSaveRequestDataValues,
|
||||
onUploadRequestAttachment,
|
||||
onChangeStatus,
|
||||
onConsumePendingStatusChangePreset,
|
||||
domIds,
|
||||
AttachmentPreviewModalComponent,
|
||||
StatusLineComponent,
|
||||
}) {
|
||||
|
|
@ -85,8 +88,33 @@ export function RequestWorkspace({
|
|||
templateStatus: "",
|
||||
error: "",
|
||||
});
|
||||
const [clientDataModal, setClientDataModal] = useState({
|
||||
open: false,
|
||||
loading: false,
|
||||
saving: false,
|
||||
messageId: "",
|
||||
items: [],
|
||||
status: "",
|
||||
error: "",
|
||||
});
|
||||
const fileInputRef = 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(
|
||||
() => [
|
||||
{ value: "string", label: "Строка" },
|
||||
|
|
@ -130,6 +158,7 @@ export function RequestWorkspace({
|
|||
const finance = financeSummary && typeof financeSummary === "object" ? financeSummary : null;
|
||||
const viewerRoleCode = String(viewerRole || "").toUpperCase();
|
||||
const canRequestData = viewerRoleCode === "LAWYER" || viewerRoleCode === "ADMIN";
|
||||
const canFillRequestData = viewerRoleCode === "CLIENT";
|
||||
const canSeeRate = viewerRoleCode !== "CLIENT";
|
||||
const safeMessages = Array.isArray(messages) ? messages : [];
|
||||
const safeAttachments = Array.isArray(attachments) ? attachments : [];
|
||||
|
|
@ -142,6 +171,7 @@ export function RequestWorkspace({
|
|||
const lawyerPhone = String(row?.assigned_lawyer_phone || "").trim();
|
||||
const clientHasPhone = Boolean(clientPhone);
|
||||
const lawyerHasPhone = Boolean(lawyerPhone);
|
||||
const messagePlaceholder = canFillRequestData ? "Введите сообщение для юриста" : "Введите сообщение для клиента";
|
||||
|
||||
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) => {
|
||||
if (rowLocked || dataRequestModal.loading || dataRequestModal.saving || dataRequestModal.savingTemplate) {
|
||||
event.preventDefault();
|
||||
|
|
@ -1032,7 +1179,7 @@ export function RequestWorkspace({
|
|||
</div>
|
||||
|
||||
<input
|
||||
id="request-modal-file-input"
|
||||
id={idMap.fileInput}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
|
|
@ -1043,7 +1190,7 @@ export function RequestWorkspace({
|
|||
|
||||
{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.map((entry) =>
|
||||
entry.type === "date" ? (
|
||||
|
|
@ -1077,33 +1224,41 @@ export function RequestWorkspace({
|
|||
</div>
|
||||
</li>
|
||||
) : (
|
||||
<li
|
||||
key={entry.key}
|
||||
className={
|
||||
(() => {
|
||||
const messageKind = String(entry.payload?.message_kind || "");
|
||||
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 " +
|
||||
(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-bubble" +
|
||||
(String(entry.payload?.message_kind || "") === "REQUEST_DATA" ? " chat-request-data-bubble" : "") +
|
||||
(entry.payload?.request_data_all_filled ? " all-filled" : "")
|
||||
}
|
||||
className={bubbleClass}
|
||||
onClick={
|
||||
String(entry.payload?.message_kind || "") === "REQUEST_DATA" && canRequestData
|
||||
? () => openEditDataRequestModal(String(entry.payload?.id || ""))
|
||||
requestDataInteractive
|
||||
? () =>
|
||||
canRequestData
|
||||
? openEditDataRequestModal(String(entry.payload?.id || ""))
|
||||
: openClientDataRequestModal(String(entry.payload?.id || ""))
|
||||
: undefined
|
||||
}
|
||||
role={String(entry.payload?.message_kind || "") === "REQUEST_DATA" && canRequestData ? "button" : undefined}
|
||||
tabIndex={String(entry.payload?.message_kind || "") === "REQUEST_DATA" && canRequestData ? 0 : undefined}
|
||||
role={requestDataInteractive ? "button" : undefined}
|
||||
tabIndex={requestDataInteractive ? 0 : undefined}
|
||||
onKeyDown={
|
||||
String(entry.payload?.message_kind || "") === "REQUEST_DATA" && canRequestData
|
||||
requestDataInteractive
|
||||
? (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
openEditDataRequestModal(String(entry.payload?.id || ""));
|
||||
if (canRequestData) openEditDataRequestModal(String(entry.payload?.id || ""));
|
||||
else openClientDataRequestModal(String(entry.payload?.id || ""));
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
|
|
@ -1145,6 +1300,8 @@ export function RequestWorkspace({
|
|||
<div className="chat-message-time">{fmtTimeOnly(entry.payload?.created_at)}</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})()
|
||||
)
|
||||
)
|
||||
) : (
|
||||
|
|
@ -1164,10 +1321,10 @@ export function RequestWorkspace({
|
|||
}}
|
||||
onDrop={onDropFiles}
|
||||
>
|
||||
<label htmlFor="request-modal-message-body">Новое сообщение</label>
|
||||
<label htmlFor={idMap.messageBody}>Новое сообщение</label>
|
||||
<textarea
|
||||
id="request-modal-message-body"
|
||||
placeholder="Введите сообщение для клиента"
|
||||
id={idMap.messageBody}
|
||||
placeholder={messagePlaceholder}
|
||||
value={messageDraft}
|
||||
onChange={onMessageChange}
|
||||
disabled={loading || fileUploading}
|
||||
|
|
@ -1208,6 +1365,17 @@ export function RequestWorkspace({
|
|||
Запросить
|
||||
</button>
|
||||
) : null}
|
||||
{canFillRequestData && idMap.fileUploadButton ? (
|
||||
<button
|
||||
className="btn secondary btn-sm"
|
||||
type="button"
|
||||
id={idMap.fileUploadButton}
|
||||
onClick={onSendMessage}
|
||||
disabled={loading || fileUploading || !hasPendingFiles}
|
||||
>
|
||||
Загрузить файл
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
className="icon-btn file-action-btn composer-attach-btn"
|
||||
type="button"
|
||||
|
|
@ -1225,7 +1393,7 @@ export function RequestWorkspace({
|
|||
</button>
|
||||
<button
|
||||
className="btn"
|
||||
id="request-modal-message-send"
|
||||
id={idMap.sendButton}
|
||||
type="submit"
|
||||
disabled={loading || fileUploading || !canSubmit}
|
||||
>
|
||||
|
|
@ -1236,7 +1404,7 @@ export function RequestWorkspace({
|
|||
</>
|
||||
) : (
|
||||
<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.map((item) => (
|
||||
<li key={String(item.id)}>
|
||||
|
|
@ -1251,7 +1419,7 @@ export function RequestWorkspace({
|
|||
type="button"
|
||||
data-tooltip="Предпросмотр"
|
||||
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">
|
||||
<path
|
||||
|
|
@ -1305,6 +1473,126 @@ export function RequestWorkspace({
|
|||
onClose={closePreview}
|
||||
/>
|
||||
) : 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
|
||||
className={"overlay" + (statusChangeModal.open ? " open" : "")}
|
||||
onClick={closeStatusChangeModal}
|
||||
|
|
|
|||
|
|
@ -1,601 +1,86 @@
|
|||
:root {
|
||||
--bg: #0d1217;
|
||||
--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;
|
||||
.client-page-shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
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));
|
||||
.client-main {
|
||||
width: min(1400px, calc(100% - 1.6rem));
|
||||
margin: 0 auto;
|
||||
padding: 1rem 0 1.6rem;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
backdrop-filter: blur(10px);
|
||||
background: rgba(13, 18, 23, 0.78);
|
||||
border-bottom: 1px solid var(--line);
|
||||
.client-topbar p {
|
||||
margin: 0.35rem 0 0;
|
||||
}
|
||||
|
||||
.topbar-inner {
|
||||
min-height: 76px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
.client-section {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brand {
|
||||
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 {
|
||||
.client-request-toolbar {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: end;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.34rem;
|
||||
}
|
||||
|
||||
.field.grow {
|
||||
.client-request-toolbar .field.grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #9fb0c6;
|
||||
font-weight: 700;
|
||||
.client-summary {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
input,
|
||||
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 {
|
||||
.client-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.7rem;
|
||||
gap: 0.65rem;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.service-request-actions {
|
||||
.client-summary-actions {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 0.58rem 0.65rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
.client-service-requests {
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
.meta-row small {
|
||||
display: block;
|
||||
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%);
|
||||
.client-service-requests h3 {
|
||||
margin: 0 0 0.65rem;
|
||||
}
|
||||
|
||||
.service-request-modal {
|
||||
width: min(700px, 100%);
|
||||
width: min(720px, 100%);
|
||||
}
|
||||
|
||||
.service-request-body {
|
||||
width: 100%;
|
||||
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 textarea {
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.service-request-form {
|
||||
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 {
|
||||
#client-page-status {
|
||||
margin-top: 0.7rem;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.file-row {
|
||||
margin-top: 0.7rem;
|
||||
display: flex;
|
||||
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: 1120px) {
|
||||
.client-summary-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.wrap {
|
||||
@media (max-width: 760px) {
|
||||
.client-main {
|
||||
width: calc(100% - 1rem);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.cabinet-card {
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.file-row {
|
||||
.client-request-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.client-summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,150 +4,13 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Страница клиента • Правовой трекер</title>
|
||||
<link rel="stylesheet" href="/admin.css?v=20260227-01">
|
||||
<link rel="stylesheet" href="/client.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="wrap topbar-inner">
|
||||
<div class="brand">Кабинет клиента</div>
|
||||
<nav class="nav">
|
||||
<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>
|
||||
<div id="client-root"></div>
|
||||
<script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
|
||||
<script src="/client.js?v=20260227-01"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
842
app/web/client.jsx
Normal file
842
app/web/client.jsx
Normal 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
1
app/web/client/index.jsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
import "../client.jsx";
|
||||
|
|
@ -236,6 +236,29 @@
|
|||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
const requestCloseButtons = document.querySelectorAll("[data-close-modal]");
|
||||
const accessOpenButtons = document.querySelectorAll("[data-open-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 requestStatus = document.getElementById("form-status");
|
||||
|
|
@ -15,6 +17,11 @@
|
|||
const accessCodeInput = document.getElementById("access-code");
|
||||
const accessSendOtpButton = document.getElementById("access-send-otp");
|
||||
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 quoteMeta = document.getElementById("quote-meta");
|
||||
|
|
@ -24,6 +31,7 @@
|
|||
const featuredTeamDots = document.getElementById("featured-team-dots");
|
||||
const featuredTeamPrev = document.getElementById("featured-team-prev");
|
||||
const featuredTeamNext = document.getElementById("featured-team-next");
|
||||
let otpModalResolver = null;
|
||||
|
||||
function setStatus(el, message, kind) {
|
||||
if (!el) return;
|
||||
|
|
@ -85,6 +93,16 @@
|
|||
accessCloseButtons.forEach((button) => {
|
||||
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) => {
|
||||
if (!modal) return;
|
||||
|
|
@ -92,9 +110,68 @@
|
|||
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) => {
|
||||
if (event.key !== "Escape") return;
|
||||
if (otpModalResolver && otpModal && otpModal.classList.contains("open")) {
|
||||
const resolve = otpModalResolver;
|
||||
otpModalResolver = null;
|
||||
resolve("");
|
||||
}
|
||||
closeModal(otpModal);
|
||||
closeModal(requestModal);
|
||||
closeModal(accessModal);
|
||||
});
|
||||
|
|
@ -367,7 +444,10 @@
|
|||
const otpSendData = await parseJsonSafe(otpSend);
|
||||
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 не введен");
|
||||
|
||||
setStatus(requestStatus, "Проверяем OTP...", null);
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -5,7 +5,7 @@ services:
|
|||
dockerfile: frontend/Dockerfile
|
||||
container_name: law-frontend
|
||||
depends_on: [backend]
|
||||
ports: ["8081:80"]
|
||||
ports: ["8081:80", "8080:80"]
|
||||
|
||||
e2e:
|
||||
build:
|
||||
|
|
|
|||
|
|
@ -245,6 +245,20 @@ async function createRequestViaLanding(page, options = {}) {
|
|||
await page.locator("#description").fill(description);
|
||||
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 statusText = await page.locator("#form-status").innerText();
|
||||
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;
|
||||
const filesTab = page.getByRole("tab", { name: /Файлы/ });
|
||||
if (await filesTab.count()) {
|
||||
await filesTab.click();
|
||||
}
|
||||
await expect(page.locator("#cabinet-files")).toContainText(fileName);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ test("public flow via UI: landing -> create request -> cabinet -> chat -> upload
|
|||
|
||||
const uploadedFile = `public-${Date.now()}.pdf`;
|
||||
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 page.locator("#file-preview-overlay #file-preview-body").waitFor();
|
||||
await page.locator("#file-preview-close").click();
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
FROM node:22-alpine AS admin-build
|
||||
FROM node:22-alpine AS frontend-build
|
||||
WORKDIR /build
|
||||
COPY app/web/admin ./admin
|
||||
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 \
|
||||
&& 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
|
||||
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
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
|
||||
|
|
|
|||
|
|
@ -20,6 +20,19 @@ server {
|
|||
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$ {
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
|
|
|
|||
|
|
@ -42,6 +42,15 @@ class _FakeS3Storage:
|
|||
def __init__(self):
|
||||
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:
|
||||
row = self.objects.get(key)
|
||||
if row is None:
|
||||
|
|
@ -299,3 +308,82 @@ class PublicCabinetTests(unittest.TestCase):
|
|||
cookies=self._public_cookies("TRK-OTHER"),
|
||||
)
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ class SmsProviderHealthTests(unittest.TestCase):
|
|||
"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):
|
||||
|
|
@ -113,3 +114,16 @@ class SmsProviderHealthTests(unittest.TestCase):
|
|||
body = response.json()
|
||||
self.assertEqual(body.get("status"), "error")
|
||||
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
41
tests/test_sms_service.py
Normal 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")
|
||||
Loading…
Reference in a new issue