Gate STO workplace by role
Some checks failed
ci / test (push) Has been cancelled

This commit is contained in:
VPN SaaS Dev
2026-05-16 10:33:33 +09:00
parent 83ad880b9d
commit ac5845d5a0
10 changed files with 1612 additions and 593 deletions

View File

@@ -423,11 +423,12 @@ function formData(form) {
}
async function api(path, options = {}) {
const headers = { "Content-Type": "application/json", ...authHeaders(options.headers || {}) };
const { headers: optionHeaders = {}, ...fetchOptions } = options;
const headers = { "Content-Type": "application/json", ...authHeaders(optionHeaders) };
if (options.body instanceof FormData) delete headers["Content-Type"];
const response = await fetch(`/api${path}`, {
...fetchOptions,
headers,
...options,
});
if (!response.ok) {
const text = await response.text();
@@ -541,9 +542,47 @@ function hideAuthOverlay() {
document.body.classList.remove("auth-required");
}
const APPROVED_SERVICE_STATUSES = new Set(["approved", "verified"]);
const STO_WORKPLACE_ROLES = new Set(["owner", "mechanic"]);
const STO_CALENDAR_ROLES = new Set(["owner", "manager", "receptionist"]);
function isPlatformAdmin() {
return ["admin", "verifier", "moderator"].includes(state.user?.platform_role);
}
function approvedServiceCenters() {
return state.serviceCenters.filter((center) => APPROVED_SERVICE_STATUSES.has(center.verification_status));
}
function stoWorkplaceCenters() {
return approvedServiceCenters().filter((center) => STO_WORKPLACE_ROLES.has(center.employee_role || "owner"));
}
function stoCalendarCenters() {
return approvedServiceCenters().filter((center) => STO_CALENDAR_ROLES.has(center.employee_role || "owner"));
}
function canUseServiceProfile() {
return state.serviceCenters.length > 0 || state.user?.platform_role === "service_owner" || isPlatformAdmin();
}
function canOpenDrawerSection(sectionId) {
if (sectionId === "adminSection") return isPlatformAdmin();
if (sectionId === "mechanicWorkplaceSection") return stoWorkplaceCenters().length > 0;
if (sectionId === "stoCalendarSection") return stoCalendarCenters().length > 0;
if (sectionId === "servicePanelSection") return canUseServiceProfile();
return true;
}
function updateRoleVisibility() {
const isAdmin = ["admin", "verifier", "moderator"].includes(state.user?.platform_role);
const isAdmin = isPlatformAdmin();
const hasWorkplace = stoWorkplaceCenters().length > 0;
const hasCalendar = stoCalendarCenters().length > 0;
const hasServiceProfile = canUseServiceProfile();
document.querySelectorAll(".admin-only").forEach((node) => node.classList.toggle("hidden", !isAdmin));
document.querySelectorAll(".sto-workplace-only").forEach((node) => node.classList.toggle("hidden", !hasWorkplace));
document.querySelectorAll(".sto-calendar-only").forEach((node) => node.classList.toggle("hidden", !hasCalendar));
document.querySelectorAll(".service-owner-only").forEach((node) => node.classList.toggle("hidden", !hasServiceProfile));
}
function showTelegramOpenHint() {
@@ -1051,17 +1090,20 @@ async function loadMyServiceCenters({ withTrust = false } = {}) {
state.activeServiceCenterId = state.serviceCenters[0]?.id || null;
}
renderServiceProfileCard();
updateRoleVisibility();
return state.serviceCenters;
}
function renderServiceProfileCard() {
const card = document.querySelector("#serviceProfileCard");
if (!card) return;
const hasCenters = state.serviceCenters.length > 0;
card.classList.toggle("hidden", !hasCenters);
document.querySelectorAll(".sto-only").forEach((node) => node.classList.toggle("hidden", !hasCenters));
if (!hasCenters) return;
const center = state.serviceCenters.find((item) => item.id === state.activeServiceCenterId) || state.serviceCenters[0];
updateRoleVisibility();
const centers = stoWorkplaceCenters();
const hasWorkplace = centers.length > 0;
card.classList.toggle("hidden", !hasWorkplace);
if (!hasWorkplace) return;
const center = centers.find((item) => item.id === state.activeServiceCenterId) || centers[0];
state.activeServiceCenterId = center.id;
const role = serviceRoleLabel(center.employee_role || "owner");
document.querySelector("#serviceProfileTitle").textContent = center.display_name || center.name || "Рабочее место";
document.querySelector("#serviceProfileMeta").textContent = `${role} · ${serviceStatusLabel(center.verification_status)}`;
@@ -1394,13 +1436,12 @@ async function loadStoCalendar() {
if (!summary || !list) return;
try {
if (!state.serviceCenters.length) {
const centers = await api("/service-centers/my");
state.serviceCenters = centers;
await loadMyServiceCenters();
}
const center = state.serviceCenters[0];
const center = stoCalendarCenters()[0];
if (!center) {
summary.innerHTML = "";
list.innerHTML = `<div class="empty">СТО пока не создано</div>`;
list.innerHTML = `<div class="empty">Календарь доступен только сотрудникам подтвержденного СТО.</div>`;
return;
}
const [dashboard, appointments] = await Promise.all([
@@ -1459,18 +1500,21 @@ async function loadMechanicWorkplace() {
if (!centerSelect || !summary || !list) return;
try {
if (!state.serviceCenters.length) await loadMyServiceCenters();
if (!state.serviceCenters.length) {
const centers = stoWorkplaceCenters();
if (!centers.length) {
centerSelect.innerHTML = "";
summary.innerHTML = "";
list.innerHTML = `<div class="empty">Сначала зарегистрируйте СТО или примите приглашение сотрудника.</div>`;
list.innerHTML = `<div class="empty">Рабочее место доступно владельцу подтвержденного СТО и активным механикам.</div>`;
return;
}
centerSelect.innerHTML = state.serviceCenters
.filter((center) => centers.some((item) => item.id === center.id))
.map((center) => `<option value="${center.id}">${escapeHtml(center.display_name || center.name)}</option>`)
.join("");
centerSelect.value = String(state.activeServiceCenterId || state.serviceCenters[0].id);
const selectedCenter = centers.find((item) => item.id === state.activeServiceCenterId) || centers[0];
centerSelect.value = String(selectedCenter.id);
const serviceCenterId = Number(centerSelect.value);
const center = state.serviceCenters.find((item) => item.id === serviceCenterId) || state.serviceCenters[0];
const center = centers.find((item) => item.id === serviceCenterId) || centers[0];
state.activeServiceCenterId = serviceCenterId;
renderServiceProfileCard();
@@ -2552,7 +2596,13 @@ function mountEntryForms() {
}
async function openDrawerSection(sectionId, options = {}) {
if (!canOpenDrawerSection(sectionId)) {
toast("Этот раздел недоступен для вашей роли", "error");
haptic("error");
sectionId = "carsSection";
}
document.querySelector("#userDrawer").classList.remove("hidden");
const drawerContent = document.querySelector(".drawer-content");
document.querySelectorAll(".drawer-section").forEach((section) => {
section.classList.toggle("hidden", section.id !== sectionId);
});
@@ -2583,11 +2633,11 @@ async function openDrawerSection(sectionId, options = {}) {
if (sectionId === "reviewsSection") renderServiceReviews();
if (sectionId === "adminSection") await loadAdminPendingServices();
if (options.expenseCategory) {
openDrawerSection("expensesSection");
await openDrawerSection("expensesSection");
presetExpense(options.expenseCategory);
return;
}
document.querySelector(`#${sectionId}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
if (drawerContent) drawerContent.scrollTo({ top: 0, behavior: "smooth" });
}
function presetExpense(category) {
@@ -2649,6 +2699,17 @@ document.querySelectorAll("[data-menu-section]").forEach((button) => {
});
});
document.querySelectorAll("[data-open-sto-page]").forEach((button) => {
button.addEventListener("click", () => {
if (!stoWorkplaceCenters().length) {
toast("Панель СТО доступна владельцу подтвержденного СТО и активным механикам", "error");
haptic("error");
return;
}
window.location.href = "/sto.html";
});
});
document.querySelector("#mechanicCenterSelect")?.addEventListener("change", async (event) => {
state.activeServiceCenterId = Number(event.currentTarget.value);
await runAction(event.currentTarget, "Обновляю рабочее место...", loadMechanicWorkplace);

427
web/static/sto.js Normal file
View File

@@ -0,0 +1,427 @@
const tg = window.Telegram?.WebApp;
tg?.ready();
tg?.expand();
const APPROVED_SERVICE_STATUSES = new Set(["approved", "verified"]);
const STO_WORKPLACE_ROLES = new Set(["owner", "mechanic"]);
const state = {
user: null,
authConfig: null,
centers: [],
activeCenterId: null,
appointments: [],
workOrders: [],
employees: [],
};
function authHeaders(extra = {}) {
const headers = { ...extra };
if (tg?.initData) headers["X-Telegram-Init-Data"] = tg.initData;
if (!tg?.initData && state.authConfig?.allow_dev_auth) {
headers["X-Dev-Telegram-Id"] = localStorage.getItem("driversDevTelegramId") || "1";
}
return headers;
}
async function api(path, options = {}) {
const { headers: optionHeaders = {}, ...fetchOptions } = options;
const headers = { "Content-Type": "application/json", ...authHeaders(optionHeaders) };
const response = await fetch(`/api${path}`, { ...fetchOptions, headers });
if (!response.ok) throw new Error(await response.text() || response.statusText);
if (response.status === 204) return null;
return response.json();
}
async function loadAuthConfig() {
state.authConfig = await api("/users/auth/config");
}
async function ensureUser() {
if (tg?.initData) {
state.user = await api("/users/webapp-auth", {
method: "POST",
body: JSON.stringify({ init_data: tg.initData }),
});
} else if (state.authConfig?.allow_dev_auth) {
state.user = await api("/users/me");
} else {
showAuthOverlay();
throw new Error("Требуется вход через Telegram");
}
document.body.classList.remove("auth-required");
document.querySelector("#authOverlay")?.classList.add("hidden");
}
function showAuthOverlay() {
document.body.classList.add("auth-required");
const botUsername = state.authConfig?.bot_username;
const link = document.querySelector("#telegramLoginLink");
if (botUsername && link) {
link.href = `https://t.me/${botUsername}`;
link.classList.remove("hidden");
}
}
function toast(message, tone = "success") {
const node = document.querySelector("#toast");
if (!node) return;
node.textContent = message;
node.className = `toast ${tone}`;
window.clearTimeout(toast.timer);
toast.timer = window.setTimeout(() => node.classList.add("hidden"), 2600);
}
function money(value) {
const currency = state.user?.currency || "RUB";
return Number(value || 0).toLocaleString("ru-RU", {
style: "currency",
currency,
maximumFractionDigits: currency === "KRW" ? 0 : 2,
});
}
function formatDateTime(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value).slice(0, 16).replace("T", " ");
return date.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function formData(form) {
return Object.fromEntries(new FormData(form).entries());
}
function numberOrNull(value) {
return value === "" || value == null ? null : Number(value);
}
function activeCenter() {
return state.centers.find((center) => center.id === state.activeCenterId) || state.centers[0] || null;
}
function roleLabel(role) {
return {
owner: "Владелец",
manager: "Менеджер",
receptionist: "Администратор",
mechanic: "Механик",
}[role] || role || "Сотрудник";
}
function statusLabel(status) {
return {
draft: "Черновик",
diagnosis: "Диагностика",
waiting_owner_approval: "Ждет согласования",
approved_by_owner: "Согласован",
rejected_by_owner: "Отклонен клиентом",
in_progress: "В работе",
completed: "Завершен",
requested: "Новая заявка",
confirmed: "Подтверждена",
confirmed_by_sto: "Подтверждена СТО",
proposed_new_time: "Предложено другое время",
invited: "Приглашен",
active: "Активен",
inactive: "Отключен",
revoked: "Отозван",
expired: "Истекло",
}[status] || status || "Без статуса";
}
async function loadCenters() {
const centers = await api("/service-centers/my");
state.centers = centers.filter((center) =>
APPROVED_SERVICE_STATUSES.has(center.verification_status) && STO_WORKPLACE_ROLES.has(center.employee_role || "owner"),
);
if (!state.activeCenterId && state.centers.length) state.activeCenterId = state.centers[0].id;
if (state.activeCenterId && !state.centers.some((center) => center.id === state.activeCenterId)) {
state.activeCenterId = state.centers[0]?.id || null;
}
}
function renderAccessDenied() {
document.querySelector("#centerTitle").textContent = "Доступ закрыт";
document.querySelector("#centerMeta").textContent = "Страница доступна владельцу подтвержденного СТО и активным механикам.";
document.querySelector("#roleBadge").textContent = "Нет доступа";
document.querySelector("#centerSelect").innerHTML = "";
document.querySelector("#dashboardStats").innerHTML = "";
document.querySelector("#appointmentsList").innerHTML = `<div class="empty">Нет доступного подтвержденного СТО.</div>`;
document.querySelector("#workOrdersList").innerHTML = `<div class="empty">Рабочее место недоступно.</div>`;
document.querySelector("#staffPanel").classList.add("hidden");
}
async function loadWorkplace() {
await loadCenters();
if (!state.centers.length) {
renderAccessDenied();
return;
}
const center = activeCenter();
document.querySelector("#centerSelect").innerHTML = state.centers
.map((item) => `<option value="${item.id}">${escapeHtml(item.display_name || item.name)}</option>`)
.join("");
document.querySelector("#centerSelect").value = String(center.id);
document.querySelector("#centerTitle").textContent = center.display_name || center.name;
document.querySelector("#centerMeta").textContent = [center.city, center.address].filter(Boolean).join(", ") || "Адрес не указан";
document.querySelector("#roleBadge").textContent = roleLabel(center.employee_role || "owner");
const [dashboard, appointments, visits, employees] = await Promise.all([
api(`/sto/dashboard?service_center_id=${center.id}`).catch(() => null),
api(`/sto/appointments?service_center_id=${center.id}`).catch(() => []),
api(`/service-centers/${center.id}/visits`).catch(() => []),
center.employee_role === "owner" ? api(`/service-centers/${center.id}/employees`).catch(() => []) : Promise.resolve([]),
]);
state.appointments = appointments.filter((item) => ["requested", "confirmed", "confirmed_by_sto", "proposed_new_time"].includes(item.status));
state.workOrders = visits.filter((item) => !["completed", "cancelled", "archived", "confirmed", "disputed"].includes(item.status));
state.employees = employees;
renderDashboard(dashboard);
renderAppointments();
renderWorkOrders();
renderStaff();
}
function renderDashboard(dashboard) {
document.querySelector("#dashboardStats").innerHTML = dashboard
? `
<div class="stat-card"><span>Заявки</span><strong>${dashboard.pending_appointments}</strong></div>
<div class="stat-card"><span>Подтверждено</span><strong>${dashboard.confirmed_appointments}</strong></div>
<div class="stat-card"><span>Заказ-наряды</span><strong>${dashboard.active_work_orders}</strong></div>
<div class="stat-card"><span>Авто</span><strong>${dashboard.connected_vehicles}</strong></div>
<div class="stat-card"><span>Месяц</span><strong>${money(dashboard.revenue_month || 0)}</strong></div>
`
: `<div class="empty">Сводка недоступна</div>`;
}
function renderAppointments() {
const canManage = (activeCenter()?.employee_role || "owner") === "owner";
document.querySelector("#appointmentsList").innerHTML = state.appointments.length
? state.appointments.map((item) => `
<div class="stack-item work-order-card">
<strong>${escapeHtml(item.service_name)}</strong>
<small>${formatDateTime(item.confirmed_start_at || item.requested_start_at)} · авто #${item.vehicle_id}</small>
<span class="trust-badge">${statusLabel(item.status)}</span>
${canManage ? `<div class="row-actions">
${item.status === "requested" ? `<button type="button" data-confirm-appointment="${item.id}">Подтвердить</button>` : ""}
${["confirmed", "confirmed_by_sto"].includes(item.status) ? `<button type="button" data-create-work-order="${item.id}">Открыть заказ-наряд</button>` : ""}
<button type="button" class="ghost-btn" data-reject-appointment="${item.id}">Отклонить</button>
</div>` : ""}
</div>
`).join("")
: `<div class="empty">Новых записей нет</div>`;
}
function renderWorkOrders() {
const role = activeCenter()?.employee_role || "owner";
const canComplete = role === "owner";
document.querySelector("#workOrdersList").innerHTML = state.workOrders.length
? state.workOrders.map((item) => `
<div class="stack-item work-order-card">
<div class="work-order-head">
<div>
<strong>${escapeHtml(item.work_order_number || `Заказ-наряд #${item.id}`)}</strong>
<small>${item.visit_date} · авто #${item.vehicle_id} · ${item.odometer || "-"} км</small>
</div>
<span class="trust-badge">${statusLabel(item.status)}</span>
</div>
${item.customer_complaint ? `<small>Жалоба: ${escapeHtml(item.customer_complaint)}</small>` : ""}
${item.diagnosis ? `<small>Диагностика: ${escapeHtml(item.diagnosis)}</small>` : ""}
<div class="work-order-totals">
<span>Работы: <strong>${money(item.labor_total || 0)}</strong></span>
<span>Запчасти: <strong>${money(item.product_total || 0)}</strong></span>
<span>Итого: <strong>${money(item.final_total || item.total_cost || 0)}</strong></span>
</div>
<form class="inline-work-form" data-labor-form="${item.id}">
<input name="title" placeholder="Работа" required />
<input name="quantity" type="number" min="0.001" step="0.001" value="1" aria-label="Количество" />
<input name="unit_price" type="number" min="0" step="0.01" placeholder="Цена" required />
<button type="submit">+ Работа</button>
</form>
<form class="inline-work-form" data-product-form="${item.id}">
<input name="title" placeholder="Запчасть / материал" required />
<input name="quantity" type="number" min="0.001" step="0.001" value="1" aria-label="Количество" />
<input name="unit_price" type="number" min="0" step="0.01" placeholder="Цена" required />
<button type="submit">+ Материал</button>
</form>
<div class="row-actions">
${["draft", "diagnosis", "approved_by_owner"].includes(item.status) ? `<button type="button" data-start-work-order="${item.id}">В работу</button>` : ""}
${role === "owner" ? `<button type="button" data-submit-work-order="${item.id}">На согласование</button>` : ""}
${canComplete ? `<button type="button" class="ghost-btn" data-complete-work-order="${item.id}">Завершить</button>` : ""}
</div>
</div>
`).join("")
: `<div class="empty">Активных заказ-нарядов нет</div>`;
}
function renderStaff() {
const center = activeCenter();
const panel = document.querySelector("#staffPanel");
if (!center || center.employee_role !== "owner") {
panel.classList.add("hidden");
return;
}
panel.classList.remove("hidden");
document.querySelector("#employeesList").innerHTML = state.employees.length
? state.employees.map((employee) => `
<div class="stack-item">
<strong>${escapeHtml(employee.first_name || employee.username || `Telegram ${employee.telegram_id || employee.user_id}`)}</strong>
<small>${employee.telegram_id ? `Telegram ID: ${employee.telegram_id}` : `User #${employee.user_id}`}</small>
<span class="trust-badge">${roleLabel(employee.role)} · ${statusLabel(employee.status)}</span>
${employee.invite_token ? `<small>Команда для сотрудника: /accept_sto_invite ${employee.invite_token}</small>` : ""}
${employee.role !== "owner" ? `<div class="row-actions">
<button type="button" data-role-employee="${employee.id}" data-role="mechanic">Механик</button>
<button type="button" data-role-employee="${employee.id}" data-role="receptionist">Администратор</button>
<button type="button" class="ghost-btn" data-disable-employee="${employee.id}">Отключить</button>
</div>` : ""}
</div>
`).join("")
: `<div class="empty">Сотрудников пока нет</div>`;
}
async function runAction(button, callback) {
if (button) button.disabled = true;
try {
await callback();
toast("Готово");
await loadWorkplace();
} catch (error) {
console.error(error);
toast(error.message || "Ошибка", "error");
} finally {
if (button) button.disabled = false;
}
}
document.querySelector("#centerSelect").addEventListener("change", async (event) => {
state.activeCenterId = Number(event.currentTarget.value);
await loadWorkplace();
});
document.querySelector("#refreshBtn").addEventListener("click", () => loadWorkplace());
document.querySelector("#inviteForm").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const center = activeCenter();
const data = formData(form);
await runAction(form.querySelector('button[type="submit"]'), async () => {
const employee = await api(`/service-centers/${center.id}/employees/invite`, {
method: "POST",
body: JSON.stringify({
telegram_id: Number(data.telegram_id),
role: data.role,
}),
});
const result = document.querySelector("#inviteResult");
result.classList.remove("hidden");
result.textContent = `Передайте сотруднику команду: /accept_sto_invite ${employee.invite_token}`;
form.reset();
});
});
document.body.addEventListener("click", async (event) => {
const button = event.target.closest("button");
if (!button) return;
const center = activeCenter();
if (button.dataset.confirmAppointment) {
await runAction(button, () => api(`/sto/appointments/${button.dataset.confirmAppointment}/confirm`, {
method: "POST",
body: JSON.stringify({ comment: "Подтверждено в панели СТО" }),
}));
}
if (button.dataset.rejectAppointment) {
await runAction(button, () => api(`/sto/appointments/${button.dataset.rejectAppointment}/reject`, {
method: "POST",
body: JSON.stringify({ comment: "Отклонено в панели СТО" }),
}));
}
if (button.dataset.createWorkOrder) {
const odometer = window.prompt("Пробег на приемке, км") || "";
await runAction(button, () => api(`/sto/appointments/${button.dataset.createWorkOrder}/create-work-order`, {
method: "POST",
body: JSON.stringify({ odometer: numberOrNull(odometer), notes: "Создано в панели СТО" }),
}));
}
if (button.dataset.startWorkOrder) {
await runAction(button, () => api(`/work-orders/${button.dataset.startWorkOrder}/start`, {
method: "POST",
body: JSON.stringify({ comment: "Взято в работу" }),
}));
}
if (button.dataset.submitWorkOrder) {
await runAction(button, () => api(`/work-orders/${button.dataset.submitWorkOrder}/submit-approval`, {
method: "POST",
body: JSON.stringify({ comment: "Смета готова к согласованию" }),
}));
}
if (button.dataset.completeWorkOrder) {
await runAction(button, () => api(`/work-orders/${button.dataset.completeWorkOrder}/complete`, {
method: "POST",
body: JSON.stringify({ comment: "Работы завершены" }),
}));
}
if (button.dataset.roleEmployee) {
await runAction(button, () => api(`/service-centers/employees/${button.dataset.roleEmployee}`, {
method: "PATCH",
body: JSON.stringify({ role: button.dataset.role, status: "active" }),
}));
}
if (button.dataset.disableEmployee) {
await runAction(button, () => api(`/service-centers/employees/${button.dataset.disableEmployee}`, {
method: "PATCH",
body: JSON.stringify({ status: "inactive" }),
}));
}
});
document.body.addEventListener("submit", async (event) => {
const form = event.target;
if (form.matches("[data-labor-form]")) {
event.preventDefault();
const data = formData(form);
await runAction(form.querySelector('button[type="submit"]'), () => api(`/work-orders/${form.dataset.laborForm}/labor-items`, {
method: "POST",
body: JSON.stringify({
title: data.title,
quantity: Number(data.quantity || 1),
unit: "job",
unit_price: Number(data.unit_price || 0),
work_type: "repair",
}),
}));
}
if (form.matches("[data-product-form]")) {
event.preventDefault();
const data = formData(form);
await runAction(form.querySelector('button[type="submit"]'), () => api(`/work-orders/${form.dataset.productForm}/product-items`, {
method: "POST",
body: JSON.stringify({
title: data.title,
quantity: Number(data.quantity || 1),
unit: "pcs",
unit_price: Number(data.unit_price || 0),
product_type: "part",
}),
}));
}
});
document.querySelector("#telegramRetryBtn").addEventListener("click", () => window.location.reload());
Promise.all([loadAuthConfig()])
.then(() => ensureUser())
.then(() => loadWorkplace())
.catch((error) => {
if (error.message === "Требуется вход через Telegram") return;
console.error(error);
toast(error.message || "Ошибка", "error");
});

View File

@@ -261,7 +261,6 @@ body.auth-required .shell {
.drawer-panel,
.sheet-panel {
max-height: 88vh;
overflow: auto;
padding: 18px;
border-radius: 16px 16px 0 0;
background: var(--surface);
@@ -270,14 +269,40 @@ body.auth-required .shell {
}
.drawer-panel {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 12px;
width: min(520px, 100%);
height: min(88vh, 860px);
margin-left: auto;
overflow: hidden;
}
.sheet-panel {
overflow: auto;
}
.drawer-menu,
.drawer-content {
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.drawer-menu {
display: grid;
gap: 10px;
max-height: min(38vh, 360px);
padding: 2px 4px 2px 0;
}
.drawer-content {
padding-right: 4px;
}
.drawer-section {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--line);
padding-top: 2px;
}
.drawer-form {
@@ -288,7 +313,7 @@ body.auth-required .shell {
.menu-row {
display: block;
width: 100%;
margin-bottom: 8px;
margin: 0;
background: var(--soft);
color: var(--text);
text-align: left;
@@ -1171,10 +1196,57 @@ button.is-busy {
color: #0e604f;
}
.menu-group {
display: grid;
gap: 6px;
padding: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
}
.menu-group summary {
display: flex;
min-height: 28px;
align-items: center;
justify-content: space-between;
color: var(--muted);
cursor: pointer;
font-size: 12px;
font-weight: 850;
list-style: none;
text-transform: uppercase;
}
.menu-group summary::-webkit-details-marker {
display: none;
}
.menu-group summary::after {
content: "+";
display: grid;
width: 22px;
height: 22px;
place-items: center;
border: 1px solid var(--line);
border-radius: 50%;
color: var(--muted);
font-size: 14px;
line-height: 1;
}
.menu-group[open] summary::after {
content: "-";
}
.menu-group .menu-row {
margin-top: 6px;
}
.service-profile-card {
display: grid;
gap: 10px;
margin: 4px 0 12px;
margin: 0;
padding: 12px;
border: 1px solid rgba(18, 115, 95, 0.24);
border-radius: 8px;
@@ -1561,6 +1633,98 @@ select {
font-weight: 700;
}
.sto-page {
background:
linear-gradient(180deg, #ffffff 0, #f2f6f4 220px),
var(--bg);
}
.sto-shell {
width: min(1380px, 100%);
}
.sto-page .topbar {
align-items: center;
}
.sto-page .top-actions {
align-items: center;
min-width: min(520px, 48vw);
}
.sto-page #centerSelect {
min-width: 0;
}
.sto-hero {
margin-bottom: 14px;
}
.sto-hero .trust-badge {
color: #103f35;
background: #dff7ef;
}
.mini-stats {
grid-template-columns: repeat(5, minmax(130px, 1fr));
}
.stat-card {
display: grid;
gap: 7px;
min-height: 88px;
padding: 13px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
box-shadow: 0 10px 24px rgba(27, 38, 34, 0.06);
}
.stat-card span {
color: var(--muted);
font-size: 12px;
font-weight: 750;
text-transform: uppercase;
}
.stat-card strong {
color: var(--text);
font-size: clamp(20px, 2.6vw, 28px);
line-height: 1.05;
}
.sto-grid {
display: grid;
grid-template-columns: minmax(320px, 0.95fr) minmax(420px, 1.35fr);
gap: 14px;
align-items: start;
}
.sto-grid .workspace {
min-width: 0;
}
#staffPanel {
grid-column: 1 / -1;
}
.staff-form {
grid-template-columns: minmax(220px, 1fr) minmax(160px, 220px) auto;
margin: 0 0 10px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fbfdfc;
}
.staff-form button {
min-width: 136px;
}
.stack-item small {
overflow-wrap: anywhere;
}
@keyframes toastIn {
from {
opacity: 0;
@@ -1627,6 +1791,37 @@ select {
.sheet-panel {
max-height: 92vh;
}
.drawer-panel {
height: 92vh;
}
.sto-page .topbar {
align-items: stretch;
flex-direction: column;
}
.sto-page .top-actions {
width: 100%;
min-width: 0;
}
.sto-grid,
.staff-form {
grid-template-columns: 1fr;
}
.mini-stats {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
padding-bottom: 4px;
}
.stat-card {
min-width: 70vw;
scroll-snap-align: start;
}
}
@media (max-width: 640px) {
@@ -1727,6 +1922,19 @@ select {
display: grid;
}
.sto-page .passport-head {
grid-template-columns: 1fr;
}
.sto-page .top-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) 44px;
}
.staff-form button {
width: 100%;
}
.auth-overlay {
align-items: stretch;
padding: 14px;