mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 18:13:46 +03:00
fix user UI 2
This commit is contained in:
parent
ee99041b14
commit
be36c2d232
7 changed files with 337 additions and 47 deletions
|
|
@ -147,12 +147,13 @@ def _ensure_view_access_or_403(session: dict, req: Request) -> None:
|
||||||
subject = _require_view_session_or_403(session)
|
subject = _require_view_session_or_403(session)
|
||||||
subject_track = _normalize_track(subject)
|
subject_track = _normalize_track(subject)
|
||||||
if subject_track.startswith("TRK-") and subject_track != _normalize_track(req.track_number):
|
if subject_track.startswith("TRK-") and subject_track != _normalize_track(req.track_number):
|
||||||
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
if subject_track == _normalize_track(req.track_number):
|
if subject_track == _normalize_track(req.track_number):
|
||||||
return
|
return
|
||||||
if _normalize_phone(subject) and _normalize_phone(subject) == _normalize_phone(req.client_phone):
|
if _normalize_phone(subject) and _normalize_phone(subject) == _normalize_phone(req.client_phone):
|
||||||
return
|
return
|
||||||
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
|
# Return 404 to avoid exposing existence of чужой заявки.
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/requests/{track_number}/messages")
|
@router.get("/requests/{track_number}/messages")
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ from app.models.audit_log import AuditLog
|
||||||
from app.models.notification import Notification
|
from app.models.notification import Notification
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
from app.models.request_service_request import RequestServiceRequest
|
from app.models.request_service_request import RequestServiceRequest
|
||||||
|
from app.models.status import Status
|
||||||
from app.models.status_history import StatusHistory
|
from app.models.status_history import StatusHistory
|
||||||
from app.models.topic import Topic
|
from app.models.topic import Topic
|
||||||
from app.services.invoice_crypto import decrypt_requisites
|
from app.services.invoice_crypto import decrypt_requisites
|
||||||
|
|
@ -170,7 +171,8 @@ def _ensure_view_access_or_403(session: dict, req: Request) -> None:
|
||||||
return
|
return
|
||||||
if _normalize_email(subject) and _normalize_email(subject) == _normalize_email(req.client_email):
|
if _normalize_email(subject) and _normalize_email(subject) == _normalize_email(req.client_email):
|
||||||
return
|
return
|
||||||
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
|
# Do not disclose whether a foreign request exists.
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
|
||||||
|
|
||||||
def _request_for_track_or_404(db: Session, session: dict, track_number: str) -> Request:
|
def _request_for_track_or_404(db: Session, session: dict, track_number: str) -> Request:
|
||||||
|
|
@ -178,7 +180,7 @@ def _request_for_track_or_404(db: Session, session: dict, track_number: str) ->
|
||||||
subject = _require_view_session_or_403(session)
|
subject = _require_view_session_or_403(session)
|
||||||
subject_track = _normalize_track(subject)
|
subject_track = _normalize_track(subject)
|
||||||
if subject_track.startswith("TRK-") and subject_track != normalized_track:
|
if subject_track.startswith("TRK-") and subject_track != normalized_track:
|
||||||
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
req = db.query(Request).filter(Request.track_number == normalized_track).first()
|
req = db.query(Request).filter(Request.track_number == normalized_track).first()
|
||||||
if req is None:
|
if req is None:
|
||||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
|
@ -346,6 +348,18 @@ def list_my_requests(
|
||||||
|
|
||||||
rows = query.order_by(Request.updated_at.desc(), Request.created_at.desc(), Request.id.desc()).all()
|
rows = query.order_by(Request.updated_at.desc(), Request.created_at.desc(), Request.id.desc()).all()
|
||||||
row_ids = [row.id for row in rows if row and row.id]
|
row_ids = [row.id for row in rows if row and row.id]
|
||||||
|
status_names: dict[str, str] = {}
|
||||||
|
status_codes = {str(row.status_code or "").strip() for row in rows if str(row.status_code or "").strip()}
|
||||||
|
if status_codes:
|
||||||
|
try:
|
||||||
|
status_rows = db.query(Status.code, Status.name).filter(Status.code.in_(list(status_codes))).all()
|
||||||
|
except SQLAlchemyError:
|
||||||
|
status_rows = []
|
||||||
|
for code, name in status_rows:
|
||||||
|
normalized_code = str(code or "").strip()
|
||||||
|
if not normalized_code:
|
||||||
|
continue
|
||||||
|
status_names[normalized_code] = str(name or normalized_code)
|
||||||
unread_by_request: dict[str, dict[str, object]] = {}
|
unread_by_request: dict[str, dict[str, object]] = {}
|
||||||
if row_ids:
|
if row_ids:
|
||||||
try:
|
try:
|
||||||
|
|
@ -380,6 +394,7 @@ def list_my_requests(
|
||||||
"track_number": row.track_number,
|
"track_number": row.track_number,
|
||||||
"topic_code": row.topic_code,
|
"topic_code": row.topic_code,
|
||||||
"status_code": row.status_code,
|
"status_code": row.status_code,
|
||||||
|
"status_name": status_names.get(str(row.status_code or "").strip()) or str(row.status_code or ""),
|
||||||
"client_has_unread_updates": bool(row.client_has_unread_updates),
|
"client_has_unread_updates": bool(row.client_has_unread_updates),
|
||||||
"client_unread_event_type": row.client_unread_event_type,
|
"client_unread_event_type": row.client_unread_event_type,
|
||||||
"viewer_unread_total": int((unread_by_request.get(str(row.id)) or {}).get("total", 0)),
|
"viewer_unread_total": int((unread_by_request.get(str(row.id)) or {}).get("total", 0)),
|
||||||
|
|
|
||||||
|
|
@ -55,10 +55,10 @@ def _ensure_object_key_prefix_or_400(key: str, prefix: str) -> None:
|
||||||
def _ensure_public_request_access_or_403(request: Request, session: dict) -> None:
|
def _ensure_public_request_access_or_403(request: Request, session: dict) -> None:
|
||||||
purpose = str(session.get("purpose") or "").strip().upper()
|
purpose = str(session.get("purpose") or "").strip().upper()
|
||||||
if purpose != "VIEW_REQUEST":
|
if purpose != "VIEW_REQUEST":
|
||||||
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
subject = str(session.get("sub") or "").strip()
|
subject = str(session.get("sub") or "").strip()
|
||||||
if not subject:
|
if not subject:
|
||||||
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
|
||||||
normalized_track = str(subject).strip().upper()
|
normalized_track = str(subject).strip().upper()
|
||||||
if normalized_track == str(request.track_number or "").strip().upper():
|
if normalized_track == str(request.track_number or "").strip().upper():
|
||||||
|
|
@ -71,7 +71,8 @@ def _ensure_public_request_access_or_403(request: Request, session: dict) -> Non
|
||||||
|
|
||||||
if _normalize_phone(subject) and _normalize_phone(subject) == _normalize_phone(request.client_phone):
|
if _normalize_phone(subject) and _normalize_phone(subject) == _normalize_phone(request.client_phone):
|
||||||
return
|
return
|
||||||
raise HTTPException(status_code=403, detail="Нет доступа к заявке")
|
# Keep response uniform for foreign resources.
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
|
||||||
|
|
||||||
def _load_attachment_with_access_or_4xx(attachment_id: str, db: Session, session: dict) -> Attachment:
|
def _load_attachment_with_access_or_4xx(attachment_id: str, db: Session, session: dict) -> Attachment:
|
||||||
|
|
|
||||||
|
|
@ -1528,6 +1528,15 @@
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.request-main-column > .block,
|
||||||
|
.request-workspace-layout > .request-chat-block {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.request-card-head {
|
.request-card-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -2373,6 +2382,10 @@
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.request-chat-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.request-chat-live-row {
|
.request-chat-live-row {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -1328,7 +1328,8 @@ export function RequestWorkspace({
|
||||||
const state = String(node?.state || "pending");
|
const state = String(node?.state || "pending");
|
||||||
const name = String(node?.name || statusLabel(node?.code));
|
const name = String(node?.name || statusLabel(node?.code));
|
||||||
const note = String(node?.note || "").trim();
|
const note = String(node?.note || "").trim();
|
||||||
const changedAt = node?.changed_at ? fmtDate(node.changed_at) : "";
|
const changedAtSource = String(node?.changed_at || "").trim() || (index === 0 ? String(row?.created_at || "").trim() : "");
|
||||||
|
const changedAt = changedAtSource ? fmtDate(changedAtSource) : "";
|
||||||
const className = "route-item " + (state === "current" ? "current" : state === "completed" ? "completed" : "pending");
|
const className = "route-item " + (state === "current" ? "current" : state === "completed" ? "completed" : "pending");
|
||||||
return (
|
return (
|
||||||
<li className={className} key={(node?.code || "node") + "-" + index}>
|
<li className={className} key={(node?.code || "node") + "-" + index}>
|
||||||
|
|
@ -1336,7 +1337,7 @@ export function RequestWorkspace({
|
||||||
<div className="route-body">
|
<div className="route-body">
|
||||||
<b>{name}</b>
|
<b>{name}</b>
|
||||||
{note ? <p>{note}</p> : null}
|
{note ? <p>{note}</p> : null}
|
||||||
{changedAt && state !== "pending" ? <div className="muted route-time">Изменен: {changedAt}</div> : null}
|
<div className="muted route-time">Дата статуса: {changedAt || "-"}</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
.client-request-toolbar {
|
.client-request-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
align-items: flex-end;
|
align-items: center;
|
||||||
margin-bottom: 0.85rem;
|
margin-bottom: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,6 +42,130 @@
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-request-picker-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: min(100%, 340px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-request-picker-trigger-label {
|
||||||
|
color: #9fb3cc;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-request-picker-trigger-track {
|
||||||
|
color: #e7f0fd;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-request-news-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(120, 148, 184, 0.52);
|
||||||
|
box-shadow: 0 0 0 0 rgba(104, 201, 126, 0);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-request-news-dot.active {
|
||||||
|
background: rgba(84, 215, 114, 0.98);
|
||||||
|
box-shadow: 0 0 0 4px rgba(84, 215, 114, 0.18);
|
||||||
|
animation: client-news-dot-pulse 1.7s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-request-picker-modal {
|
||||||
|
width: min(760px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-request-picker-subtitle {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-request-picker-list {
|
||||||
|
min-height: min(52vh, 360px);
|
||||||
|
max-height: min(52vh, 360px);
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-request-picker-item {
|
||||||
|
padding: 0;
|
||||||
|
border-color: rgba(130, 153, 183, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-request-picker-item.active {
|
||||||
|
border-color: rgba(130, 174, 240, 0.48);
|
||||||
|
background: rgba(80, 117, 177, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-request-picker-item.has-updates {
|
||||||
|
border-color: rgba(84, 215, 114, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-request-picker-btn {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.58rem 0.62rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.24rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-request-picker-btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-request-picker-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-request-picker-track {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: #eef5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-request-picker-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.65rem;
|
||||||
|
font-size: 0.79rem;
|
||||||
|
color: #a8bdd8;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-request-picker-status {
|
||||||
|
max-width: 68%;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-request-picker-updated {
|
||||||
|
color: #97abc6;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-request-picker-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
.client-summary {
|
.client-summary {
|
||||||
margin-bottom: 0.85rem;
|
margin-bottom: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
@ -143,8 +267,26 @@
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-request-picker-trigger {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
.client-summary-dates {
|
.client-summary-dates {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes client-news-dot-pulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(84, 215, 114, 0.35);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 8px rgba(84, 215, 114, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(84, 215, 114, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -329,10 +329,95 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RequestPickerModal({
|
||||||
|
open,
|
||||||
|
loading,
|
||||||
|
requests,
|
||||||
|
activeTrack,
|
||||||
|
onClose,
|
||||||
|
onReload,
|
||||||
|
onSelect,
|
||||||
|
}) {
|
||||||
|
const rows = Array.isArray(requests) ? requests : [];
|
||||||
|
return (
|
||||||
|
<Overlay open={open} id="client-request-picker-overlay" onClose={(event) => event.target.id === "client-request-picker-overlay" && onClose()}>
|
||||||
|
<div className="modal client-request-picker-modal" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<div className="modal-head">
|
||||||
|
<div>
|
||||||
|
<h3>Выбор заявки</h3>
|
||||||
|
<p className="muted client-request-picker-subtitle">Откройте нужную заявку из списка</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal-head-actions">
|
||||||
|
<button
|
||||||
|
className="icon-btn file-action-btn"
|
||||||
|
type="button"
|
||||||
|
data-tooltip="Обновить список"
|
||||||
|
aria-label="Обновить список"
|
||||||
|
onClick={onReload}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
|
||||||
|
<path
|
||||||
|
d="M12 4a8 8 0 0 1 7.7 5.8 1 1 0 0 1-1.92.56A6 6 0 1 0 16.7 16H15a1 1 0 1 1 0-2h4a1 1 0 0 1 1 1v4a1 1 0 1 1-2 0v-1.18A8 8 0 1 1 12 4z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button className="close" type="button" onClick={onClose} aria-label="Закрыть">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className="simple-list client-request-picker-list" id="client-request-picker-list">
|
||||||
|
{rows.length ? (
|
||||||
|
rows.map((row, index) => {
|
||||||
|
const track = String(row?.track_number || "").trim();
|
||||||
|
const isActive = track && track === String(activeTrack || "").trim();
|
||||||
|
const hasUpdates = Number(row?.viewer_unread_total || 0) > 0 || Boolean(row?.client_has_unread_updates);
|
||||||
|
const statusName = String(row?.status_name || statusLabel(row?.status_code) || "-");
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={String(row?.id || track || "row-" + String(index))}
|
||||||
|
className={"client-request-picker-item" + (isActive ? " active" : "") + (hasUpdates ? " has-updates" : "")}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="client-request-picker-btn"
|
||||||
|
onClick={() => onSelect(track)}
|
||||||
|
disabled={!track || loading}
|
||||||
|
aria-label={"Открыть заявку " + (track || "")}
|
||||||
|
>
|
||||||
|
<div className="client-request-picker-head">
|
||||||
|
<span className="client-request-picker-track">{track || "Без номера"}</span>
|
||||||
|
<span className={"client-request-news-dot" + (hasUpdates ? " active" : "")} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div className="client-request-picker-meta">
|
||||||
|
<span className="client-request-picker-status">{statusName}</span>
|
||||||
|
<span className="client-request-picker-updated">{fmtShortDateTime(row?.updated_at)}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<li className="muted">{loading ? "Загрузка списка..." : "Заявок не найдено"}</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
<div className="client-request-picker-actions">
|
||||||
|
<button className="btn secondary btn-sm" type="button" onClick={onClose}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [requestModal, setRequestModal] = useState(createRequestModalState());
|
const [requestModal, setRequestModal] = useState(createRequestModalState());
|
||||||
const [requestsList, setRequestsList] = useState([]);
|
const [requestsList, setRequestsList] = useState([]);
|
||||||
const [activeTrack, setActiveTrack] = useState("");
|
const [activeTrack, setActiveTrack] = useState("");
|
||||||
|
const [requestPickerModal, setRequestPickerModal] = useState({ open: false, loading: false });
|
||||||
const [status, setStatus] = useState({ message: "", kind: "" });
|
const [status, setStatus] = useState({ message: "", kind: "" });
|
||||||
const [serviceRequests, setServiceRequests] = useState([]);
|
const [serviceRequests, setServiceRequests] = useState([]);
|
||||||
const [clientHelpModal, setClientHelpModal] = useState({
|
const [clientHelpModal, setClientHelpModal] = useState({
|
||||||
|
|
@ -489,11 +574,16 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
||||||
[apiJson]
|
[apiJson]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const refreshRequestsList = useCallback(async () => {
|
||||||
|
const data = await apiJson("/api/public/requests/my", null, "Не удалось загрузить список заявок");
|
||||||
|
const rows = Array.isArray(data?.rows) ? data.rows : [];
|
||||||
|
setRequestsList(rows);
|
||||||
|
return rows;
|
||||||
|
}, [apiJson]);
|
||||||
|
|
||||||
const loadMyRequests = useCallback(
|
const loadMyRequests = useCallback(
|
||||||
async (preferredTrack) => {
|
async (preferredTrack) => {
|
||||||
const data = await apiJson("/api/public/requests/my", null, "Не удалось загрузить список заявок");
|
const rows = await refreshRequestsList();
|
||||||
const rows = Array.isArray(data?.rows) ? data.rows : [];
|
|
||||||
setRequestsList(rows);
|
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
setRequestModal((prev) => ({
|
setRequestModal((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -521,7 +611,47 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
||||||
await loadRequestWorkspace(selected, true);
|
await loadRequestWorkspace(selected, true);
|
||||||
setPageStatus("Открыта заявка: " + selected, "ok");
|
setPageStatus("Открыта заявка: " + selected, "ok");
|
||||||
},
|
},
|
||||||
[apiJson, loadRequestWorkspace, setPageStatus]
|
[loadRequestWorkspace, refreshRequestsList, setPageStatus]
|
||||||
|
);
|
||||||
|
|
||||||
|
const openRequestPicker = useCallback(() => {
|
||||||
|
setRequestPickerModal({ open: true, loading: true });
|
||||||
|
void refreshRequestsList()
|
||||||
|
.catch((error) => setPageStatus(error?.message || "Не удалось загрузить список заявок", "error"))
|
||||||
|
.finally(() => {
|
||||||
|
setRequestPickerModal((prev) => ({ ...prev, loading: false }));
|
||||||
|
});
|
||||||
|
}, [refreshRequestsList, setPageStatus]);
|
||||||
|
|
||||||
|
const closeRequestPicker = useCallback(() => {
|
||||||
|
setRequestPickerModal((prev) => ({ ...prev, open: false }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reloadRequestPicker = useCallback(() => {
|
||||||
|
setRequestPickerModal((prev) => ({ ...prev, loading: true }));
|
||||||
|
void refreshRequestsList()
|
||||||
|
.catch((error) => setPageStatus(error?.message || "Не удалось обновить список заявок", "error"))
|
||||||
|
.finally(() => {
|
||||||
|
setRequestPickerModal((prev) => ({ ...prev, loading: false }));
|
||||||
|
});
|
||||||
|
}, [refreshRequestsList, setPageStatus]);
|
||||||
|
|
||||||
|
const selectRequestFromPicker = useCallback(
|
||||||
|
async (trackNumber) => {
|
||||||
|
const track = String(trackNumber || "").trim().toUpperCase();
|
||||||
|
if (!track) return;
|
||||||
|
setRequestPickerModal((prev) => ({ ...prev, loading: true }));
|
||||||
|
try {
|
||||||
|
await loadRequestWorkspace(track, true);
|
||||||
|
await refreshRequestsList();
|
||||||
|
setPageStatus("Открыта заявка: " + track, "ok");
|
||||||
|
setRequestPickerModal({ open: false, loading: false });
|
||||||
|
} catch (error) {
|
||||||
|
setRequestPickerModal((prev) => ({ ...prev, loading: false }));
|
||||||
|
setPageStatus(error?.message || "Не удалось открыть заявку", "error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadRequestWorkspace, refreshRequestsList, setPageStatus]
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateMessageDraft = useCallback((event) => {
|
const updateMessageDraft = useCallback((event) => {
|
||||||
|
|
@ -781,6 +911,10 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
||||||
}, [loadMyRequests, setPageStatus]);
|
}, [loadMyRequests, setPageStatus]);
|
||||||
|
|
||||||
const summary = requestModal.requestData || null;
|
const summary = requestModal.requestData || null;
|
||||||
|
const hasAnyUnreadUpdates = useMemo(
|
||||||
|
() => requestsList.some((row) => Number(row?.viewer_unread_total || 0) > 0 || Boolean(row?.client_has_unread_updates)),
|
||||||
|
[requestsList]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="client-page-shell">
|
<div className="client-page-shell">
|
||||||
|
|
@ -813,39 +947,16 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="client-request-toolbar">
|
<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 || "-") +
|
|
||||||
(Number(row?.viewer_unread_total || 0) > 0 ? " • непрочитано: " + String(row.viewer_unread_total) : "")}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
className="btn secondary"
|
className="btn secondary client-request-picker-trigger"
|
||||||
id="client-refresh"
|
id="client-request-open"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={openRequestPicker}
|
||||||
void loadMyRequests(activeTrack).catch((error) => setPageStatus(error?.message || "Не удалось обновить список", "error"));
|
disabled={requestModal.loading || !requestsList.length}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Обновить
|
<span className="client-request-picker-trigger-label">Заявка</span>
|
||||||
|
<span className="client-request-picker-trigger-track">{activeTrack || "Выбрать"}</span>
|
||||||
|
{hasAnyUnreadUpdates ? <span className="client-request-news-dot active" aria-hidden="true" /> : null}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -860,9 +971,6 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="client-summary-dates">
|
<div className="client-summary-dates">
|
||||||
<span>
|
|
||||||
Создана: <b id="cabinet-request-created">{summary ? fmtShortDateTime(summary.created_at) : "-"}</b>
|
|
||||||
</span>
|
|
||||||
<span>
|
<span>
|
||||||
Обновлена: <b id="cabinet-request-updated">{summary ? fmtShortDateTime(summary.updated_at) : "-"}</b>
|
Обновлена: <b id="cabinet-request-updated">{summary ? fmtShortDateTime(summary.updated_at) : "-"}</b>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -916,6 +1024,15 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
||||||
</section>
|
</section>
|
||||||
<p className="status" id="client-page-status">{status.message}</p>
|
<p className="status" id="client-page-status">{status.message}</p>
|
||||||
</main>
|
</main>
|
||||||
|
<RequestPickerModal
|
||||||
|
open={requestPickerModal.open}
|
||||||
|
loading={requestPickerModal.loading}
|
||||||
|
requests={requestsList}
|
||||||
|
activeTrack={activeTrack}
|
||||||
|
onClose={closeRequestPicker}
|
||||||
|
onReload={reloadRequestPicker}
|
||||||
|
onSelect={selectRequestFromPicker}
|
||||||
|
/>
|
||||||
<ClientHelpModal
|
<ClientHelpModal
|
||||||
open={clientHelpModal.open}
|
open={clientHelpModal.open}
|
||||||
status={clientHelpModal.status}
|
status={clientHelpModal.status}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue