From be36c2d232e280d7fabe2d8032d4f4823b65e00b Mon Sep 17 00:00:00 2001 From: TronoSfera <119615520+TronoSfera@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:56:37 +0300 Subject: [PATCH] fix user UI 2 --- app/api/public/chat.py | 5 +- app/api/public/requests.py | 19 +- app/api/public/uploads.py | 7 +- app/web/admin.css | 13 ++ .../features/requests/RequestWorkspace.jsx | 5 +- app/web/client.css | 144 ++++++++++++- app/web/client.jsx | 191 ++++++++++++++---- 7 files changed, 337 insertions(+), 47 deletions(-) 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()}> +
    +
    +

    Выбор заявки

    +

    Откройте нужную заявку из списка

    +
    +
    + + +
    +
    + +
    + +
    +
    +
    + ); + } + 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}

    +