compact vehicle card passport
Some checks failed
ci / test (push) Has been cancelled

This commit is contained in:
VPN SaaS Dev
2026-05-16 22:04:00 +09:00
parent c98432ca7d
commit 8ab296b675
3 changed files with 145 additions and 26 deletions

View File

@@ -313,6 +313,9 @@ const state = {
selectedCarId: null,
latestFuel: [],
latestService: [],
latestFuelAllTime: [],
latestServiceAllTime: [],
selectedCarHighlights: null,
latestExpenses: [],
latestStats: null,
allStats: null,
@@ -329,7 +332,6 @@ const state = {
connectedServices: [],
adminPendingServices: [],
vehicleScore: null,
vehicleTimeline: [],
achievements: [],
receiptFile: null,
serviceWorkerRegistration: null,
@@ -901,10 +903,11 @@ function renderCars() {
.map(
(car) => `
<button class="car-item ${car.id === state.selectedCarId ? "active" : ""}" data-car="${car.id}">
<span class="car-badge">${(car.make || car.name).slice(0, 2).toUpperCase()}</span>
<span class="car-badge">${escapeHtml((car.make || car.name).slice(0, 2).toUpperCase())}</span>
<span class="car-copy">
<strong>${car.name}</strong>
<small>${[car.make, car.model, car.trim, car.year, car.fuel_type].filter(Boolean).join(" ") || t("Без деталей")}</small>
<strong>${escapeHtml(car.name)}</strong>
<small>${escapeHtml([car.make, car.model, car.trim, car.year, car.fuel_type].filter(Boolean).join(" ") || t("Без деталей"))}</small>
${car.id === state.selectedCarId ? renderSelectedCarHighlights(car) : ""}
</span>
</button>
`,
@@ -917,6 +920,84 @@ function renderCars() {
});
}
function renderSelectedCarHighlights(car) {
const highlights = state.selectedCarHighlights?.carId === car.id ? state.selectedCarHighlights : buildCarHighlights(car, [], []);
const rows = [
["Одометр", highlights.odometer],
["Заправка", highlights.lastFuel],
["Масло", highlights.lastOil],
["До масла", highlights.oilRemaining],
];
return `
<span class="car-key-params">
${rows
.map(
([label, value]) => `
<span class="car-key-param">
<span>${label}</span>
<strong>${escapeHtml(value)}</strong>
</span>
`,
)
.join("")}
</span>
`;
}
function buildCarHighlights(car, fuelEntries, serviceEntries) {
const currentOdometer = Number(car?.current_odometer || 0);
const lastFuel = fuelEntries[0] || null;
const lastOil = serviceEntries.find(isOilService) || null;
const oilDueOdometer = oilDueKm(car, lastOil);
const oilRemainingKm = oilDueOdometer != null && car?.current_odometer != null
? oilDueOdometer - currentOdometer
: null;
return {
carId: car?.id,
odometer: car?.current_odometer != null ? `${formatKm(car.current_odometer)}` : "-",
lastFuel: lastFuel ? `${formatShortDate(lastFuel.entry_date)} · ${formatLiters(lastFuel.liters)}` : "-",
lastOil: lastOil ? formatShortDate(lastOil.entry_date) : "-",
oilRemaining: formatOilRemaining(oilRemainingKm),
};
}
function isOilService(item) {
const text = [item?.title, item?.category, item?.notes, item?.service_type].filter(Boolean).join(" ").toLowerCase();
return /масл|oil|engine_oil/.test(text);
}
function oilDueKm(car, latestOil) {
if (latestOil?.next_due_odometer != null) return Number(latestOil.next_due_odometer);
const interval = Number(car?.oil_change_interval_km || 0);
if (!interval) return null;
if (latestOil?.odometer != null) return Number(latestOil.odometer) + interval;
if (car?.current_odometer != null) return Number(car.current_odometer) + interval;
return null;
}
function formatKm(value) {
if (value == null || value === "") return "-";
return `${Number(value).toLocaleString("ru-RU")} км`;
}
function formatLiters(value) {
if (value == null || value === "") return "-";
return `${Number(value).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} л`;
}
function formatShortDate(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value).slice(0, 10);
return date.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "2-digit" });
}
function formatOilRemaining(value) {
if (value == null || Number.isNaN(value)) return "-";
if (value < 0) return `просрочено ${formatKm(Math.abs(value))}`;
return formatKm(value);
}
function setInputValue(form, name, value) {
if (!form?.elements[name]) return;
const input = form.elements[name];
@@ -1902,11 +1983,9 @@ function updateScore() {
ring.style.background = `conic-gradient(#5ee0bd ${score * 3.6}deg, rgba(255,255,255,0.12) 0deg)`;
}
document.querySelector("#scoreHint").textContent = car
? "Качество паспорта растет от подтвержденных данных, сервисной истории и точного пробега."
? "Короткая сводка по полноте данных и готовности к обслуживанию."
: t("Добавь авто и первую запись, чтобы видеть точные отчеты");
renderScoreActions(state.vehicleScore?.missing_items || []);
renderAchievements();
renderVehicleTimeline();
}
function scoreLabel(quality, score) {
@@ -1938,9 +2017,9 @@ function healthLabel(status) {
function renderScoreActions(items) {
const root = document.querySelector("#scoreActions");
if (!root) return;
const visible = items.slice(0, 3);
const visible = items.slice(0, 1);
if (!visible.length) {
root.innerHTML = `<div class="passport-note">Паспорт выглядит надежно. Следующий рост даст подтвержденная сервисная история.</div>`;
root.innerHTML = `<div class="passport-note">Паспорт готов: ключевые данные, история и ТО собраны достаточно для пилота.</div>`;
return;
}
root.innerHTML = visible
@@ -2339,6 +2418,7 @@ async function applyInitialRoute() {
async function selectCar(carId) {
state.selectedCarId = carId;
state.selectedCarHighlights = null;
renderCars();
fillCarProfileForm();
await loadSelectedCar();
@@ -2348,17 +2428,19 @@ async function loadSelectedCar() {
if (!state.selectedCarId) {
state.latestFuel = [];
state.latestService = [];
state.latestFuelAllTime = [];
state.latestServiceAllTime = [];
state.selectedCarHighlights = null;
state.latestExpenses = [];
state.latestStats = null;
state.allStats = null;
state.analytics = null;
state.vehicleScore = null;
state.vehicleTimeline = [];
state.achievements = [];
renderStats(null);
return;
}
const [stats, allStats, fuel, service, expenses, analytics, vehicleScore] = await Promise.all([
const [stats, allStats, fuel, service, expenses, analytics, vehicleScore, allFuel, allService] = await Promise.all([
api(`/cars/${state.selectedCarId}/stats${periodQuery()}`),
api(`/cars/${state.selectedCarId}/stats${allPeriodQuery()}`),
api(`/cars/${state.selectedCarId}/fuel${periodQuery()}`),
@@ -2366,20 +2448,20 @@ async function loadSelectedCar() {
api(`/cars/${state.selectedCarId}/expenses${periodQuery()}`),
api(`/cars/${state.selectedCarId}/analytics`),
api(`/my/vehicles/${state.selectedCarId}/score`),
]);
const [timeline, achievements] = await Promise.all([
api(`/my/vehicles/${state.selectedCarId}/timeline?limit=30`),
api("/me/achievements"),
api(`/cars/${state.selectedCarId}/fuel?limit=1`),
api(`/cars/${state.selectedCarId}/service?limit=100`),
]);
state.latestStats = stats;
state.allStats = allStats;
state.latestFuel = fuel;
state.latestService = service;
state.latestFuelAllTime = allFuel;
state.latestServiceAllTime = allService;
state.latestExpenses = expenses;
state.analytics = analytics;
state.vehicleScore = vehicleScore;
state.vehicleTimeline = timeline;
state.achievements = achievements;
state.selectedCarHighlights = buildCarHighlights(selectedCar(), allFuel, allService);
renderCars();
renderStats(stats);
drawCharts(fuel, service, stats);
}