This commit is contained in:
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user