compact UI and add localization switch
Some checks failed
ci / test (push) Has been cancelled

This commit is contained in:
VPN SaaS Dev
2026-05-19 05:05:24 +09:00
parent 5e5582664a
commit 58ff6ff614
6 changed files with 761 additions and 65 deletions

View File

@@ -53,6 +53,8 @@
</div> </div>
</div> </div>
<form id="filterForm" class="grid-form drawer-form compact-form"> <form id="filterForm" class="grid-form drawer-form compact-form">
<details class="advanced-fields compact-filter">
<summary>Фильтры</summary>
<label> <label>
Город Город
<input name="city" placeholder="Seoul" /> <input name="city" placeholder="Seoul" />
@@ -62,6 +64,7 @@
<input name="specialization" placeholder="BMW, масло, тормоза" /> <input name="specialization" placeholder="BMW, масло, тормоза" />
</label> </label>
<button type="submit">Найти</button> <button type="submit">Найти</button>
</details>
</form> </form>
<div id="serviceList" class="stack-list"></div> <div id="serviceList" class="stack-list"></div>
</aside> </aside>
@@ -89,6 +92,12 @@
<option value="other">Другое</option> <option value="other">Другое</option>
</select> </select>
</label> </label>
<div class="preset-row quick-service-pills wide" aria-label="Быстрый выбор услуги">
<button type="button" data-booking-service="oil_change">Масло</button>
<button type="button" data-booking-service="diagnostics">Диагностика</button>
<button type="button" data-booking-service="tire_service">Шины</button>
<button type="button" data-booking-service="brakes">Тормоза</button>
</div>
<label> <label>
Длительность Длительность
<select name="estimated_duration_minutes" id="durationSelect"> <select name="estimated_duration_minutes" id="durationSelect">

View File

@@ -220,6 +220,9 @@
Исполнитель Исполнитель
<input name="vendor" placeholder="СТО / магазин" /> <input name="vendor" placeholder="СТО / магазин" />
</label> </label>
<details class="advanced-fields wide">
<summary>Напоминание о следующем ТО</summary>
<div class="grid-form drawer-form compact-inner-form">
<label> <label>
Следующая дата Следующая дата
<input name="next_due_date" type="date" /> <input name="next_due_date" type="date" />
@@ -228,6 +231,8 @@
Следующий пробег Следующий пробег
<input name="next_due_odometer" type="number" min="0" /> <input name="next_due_odometer" type="number" min="0" />
</label> </label>
</div>
</details>
<button type="submit">Сохранить запись</button> <button type="submit">Сохранить запись</button>
</form> </form>
</section> </section>
@@ -292,6 +297,28 @@
</div> </div>
<div class="drawer-content"> <div class="drawer-content">
<section class="drawer-section hidden" id="quickAddSection">
<h2>Добавить запись</h2>
<div class="quick-entry-grid">
<button type="button" data-quick-entry="fuelSection">
<span>Заправка</span>
<small>дата, пробег, литры, цена</small>
</button>
<button type="button" data-quick-entry="serviceSection">
<span>ТО и ремонт</span>
<small>работа, стоимость, следующий срок</small>
</button>
<button type="button" data-quick-entry="expensesSection">
<span>Расход</span>
<small>страховка, штраф, парковка, прочее</small>
</button>
<button type="button" data-quick-entry="scan">
<span>Скан чека</span>
<small>фото или файл</small>
</button>
</div>
</section>
<section class="drawer-section hidden" id="carsSection"> <section class="drawer-section hidden" id="carsSection">
<h2>Автомобили</h2> <h2>Автомобили</h2>
<div id="drawerCars" class="cars drawer-cars"></div> <div id="drawerCars" class="cars drawer-cars"></div>
@@ -350,6 +377,9 @@
Поставщик / место Поставщик / место
<input name="vendor" /> <input name="vendor" />
</label> </label>
<details class="advanced-fields wide">
<summary>Дополнительно</summary>
<div class="grid-form drawer-form compact-inner-form">
<label> <label>
Одометр Одометр
<input name="odometer" type="number" min="0" /> <input name="odometer" type="number" min="0" />
@@ -393,6 +423,8 @@
<input name="is_recurring" type="checkbox" /> <input name="is_recurring" type="checkbox" />
Регулярный расход Регулярный расход
</label> </label>
</div>
</details>
<button type="submit">Сохранить расход</button> <button type="submit">Сохранить расход</button>
</form> </form>
</section> </section>

View File

@@ -4,6 +4,8 @@ tg?.expand();
const textNodes = new WeakMap(); const textNodes = new WeakMap();
const attrOriginals = new WeakMap(); const attrOriginals = new WeakMap();
let translationObserver = null;
let translationTimer = null;
const i18n = { const i18n = {
en: { en: {
@@ -83,6 +85,14 @@ const i18n = {
"Марка": "Make", "Марка": "Make",
"Модель": "Model", "Модель": "Model",
"Добавить авто": "Add vehicle", "Добавить авто": "Add vehicle",
"Добавить запись": "Add entry",
"Расход": "Expense",
"дата, пробег, литры, цена": "date, odometer, liters, price",
"работа, стоимость, следующий срок": "work, cost, next due",
"страховка, штраф, парковка, прочее": "insurance, fine, parking, other",
"фото или файл": "photo or file",
"Дополнительно": "More options",
"Напоминание о следующем ТО": "Next maintenance reminder",
"За весь срок": "All time", "За весь срок": "All time",
"За месяц": "This month", "За месяц": "This month",
"За день": "Per day", "За день": "Per day",
@@ -215,6 +225,14 @@ const i18n = {
"Марка": "브랜드", "Марка": "브랜드",
"Модель": "모델", "Модель": "모델",
"Добавить авто": "차량 추가", "Добавить авто": "차량 추가",
"Добавить запись": "기록 추가",
"Расход": "지출",
"дата, пробег, литры, цена": "날짜, 주행거리, 리터, 가격",
"работа, стоимость, следующий срок": "작업, 비용, 다음 예정",
"страховка, штраф, парковка, прочее": "보험, 벌금, 주차, 기타",
"фото или файл": "사진 또는 파일",
"Дополнительно": "추가 옵션",
"Напоминание о следующем ТО": "다음 정비 알림",
"За весь срок": "전체", "За весь срок": "전체",
"За месяц": "월", "За месяц": "월",
"За день": "일 평균", "За день": "일 평균",
@@ -304,6 +322,15 @@ function applyTranslations(root = document.body) {
}); });
} }
function observeTranslations(root = document.body) {
if (translationObserver || !root) return;
translationObserver = new MutationObserver(() => {
window.clearTimeout(translationTimer);
translationTimer = window.setTimeout(() => applyTranslations(root), 40);
});
translationObserver.observe(root, { childList: true, subtree: true });
}
const state = { const state = {
user: null, user: null,
@@ -582,6 +609,7 @@ async function ensureUser() {
method: "POST", method: "POST",
body: JSON.stringify({ init_data: tg.initData }), body: JSON.stringify({ init_data: tg.initData }),
}); });
localStorage.setItem("carpassLocale", state.user.locale || "ru");
hideAuthOverlay(); hideAuthOverlay();
updateRoleVisibility(); updateRoleVisibility();
return; return;
@@ -590,6 +618,7 @@ async function ensureUser() {
const devId = localStorage.getItem("driversDevTelegramId") || "1"; const devId = localStorage.getItem("driversDevTelegramId") || "1";
localStorage.setItem("driversDevTelegramId", devId); localStorage.setItem("driversDevTelegramId", devId);
state.user = await api("/users/me"); state.user = await api("/users/me");
localStorage.setItem("carpassLocale", state.user.locale || "ru");
hideAuthOverlay(); hideAuthOverlay();
updateRoleVisibility(); updateRoleVisibility();
return; return;
@@ -598,6 +627,36 @@ async function ensureUser() {
throw new Error("Требуется вход через Telegram"); throw new Error("Требуется вход через Telegram");
} }
function installLocaleSwitch() {
const topActions = document.querySelector(".topbar .top-actions");
if (!topActions || document.querySelector("#globalLocaleSelect")) return;
const select = document.createElement("select");
select.id = "globalLocaleSelect";
select.className = "locale-switch";
select.setAttribute("aria-label", "Язык");
select.innerHTML = `
<option value="ru">RU</option>
<option value="en">EN</option>
<option value="ko">KO</option>
`;
select.value = state.user?.locale || "ru";
select.addEventListener("change", async () => {
await runAction(select, "Сохраняю...", async () => {
state.user = await api(`/users/${state.user.id}/preferences`, {
method: "PATCH",
body: JSON.stringify({ locale: select.value, currency: state.user.currency }),
});
localStorage.setItem("carpassLocale", state.user.locale || "ru");
document.querySelector("#localeSelect").value = state.user.locale || "ru";
applyTranslations();
renderCars();
renderStats(state.latestStats);
toast("Сохранено");
});
});
topActions.prepend(select);
}
function hideAuthOverlay() { function hideAuthOverlay() {
document.querySelector("#authOverlay")?.classList.add("hidden"); document.querySelector("#authOverlay")?.classList.add("hidden");
document.body.classList.remove("auth-required"); document.body.classList.remove("auth-required");
@@ -2671,6 +2730,9 @@ document.querySelector("#settingsForm").addEventListener("submit", async (event)
method: "PATCH", method: "PATCH",
body: JSON.stringify({ locale: data.locale, currency: data.currency }), body: JSON.stringify({ locale: data.locale, currency: data.currency }),
}); });
localStorage.setItem("carpassLocale", state.user.locale || "ru");
const globalLocale = document.querySelector("#globalLocaleSelect");
if (globalLocale) globalLocale.value = state.user.locale || "ru";
applyTranslations(); applyTranslations();
initCarCatalog(); initCarCatalog();
await loadSelectedCar(); await loadSelectedCar();
@@ -2700,6 +2762,7 @@ document.querySelector("#fuelForm").addEventListener("submit", async (event) =>
}); });
form.reset(); form.reset();
form.entry_date.value = today(); form.entry_date.value = today();
form.is_full_tank.checked = true;
await loadSelectedCar(); await loadSelectedCar();
toast("Сохранено"); toast("Сохранено");
haptic("success"); haptic("success");
@@ -2802,6 +2865,20 @@ function mountEntryForms() {
} }
} }
function fillEntryDefaults(sectionId) {
const car = selectedCar();
const odometer = car?.current_odometer || "";
const sections = sectionId ? [document.querySelector(`#${sectionId}`)] : [...document.querySelectorAll(".drawer-section")];
sections.filter(Boolean).forEach((section) => {
section.querySelectorAll('input[name="entry_date"]').forEach((input) => {
if (!input.value) input.value = today();
});
section.querySelectorAll('input[name="odometer"]').forEach((input) => {
if (!input.value && odometer) input.value = odometer;
});
});
}
async function openDrawerSection(sectionId, options = {}) { async function openDrawerSection(sectionId, options = {}) {
if (!canOpenDrawerSection(sectionId)) { if (!canOpenDrawerSection(sectionId)) {
toast("Этот раздел недоступен для вашей роли", "error"); toast("Этот раздел недоступен для вашей роли", "error");
@@ -2817,6 +2894,7 @@ async function openDrawerSection(sectionId, options = {}) {
button.classList.toggle("active", button.dataset.menuSection === sectionId); button.classList.toggle("active", button.dataset.menuSection === sectionId);
}); });
mountEntryForms(); mountEntryForms();
fillEntryDefaults(sectionId);
if (sectionId === "carProfileSection") fillCarProfileForm(); if (sectionId === "carProfileSection") fillCarProfileForm();
if (sectionId === "settingsSection") { if (sectionId === "settingsSection") {
document.querySelector("#localeSelect").value = state.user?.locale || "ru"; document.querySelector("#localeSelect").value = state.user?.locale || "ru";
@@ -2891,7 +2969,18 @@ document.querySelector("#addCarQuickBtn").addEventListener("click", () => {
}); });
document.querySelector("#addRecordPrimaryBtn").addEventListener("click", () => { document.querySelector("#addRecordPrimaryBtn").addEventListener("click", () => {
openDrawerSection("expensesSection"); openDrawerSection("quickAddSection");
});
document.querySelectorAll("[data-quick-entry]").forEach((button) => {
button.addEventListener("click", async () => {
haptic();
if (button.dataset.quickEntry === "scan") {
openScanModal();
return;
}
await openDrawerSection(button.dataset.quickEntry);
});
}); });
document.querySelectorAll("[data-menu-section]").forEach((button) => { document.querySelectorAll("[data-menu-section]").forEach((button) => {
@@ -3043,6 +3132,8 @@ initPwa();
Promise.all([loadAuthConfig()]) Promise.all([loadAuthConfig()])
.then(() => Promise.all([ensureUser(), loadCatalog()])) .then(() => Promise.all([ensureUser(), loadCatalog()]))
.then(() => { .then(() => {
installLocaleSwitch();
observeTranslations();
document.querySelector("#localeSelect").value = state.user?.locale || "ru"; document.querySelector("#localeSelect").value = state.user?.locale || "ru";
document.querySelector("#currencySelect").value = state.user?.currency || "RUB"; document.querySelector("#currencySelect").value = state.user?.currency || "RUB";
document.querySelector("#expenseForm").currency.value = state.user?.currency || "RUB"; document.querySelector("#expenseForm").currency.value = state.user?.currency || "RUB";

View File

@@ -113,6 +113,16 @@ document.querySelector("#filterForm").addEventListener("submit", async (event) =
}); });
}); });
document.querySelectorAll("[data-booking-service]").forEach((button) => {
button.addEventListener("click", async () => {
document.querySelector("#serviceTypeSelect").value = button.dataset.bookingService;
document.querySelectorAll("[data-booking-service]").forEach((item) => {
item.classList.toggle("active", item === button);
});
await loadSlots().catch((error) => page.toast(error.message || "Не удалось обновить окна", "error"));
});
});
document.querySelector("#bookingForm").addEventListener("submit", async (event) => { document.querySelector("#bookingForm").addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
const center = selectedCenter(); const center = selectedCenter();

View File

@@ -2,6 +2,238 @@ const tg = window.Telegram?.WebApp;
tg?.ready(); tg?.ready();
tg?.expand(); tg?.expand();
const CarPassI18n = (() => {
const textNodes = new WeakMap();
const attrOriginals = new WeakMap();
let observer = null;
let timer = null;
const dictionaries = {
en: {
"Гараж": "Garage",
"Автомобили": "Vehicles",
"Автомобиль": "Vehicle",
"Авто": "Vehicles",
"Заправка": "Fuel",
"Сервис": "Service",
"Расход": "Expense",
"Расходы": "Expenses",
"ТО и ремонт": "Maintenance and repair",
"Дата": "Date",
"Одометр, км": "Odometer, km",
"Одометр": "Odometer",
"Литры": "Liters",
"Цена за литр": "Price per liter",
"АЗС": "Fuel station",
"Полный бак": "Full tank",
"Стоимость": "Cost",
"Валюта": "Currency",
"Категория": "Category",
"Название": "Title",
"Комментарий": "Comment",
"Сохранить": "Save",
"Сохранить запись": "Save entry",
"Сохранить расход": "Save expense",
"Сохранить заправку": "Save fuel entry",
"Сохранить настройки": "Save settings",
"Создать запись": "Create booking",
"Запись в СТО": "Book service",
"СТО": "Service centers",
"Сервисы": "Services",
"Каталог": "Catalog",
"Заявка": "Request",
"Выберите сервис": "Choose service",
"Город": "City",
"Специализация": "Specialization",
"Найти": "Search",
"Что нужно сделать": "What needs to be done",
"Услуга": "Service",
"Длительность": "Duration",
"Свободное окно": "Available slot",
"Окно записи": "Booking slot",
"Отправить заявку": "Send request",
"Проверить карточку авто": "Check vehicle card",
"Меню": "Menu",
"Проверить вход": "Check login",
"Открыть в Telegram": "Open in Telegram",
"Обновить": "Refresh",
"Настройки": "Settings",
"Язык": "Language",
"Панель СТО": "Service workplace",
"Записи клиентов": "Client bookings",
"Заказ-наряды": "Work orders",
"Сотрудники": "Staff",
"Пригласить": "Invite",
"Заказ-наряд": "Work order",
"Работы": "Labor",
"Запчасти и жидкости": "Parts and fluids",
"Проверьте смету": "Review estimate",
"Согласовать": "Approve",
"Отклонить": "Reject",
"Админ-панель": "Admin panel",
"Операционный обзор": "Operational overview",
"Последние события": "Latest events",
"Быстрые переходы": "Quick links",
"Заявки СТО": "Service applications",
"Записи": "Bookings",
"Фильтр": "Filter",
"Фильтры": "Filters",
"Запросить": "Query",
"Показать": "Show",
"Импорт и экспорт": "Import and export",
"Скачать JSON": "Download JSON",
"Проверить файл": "Preview file",
"Импортировать": "Import",
"Паспорт автомобиля": "Vehicle passport",
"Выберите автомобиль": "Choose vehicle",
"Параметры авто": "Vehicle settings",
"Сохранить паспорт": "Save passport",
"Удалить автомобиль": "Delete vehicle",
"Готов к работе": "Ready",
"Сохраняю...": "Saving...",
"Сохранено": "Saved",
"Ошибка": "Error",
"Нет данных": "No data",
"Нет доступа": "No access",
},
ko: {
"Гараж": "차고",
"Автомобили": "차량",
"Автомобиль": "차량",
"Авто": "차량",
"Заправка": "주유",
"Сервис": "정비",
"Расход": "지출",
"Расходы": "지출",
"ТО и ремонт": "정비 및 수리",
"Дата": "날짜",
"Одометр, км": "주행거리, km",
"Одометр": "주행거리",
"Литры": "리터",
"Цена за литр": "리터당 가격",
"АЗС": "주유소",
"Полный бак": "가득 주유",
"Стоимость": "비용",
"Валюта": "통화",
"Категория": "카테고리",
"Название": "제목",
"Комментарий": "메모",
"Сохранить": "저장",
"Сохранить запись": "기록 저장",
"Сохранить расход": "지출 저장",
"Сохранить заправку": "주유 저장",
"Сохранить настройки": "설정 저장",
"Создать запись": "예약 생성",
"Запись в СТО": "정비소 예약",
"СТО": "정비소",
"Сервисы": "서비스",
"Каталог": "목록",
"Заявка": "요청",
"Выберите сервис": "정비소 선택",
"Город": "도시",
"Специализация": "전문 분야",
"Найти": "검색",
"Что нужно сделать": "필요한 작업",
"Услуга": "서비스",
"Длительность": "소요 시간",
"Свободное окно": "예약 가능 시간",
"Окно записи": "예약 시간",
"Отправить заявку": "요청 보내기",
"Проверить карточку авто": "차량 카드 확인",
"Меню": "메뉴",
"Проверить вход": "로그인 확인",
"Открыть в Telegram": "텔레그램에서 열기",
"Обновить": "새로고침",
"Настройки": "설정",
"Язык": "언어",
"Панель СТО": "정비소 작업실",
"Записи клиентов": "고객 예약",
"Заказ-наряды": "작업지시서",
"Сотрудники": "직원",
"Пригласить": "초대",
"Заказ-наряд": "작업지시서",
"Работы": "공임",
"Запчасти и жидкости": "부품 및 오일",
"Проверьте смету": "견적 확인",
"Согласовать": "승인",
"Отклонить": "거절",
"Админ-панель": "관리자 패널",
"Операционный обзор": "운영 요약",
"Последние события": "최근 이벤트",
"Быстрые переходы": "빠른 이동",
"Заявки СТО": "정비소 신청",
"Записи": "예약",
"Фильтр": "필터",
"Фильтры": "필터",
"Запросить": "조회",
"Показать": "보기",
"Импорт и экспорт": "가져오기/내보내기",
"Скачать JSON": "JSON 다운로드",
"Проверить файл": "파일 확인",
"Импортировать": "가져오기",
"Паспорт автомобиля": "차량 패스포트",
"Выберите автомобиль": "차량 선택",
"Параметры авто": "차량 설정",
"Сохранить паспорт": "패스포트 저장",
"Удалить автомобиль": "차량 삭제",
"Готов к работе": "준비 완료",
"Сохраняю...": "저장 중...",
"Сохранено": "저장됨",
"Ошибка": "오류",
"Нет данных": "데이터 없음",
"Нет доступа": "접근 불가",
},
};
function t(text, locale = currentLocale()) {
return dictionaries[locale]?.[text] || text;
}
function currentLocale() {
return window.CarPassPage?.state?.user?.locale || localStorage.getItem("carpassLocale") || "ru";
}
function apply(root = document.body, locale = currentLocale()) {
document.documentElement.lang = locale;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement;
if (!parent || ["SCRIPT", "STYLE", "TEXTAREA", "INPUT", "SELECT"].includes(parent.tagName)) return NodeFilter.FILTER_REJECT;
return node.nodeValue.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
},
});
while (walker.nextNode()) {
const node = walker.currentNode;
if (!textNodes.has(node)) textNodes.set(node, node.nodeValue.trim());
const original = textNodes.get(node);
node.nodeValue = node.nodeValue.replace(node.nodeValue.trim(), t(original, locale));
}
root.querySelectorAll?.("[placeholder], [aria-label], [title]").forEach((element) => {
["placeholder", "aria-label", "title"].forEach((attr) => {
const value = element.getAttribute(attr);
if (!value) return;
let originals = attrOriginals.get(element);
if (!originals) {
originals = {};
attrOriginals.set(element, originals);
}
originals[attr] ||= value;
element.setAttribute(attr, t(originals[attr], locale));
});
});
}
function observe(root = document.body) {
if (observer || !root) return;
observer = new MutationObserver(() => {
window.clearTimeout(timer);
timer = window.setTimeout(() => apply(root), 40);
});
observer.observe(root, { childList: true, subtree: true });
}
return { apply, t, currentLocale, observe };
})();
const CarPassPage = (() => { const CarPassPage = (() => {
const state = { user: null, authConfig: null }; const state = { user: null, authConfig: null };
@@ -51,13 +283,14 @@ const CarPassPage = (() => {
} }
document.body.classList.remove("auth-required"); document.body.classList.remove("auth-required");
document.querySelector("#authOverlay")?.classList.add("hidden"); document.querySelector("#authOverlay")?.classList.add("hidden");
localStorage.setItem("carpassLocale", state.user.locale || "ru");
return state.user; return state.user;
} }
function toast(message, tone = "success") { function toast(message, tone = "success") {
const node = document.querySelector("#toast"); const node = document.querySelector("#toast");
if (!node) return; if (!node) return;
node.textContent = message; node.textContent = t(message);
node.className = `toast ${tone}`; node.className = `toast ${tone}`;
window.clearTimeout(toast.timer); window.clearTimeout(toast.timer);
toast.timer = window.setTimeout(() => node.classList.add("hidden"), 2600); toast.timer = window.setTimeout(() => node.classList.add("hidden"), 2600);
@@ -69,7 +302,7 @@ const CarPassPage = (() => {
button.dataset.label = button.textContent; button.dataset.label = button.textContent;
button.disabled = true; button.disabled = true;
button.classList.add("is-busy"); button.classList.add("is-busy");
button.innerHTML = `<span class="spinner"></span><span>${label}</span>`; button.innerHTML = `<span class="spinner"></span><span>${t(label)}</span>`;
} else { } else {
button.disabled = false; button.disabled = false;
button.classList.remove("is-busy"); button.classList.remove("is-busy");
@@ -115,18 +348,73 @@ const CarPassPage = (() => {
if (!value) return "-"; if (!value) return "-";
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value).slice(0, 16).replace("T", " "); 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" }); const locale = { ru: "ru-RU", en: "en-US", ko: "ko-KR" }[state.user?.locale] || "ru-RU";
return date.toLocaleString(locale, { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
} }
function today() { function today() {
return new Date().toISOString().slice(0, 10); return new Date().toISOString().slice(0, 10);
} }
function t(text) {
return CarPassI18n.t(text, state.user?.locale || "ru");
}
function applyTranslations(root = document.body) {
CarPassI18n.apply(root, state.user?.locale || "ru");
}
async function updateLocale(locale) {
if (!state.user || state.user.locale === locale) return;
state.user = await api(`/users/${state.user.id}/preferences`, {
method: "PATCH",
body: JSON.stringify({ locale }),
});
localStorage.setItem("carpassLocale", locale);
applyTranslations();
}
function installLocaleSwitch() {
const topbar = document.querySelector(".topbar");
if (!topbar || document.querySelector("#globalLocaleSelect")) return;
let host = topbar.querySelector(".top-actions");
if (!host) {
host = document.createElement("div");
host.className = "top-actions";
topbar.appendChild(host);
}
const select = document.createElement("select");
select.id = "globalLocaleSelect";
select.className = "locale-switch";
select.setAttribute("aria-label", "Язык");
select.innerHTML = `
<option value="ru">RU</option>
<option value="en">EN</option>
<option value="ko">KO</option>
`;
select.value = state.user?.locale || "ru";
select.addEventListener("change", async () => {
try {
await updateLocale(select.value);
toast("Сохранено");
} catch (error) {
toast(error.message || "Ошибка", "error");
}
});
const primarySelect = host.querySelector("select:not(.locale-switch)");
if (primarySelect) primarySelect.insertAdjacentElement("afterend", select);
else host.prepend(select);
}
async function boot(init) { async function boot(init) {
try { try {
await loadAuthConfig(); await loadAuthConfig();
await ensureUser(); await ensureUser();
installLocaleSwitch();
applyTranslations();
CarPassI18n.observe();
await init(); await init();
applyTranslations();
} catch (error) { } catch (error) {
if (error.message === "Требуется вход через Telegram") return; if (error.message === "Требуется вход через Telegram") return;
console.error(error); console.error(error);
@@ -148,5 +436,9 @@ const CarPassPage = (() => {
csvList, csvList,
formatDateTime, formatDateTime,
today, today,
t,
applyTranslations,
installLocaleSwitch,
updateLocale,
}; };
})(); })();

View File

@@ -2579,7 +2579,7 @@ select {
.sto-page .top-actions { .sto-page .top-actions {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 44px; grid-template-columns: minmax(0, 1fr) 58px 38px;
} }
.staff-form button { .staff-form button {
@@ -2597,3 +2597,265 @@ select {
padding: 20px; padding: 20px;
} }
} }
/* Compact UX pass */
.locale-switch {
width: 66px;
min-height: 34px;
padding: 0 8px;
border-radius: 8px;
background: #fff;
color: var(--text);
font-size: 12px;
font-weight: 850;
}
.shell {
padding-top: 10px;
}
.topbar {
padding-block: 9px;
}
.topbar h1 {
font-size: clamp(23px, 4vw, 34px);
}
.eyebrow {
font-size: 11px;
letter-spacing: 0.03em;
}
button,
.ghost-btn {
min-height: 38px;
padding-inline: 12px;
}
.icon-btn {
width: 38px;
min-height: 38px;
}
input,
select,
textarea {
min-height: 38px;
padding-inline: 10px;
}
label {
gap: 4px;
font-size: 12px;
}
.summary-card {
min-height: 92px;
padding: 13px;
}
.summary-card strong {
font-size: clamp(19px, 3.2vw, 27px);
}
.primary-add-btn {
min-height: 46px;
margin-bottom: 12px;
}
.panel,
.workspace,
.chart-card {
padding: 13px;
}
.passport-panel {
gap: 8px;
padding: 11px;
}
.passport-head h2,
h2 {
font-size: 17px;
}
.stats,
.hero-grid,
.layout,
.charts,
.flow-layout,
.sto-grid {
gap: 10px;
}
.stat,
.stat-card {
min-height: 76px;
padding: 10px;
}
.stat strong,
.stat-card strong {
font-size: clamp(18px, 2.1vw, 23px);
}
.grid-form,
.entry-form,
.flow-form {
gap: 9px;
}
.entry-form {
padding: 12px;
}
.drawer-panel {
width: min(560px, 100%);
gap: 9px;
padding: 14px;
}
.drawer-menu {
grid-template-columns: repeat(auto-fit, minmax(145px, 1fr));
gap: 8px;
max-height: min(28vh, 230px);
}
.menu-group {
padding: 8px;
}
.menu-row {
min-height: 40px;
padding-inline: 11px;
}
.quick-entry-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 9px;
}
.quick-entry-grid button {
display: grid;
gap: 4px;
min-height: 82px;
align-content: center;
background: #fff;
color: var(--text);
border: 1px solid var(--line);
box-shadow: 0 8px 20px rgba(27, 38, 34, 0.06);
text-align: left;
}
.quick-entry-grid button span {
font-size: 15px;
}
.quick-entry-grid button small {
color: var(--muted);
font-size: 12px;
line-height: 1.25;
}
.advanced-fields {
display: grid;
grid-column: 1 / -1;
gap: 8px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fbfdfc;
}
.advanced-fields summary {
min-height: 38px;
padding: 10px 12px;
color: var(--text);
cursor: pointer;
font-size: 12px;
font-weight: 850;
list-style: none;
}
.advanced-fields summary::-webkit-details-marker {
display: none;
}
.advanced-fields summary::after {
content: "+";
float: right;
color: var(--muted);
}
.advanced-fields[open] summary::after {
content: "-";
}
.compact-inner-form {
margin: 0;
padding: 0 10px 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.compact-filter {
width: 100%;
padding: 0;
}
.compact-filter label,
.compact-filter button {
margin: 0 10px 10px;
}
.quick-service-pills {
margin-top: -4px;
}
.quick-service-pills button.active {
border-color: rgba(18, 115, 95, 0.45);
background: #e7f4ef;
color: #0e604f;
}
.flow-hero {
padding: 14px;
}
.flow-hero h2 {
font-size: clamp(20px, 2.4vw, 28px);
}
.form-block {
padding: 10px;
}
.admin-table th,
.admin-table td {
padding: 7px 9px;
}
@media (max-width: 640px) {
.top-actions {
gap: 6px;
}
.locale-switch {
width: 58px;
}
.quick-entry-grid,
.compact-inner-form {
grid-template-columns: 1fr;
}
.drawer-menu {
grid-template-columns: 1fr;
max-height: min(32vh, 260px);
}
.summary-card,
.stat {
min-height: 74px;
}
}