diff --git a/app/api/public/chat.py b/app/api/public/chat.py
index 6a58595..10c0a62 100644
--- a/app/api/public/chat.py
+++ b/app/api/public/chat.py
@@ -147,12 +147,13 @@ def _ensure_view_access_or_403(session: dict, req: Request) -> None:
subject = _require_view_session_or_403(session)
subject_track = _normalize_track(subject)
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):
return
if _normalize_phone(subject) and _normalize_phone(subject) == _normalize_phone(req.client_phone):
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")
diff --git a/app/api/public/requests.py b/app/api/public/requests.py
index c445f8a..c22498e 100644
--- a/app/api/public/requests.py
+++ b/app/api/public/requests.py
@@ -23,6 +23,7 @@ from app.models.audit_log import AuditLog
from app.models.notification import Notification
from app.models.request import Request
from app.models.request_service_request import RequestServiceRequest
+from app.models.status import Status
from app.models.status_history import StatusHistory
from app.models.topic import Topic
from app.services.invoice_crypto import decrypt_requisites
@@ -170,7 +171,8 @@ def _ensure_view_access_or_403(session: dict, req: Request) -> None:
return
if _normalize_email(subject) and _normalize_email(subject) == _normalize_email(req.client_email):
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:
@@ -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_track = _normalize_track(subject)
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()
if req is None:
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()
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]] = {}
if row_ids:
try:
@@ -380,6 +394,7 @@ def list_my_requests(
"track_number": row.track_number,
"topic_code": row.topic_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_unread_event_type": row.client_unread_event_type,
"viewer_unread_total": int((unread_by_request.get(str(row.id)) or {}).get("total", 0)),
diff --git a/app/api/public/uploads.py b/app/api/public/uploads.py
index a10eb1b..5b79de6 100644
--- a/app/api/public/uploads.py
+++ b/app/api/public/uploads.py
@@ -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:
purpose = str(session.get("purpose") or "").strip().upper()
if purpose != "VIEW_REQUEST":
- raise HTTPException(status_code=403, detail="Нет доступа к заявке")
+ raise HTTPException(status_code=404, detail="Заявка не найдена")
subject = str(session.get("sub") or "").strip()
if not subject:
- raise HTTPException(status_code=403, detail="Нет доступа к заявке")
+ raise HTTPException(status_code=404, detail="Заявка не найдена")
normalized_track = str(subject).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):
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:
diff --git a/app/web/admin.css b/app/web/admin.css
index 09e24ed..6c7cd6c 100644
--- a/app/web/admin.css
+++ b/app/web/admin.css
@@ -1528,6 +1528,15 @@
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 {
display: flex;
align-items: center;
@@ -2373,6 +2382,10 @@
flex-wrap: wrap;
}
+ .request-chat-head h3 {
+ margin: 0;
+ }
+
.request-chat-live-row {
display: inline-flex;
align-items: center;
diff --git a/app/web/admin/features/requests/RequestWorkspace.jsx b/app/web/admin/features/requests/RequestWorkspace.jsx
index 8eded81..b90a049 100644
--- a/app/web/admin/features/requests/RequestWorkspace.jsx
+++ b/app/web/admin/features/requests/RequestWorkspace.jsx
@@ -1328,7 +1328,8 @@ export function RequestWorkspace({
const state = String(node?.state || "pending");
const name = String(node?.name || statusLabel(node?.code));
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");
return (
@@ -1336,7 +1337,7 @@ export function RequestWorkspace({
{name}
{note ?
{note}
: null}
- {changedAt && state !== "pending" ?
Изменен: {changedAt}
: null}
+
Дата статуса: {changedAt || "-"}
);
diff --git a/app/web/client.css b/app/web/client.css
index 177e30a..0811b88 100644
--- a/app/web/client.css
+++ b/app/web/client.css
@@ -34,7 +34,7 @@
.client-request-toolbar {
display: flex;
gap: 0.6rem;
- align-items: flex-end;
+ align-items: center;
margin-bottom: 0.85rem;
}
@@ -42,6 +42,130 @@
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 {
margin-bottom: 0.85rem;
}
@@ -143,8 +267,26 @@
align-items: stretch;
}
+ .client-request-picker-trigger {
+ min-width: 0;
+ width: 100%;
+ justify-content: space-between;
+ }
+
.client-summary-dates {
display: grid;
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);
+ }
+}
diff --git a/app/web/client.jsx b/app/web/client.jsx
index 2a8fdd8..9208bf9 100644
--- a/app/web/client.jsx
+++ b/app/web/client.jsx
@@ -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 (
+ event.target.id === "client-request-picker-overlay" && onClose()}>
+ event.stopPropagation()}>
+
+
+
Выбор заявки
+
Откройте нужную заявку из списка
+
+
+
+
+ {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 (
+ -
+
+
+ );
+ })
+ ) : (
+ - {loading ? "Загрузка списка..." : "Заявок не найдено"}
+ )}
+
+
+
+
+
+
+ );
+ }
+
function App() {
const [requestModal, setRequestModal] = useState(createRequestModalState());
const [requestsList, setRequestsList] = useState([]);
const [activeTrack, setActiveTrack] = useState("");
+ const [requestPickerModal, setRequestPickerModal] = useState({ open: false, loading: false });
const [status, setStatus] = useState({ message: "", kind: "" });
const [serviceRequests, setServiceRequests] = useState([]);
const [clientHelpModal, setClientHelpModal] = useState({
@@ -489,11 +574,16 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
[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(
async (preferredTrack) => {
- const data = await apiJson("/api/public/requests/my", null, "Не удалось загрузить список заявок");
- const rows = Array.isArray(data?.rows) ? data.rows : [];
- setRequestsList(rows);
+ const rows = await refreshRequestsList();
if (!rows.length) {
setRequestModal((prev) => ({
...prev,
@@ -521,7 +611,47 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
await loadRequestWorkspace(selected, true);
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) => {
@@ -781,6 +911,10 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
}, [loadMyRequests, setPageStatus]);
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 (
@@ -813,39 +947,16 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
-
-
-
-
@@ -860,9 +971,6 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
-
- Создана: {summary ? fmtShortDateTime(summary.created_at) : "-"}
-
Обновлена: {summary ? fmtShortDateTime(summary.updated_at) : "-"}
@@ -916,6 +1024,15 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
{status.message}
+