mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +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_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")
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 loadMyRequests = useCallback(
|
||||
async (preferredTrack) => {
|
||||
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 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"));
|
||||
}}
|
||||
<button
|
||||
className="btn secondary client-request-picker-trigger"
|
||||
id="client-request-open"
|
||||
type="button"
|
||||
onClick={openRequestPicker}
|
||||
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"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void loadMyRequests(activeTrack).catch((error) => setPageStatus(error?.message || "Не удалось обновить список", "error"));
|
||||
}}
|
||||
>
|
||||
Обновить
|
||||
<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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue