fix user UI 2

This commit is contained in:
TronoSfera 2026-03-02 21:56:37 +03:00
parent ee99041b14
commit be36c2d232
7 changed files with 337 additions and 47 deletions

View file

@ -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")

View file

@ -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)),

View file

@ -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:

View file

@ -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;

View file

@ -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 (
<li className={className} key={(node?.code || "node") + "-" + index}>
@ -1336,7 +1337,7 @@ export function RequestWorkspace({
<div className="route-body">
<b>{name}</b>
{note ? <p>{note}</p> : null}
{changedAt && state !== "pending" ? <div className="muted route-time">Изменен: {changedAt}</div> : null}
<div className="muted route-time">Дата статуса: {changedAt || "-"}</div>
</div>
</li>
);

View file

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

View file

@ -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() {
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 (
<div className="client-page-shell">
@ -813,39 +947,16 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
</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 || "-") +
(Number(row?.viewer_unread_total || 0) > 0 ? " • непрочитано: " + String(row.viewer_unread_total) : "")}
</option>
))}
</select>
</div>
<button
className="btn secondary"
id="client-refresh"
className="btn secondary client-request-picker-trigger"
id="client-request-open"
type="button"
onClick={() => {
void loadMyRequests(activeTrack).catch((error) => setPageStatus(error?.message || "Не удалось обновить список", "error"));
}}
onClick={openRequestPicker}
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>
</div>
@ -860,9 +971,6 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
</span>
</div>
<div className="client-summary-dates">
<span>
Создана: <b id="cabinet-request-created">{summary ? fmtShortDateTime(summary.created_at) : "-"}</b>
</span>
<span>
Обновлена: <b id="cabinet-request-updated">{summary ? fmtShortDateTime(summary.updated_at) : "-"}</b>
</span>
@ -916,6 +1024,15 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
</section>
<p className="status" id="client-page-status">{status.message}</p>
</main>
<RequestPickerModal
open={requestPickerModal.open}
loading={requestPickerModal.loading}
requests={requestsList}
activeTrack={activeTrack}
onClose={closeRequestPicker}
onReload={reloadRequestPicker}
onSelect={selectRequestFromPicker}
/>
<ClientHelpModal
open={clientHelpModal.open}
status={clientHelpModal.status}