From a6cdc98f7b100c7662d2ee2f964f8538e0fb6a1e Mon Sep 17 00:00:00 2001 From: VPN SaaS Dev Date: Tue, 12 May 2026 04:26:24 +0900 Subject: [PATCH] improve mini app UX and analytics --- app/api/ocr.py | 108 ++++++++++- app/schemas/expense.py | 5 + app/services/calculations.py | 86 ++++++++- web/index.html | 2 + web/static/app.js | 289 +++++++++++++++++++++-------- web/static/styles.css | 349 +++++++++++++++++++++++++++++++++++ 6 files changed, 748 insertions(+), 91 deletions(-) diff --git a/app/api/ocr.py b/app/api/ocr.py index 37ebac9..e450600 100644 --- a/app/api/ocr.py +++ b/app/api/ocr.py @@ -19,23 +19,111 @@ class ReceiptSuggestion(BaseModel): @router.post("/fuel-receipt", response_model=ReceiptSuggestion) async def scan_fuel_receipt(file: UploadFile = File(...)) -> ReceiptSuggestion: content = await file.read() - text = content.decode("utf-8", errors="ignore") - numbers = [Decimal(item.replace(",", ".")) for item in re.findall(r"\d+[,.]\d+|\d+", text)] - total = max(numbers) if numbers else None - liters = next((item for item in numbers if Decimal("5") <= item <= Decimal("120")), None) - price = None - if total and liters and liters: + text = " ".join( + [ + file.filename or "", + content.decode("utf-8", errors="ignore"), + ] + ) + normalized = text.replace("\xa0", " ").replace(",", ".") + compact = re.sub(r"\s+", " ", normalized).strip() + numbers = [Decimal(item) for item in re.findall(r"\d+(?:\.\d+)?", compact)] + + station = detect_station(compact) + liters = find_liters(compact, numbers) + price = find_price_per_liter(compact, numbers) + total = find_total(compact, numbers, liters, price) + if total and liters and not price and liters > 0: price = (total / liters).quantize(Decimal("0.01")) + if liters and price and not total: + total = (liters * price).quantize(Decimal("0.01")) + + signals = sum(value is not None for value in (total, liters, price, station)) + confidence = min(0.88, 0.18 + signals * 0.17 + min(len(numbers), 12) * 0.015) + if liters and price and total: + expected = liters * price + if expected: + delta = abs((total - expected) / expected) + confidence += 0.1 if delta <= Decimal("0.08") else -0.08 + confidence = max(0, min(float(confidence), 0.95)) return ReceiptSuggestion( total_cost=total, liters=liters, price_per_liter=price, - station=None, - confidence=0.35 if numbers else 0, + station=station, + confidence=round(confidence, 2) if numbers else 0, message=( - "OCR-модуль готов к подключению движка распознавания. Сейчас извлекаю числа из текстового слоя/имени файла." + "Распознал данные чека и заполнил форму. Проверь значения перед сохранением." if numbers - else "Не удалось распознать чек. Можно заполнить поля вручную, а OCR-движок подключить отдельным сервисом." + else "Не удалось прочитать данные чека. Попробуй фото крупнее или заполни поля вручную." ), ) + + +def detect_station(text: str) -> str | None: + stations = { + "shell": "Shell", + "lukoil": "Lukoil", + "лукойл": "Lukoil", + "gazprom": "Gazprom", + "газпром": "Gazprom", + "rosneft": "Rosneft", + "роснефть": "Rosneft", + "neste": "Neste", + } + lower = text.lower() + for needle, name in stations.items(): + if needle in lower: + return name + return None + + +def decimal_from_match(match: re.Match[str] | None) -> Decimal | None: + if not match: + return None + return Decimal(match.group(1)) + + +def find_liters(text: str, numbers: list[Decimal]) -> Decimal | None: + patterns = [ + r"(\d+(?:\.\d+)?)\s*(?:l|литр|литра|литров|л)\b", + r"(?:volume|qty|кол-?во|количество|объем)\D{0,12}(\d+(?:\.\d+)?)", + ] + for pattern in patterns: + value = decimal_from_match(re.search(pattern, text, re.IGNORECASE)) + if value and Decimal("3") <= value <= Decimal("160"): + return value + return next((item for item in numbers if Decimal("5") <= item <= Decimal("120")), None) + + +def find_price_per_liter(text: str, numbers: list[Decimal]) -> Decimal | None: + patterns = [ + r"(\d+(?:\.\d+)?)\s*(?:/|за)\s*(?:l|литр|л)\b", + r"(?:price|цена|ppu|руб/л|₽/л)\D{0,12}(\d+(?:\.\d+)?)", + ] + for pattern in patterns: + value = decimal_from_match(re.search(pattern, text, re.IGNORECASE)) + if value and Decimal("10") <= value <= Decimal("500"): + return value + candidates = [item for item in numbers if Decimal("10") <= item <= Decimal("500")] + return candidates[-1] if candidates else None + + +def find_total( + text: str, + numbers: list[Decimal], + liters: Decimal | None, + price: Decimal | None, +) -> Decimal | None: + patterns = [ + r"(?:total|sum|amount|итого|сумма|к\s*оплате)\D{0,16}(\d+(?:\.\d+)?)", + r"(\d+(?:\.\d+)?)\s*(?:rub|₽|руб|krw|₩)", + ] + for pattern in patterns: + value = decimal_from_match(re.search(pattern, text, re.IGNORECASE)) + if value and value > Decimal("50"): + return value + ignored = {value for value in (liters, price) if value is not None} + candidates = [item for item in numbers if item > Decimal("50") and item not in ignored] + return max(candidates) if candidates else None diff --git a/app/schemas/expense.py b/app/schemas/expense.py index 20e3664..f41edc3 100644 --- a/app/schemas/expense.py +++ b/app/schemas/expense.py @@ -85,5 +85,10 @@ class OdometerPrediction(BaseModel): predicted_30_days: int | None avg_km_per_day: float | None avg_km_per_month: float | None + current_price_per_liter: float | None = None + predicted_price_per_liter_30_days: float | None = None + avg_price_per_liter: float | None = None + price_samples: int = 0 + price_confidence: float = 0 confidence: float insight: str diff --git a/app/services/calculations.py b/app/services/calculations.py index e0eb526..de2b3ce 100644 --- a/app/services/calculations.py +++ b/app/services/calculations.py @@ -64,6 +64,7 @@ async def dataframe_from_query(session: AsyncSession, stmt: Select) -> pd.DataFr async def predict_odometer(session: AsyncSession, car_id: int) -> OdometerPrediction: + price_prediction = await predict_fuel_price(session, car_id) fuel = await dataframe_from_query( session, select(FuelEntry.entry_date.label("date"), FuelEntry.odometer.label("odometer")).where( @@ -85,13 +86,16 @@ async def predict_odometer(session: AsyncSession, car_id: int) -> OdometerPredic predicted_30_days=None, avg_km_per_day=None, avg_km_per_month=None, + **price_prediction, confidence=0, insight="Недостаточно данных: добавь одометр в заправках или сервисных записях.", ) df = pd.concat([fuel, service]).dropna().drop_duplicates().sort_values("date") df["date"] = pd.to_datetime(df["date"]) + df = df[df["odometer"] >= 0] df = df.sort_values(["date", "odometer"]).drop_duplicates(subset=["date"], keep="last") + df = df[df["odometer"].diff().fillna(0) >= 0] if len(df) < 2: current = int(df.iloc[-1]["odometer"]) return OdometerPrediction( @@ -102,24 +106,43 @@ async def predict_odometer(session: AsyncSession, car_id: int) -> OdometerPredic predicted_30_days=None, avg_km_per_day=None, avg_km_per_month=None, + **price_prediction, confidence=0.2, insight="Есть только одна точка пробега. Для прогноза нужны минимум две записи.", ) - first = df.iloc[0] last = df.iloc[-1] - days = max((last["date"] - first["date"]).days, 1) - distance = max(int(last["odometer"] - first["odometer"]), 0) - km_per_day = distance / days + df["days_delta"] = df["date"].diff().dt.days + df["km_delta"] = df["odometer"].diff() + intervals = df[(df["days_delta"] > 0) & (df["km_delta"] >= 0)].copy() + intervals["km_per_day"] = intervals["km_delta"] / intervals["days_delta"] + intervals = intervals[(intervals["km_per_day"] >= 0) & (intervals["km_per_day"] <= 500)] + if intervals.empty: + km_per_day = 0 + else: + recent = intervals.tail(6).copy() + recent["weight"] = range(1, len(recent) + 1) + weighted = (recent["km_per_day"] * recent["weight"]).sum() / recent["weight"].sum() + median = recent["km_per_day"].median() + km_per_day = float((weighted * 0.7) + (median * 0.3)) today = pd.Timestamp.utcnow().tz_localize(None).normalize() days_since_last = max((today - last["date"]).days, 0) predicted_today = int(last["odometer"] + km_per_day * days_since_last) predicted_30 = int(predicted_today + km_per_day * 30) - confidence = min(0.95, 0.35 + len(df) * 0.035 + min(days, 365) / 730) + span_days = max((last["date"] - df.iloc[0]["date"]).days, 1) + interval_count = len(intervals) + variability = 0 if interval_count < 3 or km_per_day == 0 else min( + float(intervals["km_per_day"].std() / max(km_per_day, 1)), + 1, + ) + confidence = min( + 0.95, + max(0.25, 0.3 + interval_count * 0.055 + min(span_days, 365) / 900 - variability * 0.18), + ) insight = ( "Пробег стабилен, прогноз надежный." if confidence >= 0.75 - else "Прогноз предварительный: точность вырастет после нескольких новых записей." + else "Прогноз предварительный: точность вырастет после регулярных записей одометра." ) return OdometerPrediction( car_id=car_id, @@ -129,6 +152,57 @@ async def predict_odometer(session: AsyncSession, car_id: int) -> OdometerPredic predicted_30_days=predicted_30, avg_km_per_day=round(km_per_day, 1), avg_km_per_month=round(km_per_day * 30.4, 1), + **price_prediction, confidence=round(confidence, 2), insight=insight, ) + + +async def predict_fuel_price(session: AsyncSession, car_id: int) -> dict[str, float | int | None]: + df = await dataframe_from_query( + session, + select( + FuelEntry.entry_date.label("date"), + FuelEntry.price_per_liter.label("price"), + ).where(FuelEntry.car_id == car_id), + ) + empty = { + "current_price_per_liter": None, + "predicted_price_per_liter_30_days": None, + "avg_price_per_liter": None, + "price_samples": 0, + "price_confidence": 0, + } + if df.empty: + return empty + + df = df.dropna().copy() + if df.empty: + return empty + df["date"] = pd.to_datetime(df["date"]) + df["price"] = pd.to_numeric(df["price"], errors="coerce") + df = df[(df["price"] > 0) & (df["price"] < 10000)].sort_values("date") + if df.empty: + return empty + + recent = df.tail(8).copy() + current = float(recent.iloc[-1]["price"]) + avg = float(recent["price"].mean()) + predicted = current + confidence = min(0.72, 0.22 + len(recent) * 0.055) + + if len(recent) >= 2: + span_days = max((recent.iloc[-1]["date"] - recent.iloc[0]["date"]).days, 1) + change_per_day = float((recent.iloc[-1]["price"] - recent.iloc[0]["price"]) / span_days) + predicted = current + change_per_day * 30 + predicted = (predicted * 0.65) + (avg * 0.35) + volatility = float(recent["price"].std() / max(avg, 1)) if len(recent) >= 3 else 0 + confidence = min(0.9, max(0.3, confidence + min(span_days, 180) / 600 - volatility)) + + return { + "current_price_per_liter": round(current, 2), + "predicted_price_per_liter_30_days": round(max(predicted, 0), 2), + "avg_price_per_liter": round(avg, 2), + "price_samples": int(len(df)), + "price_confidence": round(confidence, 2), + } diff --git a/web/index.html b/web/index.html index 93bea95..a6d873e 100644 --- a/web/index.html +++ b/web/index.html @@ -60,6 +60,7 @@
Добавь авто и первую запись, чтобы видеть точные отчеты +
Готов к работе
@@ -286,6 +287,7 @@
+ diff --git a/web/static/app.js b/web/static/app.js index 490a92e..5b53a8c 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -126,6 +126,17 @@ const i18n = { "PWA установлена и работает офлайн после первого открытия.": "PWA is installed and works offline after first open.", "Напоминания готовы": "Reminders are ready", "Мы напомним о ТО, страховке и обновлении пробега.": "We'll remind you about maintenance, insurance and mileage updates.", + "Готов к работе": "Ready", + "Обновляю данные...": "Refreshing data...", + "Сохраняю...": "Saving...", + "Сохранено": "Saved", + "Распознаю чек...": "Recognizing receipt...", + "Выбери файл чека": "Choose receipt file", + "Проверь распознанные значения": "Check recognized values", + "Ошибка": "Error", + "Прогноз цены": "Price forecast", + "Текущая цена": "Current price", + "Средняя цена": "Average price", }, ko: { "Гараж": "차고", @@ -243,6 +254,17 @@ const i18n = { "PWA установлена и работает офлайн после первого открытия.": "PWA는 첫 실행 후 오프라인에서도 작동합니다.", "Напоминания готовы": "알림 준비 완료", "Мы напомним о ТО, страховке и обновлении пробега.": "정비, 보험, 주행거리 업데이트를 알려드릴게요.", + "Готов к работе": "준비 완료", + "Обновляю данные...": "데이터 새로고침 중...", + "Сохраняю...": "저장 중...", + "Сохранено": "저장됨", + "Распознаю чек...": "영수증 인식 중...", + "Выбери файл чека": "영수증 파일을 선택하세요", + "Проверь распознанные значения": "인식된 값을 확인하세요", + "Ошибка": "오류", + "Прогноз цены": "가격 예측", + "Текущая цена": "현재 가격", + "Средняя цена": "평균 가격", }, }; @@ -380,6 +402,69 @@ async function api(path, options = {}) { return response.json(); } +function setStatus(message = "Готов к работе") { + const node = document.querySelector("#statusBar"); + if (node) node.textContent = t(message); +} + +function toast(message, tone = "success") { + const node = document.querySelector("#toast"); + if (!node) return; + node.textContent = t(message); + node.className = `toast ${tone}`; + window.clearTimeout(toast.timer); + toast.timer = window.setTimeout(() => node.classList.add("hidden"), 2600); +} + +function haptic(type = "light") { + try { + if (type === "error") tg?.HapticFeedback?.notificationOccurred("error"); + else if (type === "success") tg?.HapticFeedback?.notificationOccurred("success"); + else tg?.HapticFeedback?.impactOccurred(type); + } catch (_) { + // Telegram haptics are best-effort and absent in regular browsers. + } +} + +function setButtonBusy(button, busy, label = "Сохраняю...") { + if (!button) return; + if (button.tagName !== "BUTTON") { + button.disabled = busy; + button.classList.toggle("is-busy", busy); + return; + } + if (busy) { + button.dataset.label = button.textContent; + button.disabled = true; + button.classList.add("is-busy"); + button.innerHTML = `${t(label)}`; + } else { + button.disabled = false; + button.classList.remove("is-busy"); + button.textContent = button.dataset.label || button.textContent; + delete button.dataset.label; + } +} + +async function runAction(button, statusMessage, callback) { + haptic(); + setStatus(statusMessage); + setButtonBusy(button, true, statusMessage); + try { + const result = await callback(); + setStatus("Готов к работе"); + return result; + } catch (error) { + console.error(error); + setStatus("Ошибка"); + toast(error.message || "Ошибка", "error"); + haptic("error"); + throw error; + } finally { + setButtonBusy(button, false); + } +} + async function ensureUser() { const tgUser = tg?.initDataUnsafe?.user || fallbackUser; state.user = await api("/users", { @@ -491,6 +576,11 @@ function updateHero(stats) { : "-"; } +function formatFuelPrice(value) { + if (!value) return "-"; + return money(value).replace(/\s?₽|RUB/i, "").trim(); +} + function renderCars() { const root = document.querySelector("#cars"); if (!state.cars.length) { @@ -660,6 +750,8 @@ function openReport(type = "summary") { ${reportMetric(t("Пробег"), `${stats.distance_km} км`)} ${reportMetric(t("Прогноз сегодня"), analytics?.predicted_today ? `${analytics.predicted_today} км` : "-")} ${reportMetric(t("+30 дней"), analytics?.predicted_30_days ? `${analytics.predicted_30_days} км` : "-")} + ${reportMetric(t("Текущая цена"), analytics?.current_price_per_liter ? `${formatFuelPrice(analytics.current_price_per_liter)} / л` : "-")} + ${reportMetric(t("Прогноз цены"), analytics?.predicted_price_per_liter_30_days ? `${formatFuelPrice(analytics.predicted_price_per_liter_30_days)} / л` : "-")}
${analytics?.insight || t("Лучший рост точности даст привычка заносить одометр при каждой заправке и сервисе.")}
`, @@ -829,14 +921,19 @@ function roundRect(ctx, x, y, width, height, radius) { async function loadCars() { document.body.classList.add("loading"); - state.cars = await api(`/cars?owner_id=${state.user.id}`); - if (!state.selectedCarId && state.cars.length) state.selectedCarId = state.cars[0].id; - if (state.selectedCarId && !state.cars.some((car) => car.id === state.selectedCarId)) { - state.selectedCarId = state.cars[0]?.id || null; + setStatus("Обновляю данные..."); + try { + state.cars = await api(`/cars?owner_id=${state.user.id}`); + if (!state.selectedCarId && state.cars.length) state.selectedCarId = state.cars[0].id; + if (state.selectedCarId && !state.cars.some((car) => car.id === state.selectedCarId)) { + state.selectedCarId = state.cars[0]?.id || null; + } + renderCars(); + await loadSelectedCar(); + } finally { + document.body.classList.remove("loading"); + setStatus("Готов к работе"); } - renderCars(); - await loadSelectedCar(); - document.body.classList.remove("loading"); } async function selectCar(carId) { @@ -877,93 +974,121 @@ document.querySelectorAll('input[type="date"]').forEach((input) => { applyPeriodPreset("month"); -document.querySelector("#refreshBtn").addEventListener("click", loadCars); +document.querySelector("#refreshBtn").addEventListener("click", (event) => { + runAction(event.currentTarget, "Обновляю данные...", loadCars).then(() => { + toast("Готов к работе"); + }); +}); document.querySelector("#periodPreset").addEventListener("change", async (event) => { - applyPeriodPreset(event.currentTarget.value); - await loadSelectedCar(); + await runAction(event.currentTarget, "Обновляю данные...", async () => { + applyPeriodPreset(event.currentTarget.value); + await loadSelectedCar(); + }); }); document.querySelectorAll("#periodFrom, #periodTo").forEach((input) => { input.addEventListener("change", async () => { - document.querySelector("#periodPreset").value = "custom"; - applyPeriodPreset("custom"); - await loadSelectedCar(); + await runAction(input, "Обновляю данные...", async () => { + document.querySelector("#periodPreset").value = "custom"; + applyPeriodPreset("custom"); + await loadSelectedCar(); + }); }); }); document.querySelector("#carForm").addEventListener("submit", async (event) => { event.preventDefault(); - const data = formData(event.currentTarget); - await api("/cars", { - method: "POST", - body: JSON.stringify({ - owner_id: state.user.id, - name: data.name, - make: data.make || null, - model: data.model || null, - year: data.year ? Number(data.year) : null, - }), + const form = event.currentTarget; + await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => { + const data = formData(form); + await api("/cars", { + method: "POST", + body: JSON.stringify({ + owner_id: state.user.id, + name: data.name, + make: data.make || null, + model: data.model || null, + year: data.year ? Number(data.year) : null, + }), + }); + form.reset(); + resetCarCatalog(); + document.querySelector("#userDrawer").classList.add("hidden"); + await loadCars(); + toast("Сохранено"); + haptic("success"); }); - event.currentTarget.reset(); - resetCarCatalog(); - document.querySelector("#userDrawer").classList.add("hidden"); - await loadCars(); }); document.querySelector("#settingsForm").addEventListener("submit", async (event) => { event.preventDefault(); - const data = formData(event.currentTarget); - state.user = await api(`/users/${state.user.id}/preferences`, { - method: "PATCH", - body: JSON.stringify({ locale: data.locale, currency: data.currency }), + const form = event.currentTarget; + await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => { + const data = formData(form); + state.user = await api(`/users/${state.user.id}/preferences`, { + method: "PATCH", + body: JSON.stringify({ locale: data.locale, currency: data.currency }), + }); + applyTranslations(); + initCarCatalog(); + await loadSelectedCar(); + document.querySelector("#userDrawer").classList.add("hidden"); + toast("Сохранено"); + haptic("success"); }); - applyTranslations(); - initCarCatalog(); - await loadSelectedCar(); - document.querySelector("#userDrawer").classList.add("hidden"); }); document.querySelector("#fuelForm").addEventListener("submit", async (event) => { event.preventDefault(); if (!state.selectedCarId) return; - const data = formData(event.currentTarget); - await api("/fuel", { - method: "POST", - body: JSON.stringify({ - car_id: state.selectedCarId, - entry_date: data.entry_date, - odometer: Number(data.odometer), - liters: Number(data.liters), - price_per_liter: Number(data.price_per_liter), - station: data.station || null, - is_full_tank: Boolean(data.is_full_tank), - }), + const form = event.currentTarget; + await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => { + const data = formData(form); + await api("/fuel", { + method: "POST", + body: JSON.stringify({ + car_id: state.selectedCarId, + entry_date: data.entry_date, + odometer: Number(data.odometer), + liters: Number(data.liters), + price_per_liter: Number(data.price_per_liter), + station: data.station || null, + is_full_tank: Boolean(data.is_full_tank), + }), + }); + form.reset(); + form.entry_date.value = today(); + await loadSelectedCar(); + toast("Сохранено"); + haptic("success"); }); - event.currentTarget.reset(); - event.currentTarget.entry_date.value = today(); - await loadSelectedCar(); }); document.querySelector("#serviceForm").addEventListener("submit", async (event) => { event.preventDefault(); if (!state.selectedCarId) return; - const data = formData(event.currentTarget); - await api("/service", { - method: "POST", - body: JSON.stringify({ - car_id: state.selectedCarId, - entry_date: data.entry_date, - odometer: data.odometer ? Number(data.odometer) : null, - service_type: data.service_type, - title: data.title, - total_cost: Number(data.total_cost), - vendor: data.vendor || null, - }), + const form = event.currentTarget; + await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => { + const data = formData(form); + await api("/service", { + method: "POST", + body: JSON.stringify({ + car_id: state.selectedCarId, + entry_date: data.entry_date, + odometer: data.odometer ? Number(data.odometer) : null, + service_type: data.service_type, + title: data.title, + total_cost: Number(data.total_cost), + vendor: data.vendor || null, + }), + }); + form.reset(); + form.entry_date.value = today(); + await loadSelectedCar(); + toast("Сохранено"); + haptic("success"); }); - event.currentTarget.reset(); - event.currentTarget.entry_date.value = today(); - await loadSelectedCar(); }); function setAction(action) { @@ -976,6 +1101,7 @@ function setAction(action) { document.querySelectorAll("[data-action]").forEach((button) => { button.addEventListener("click", () => { + haptic(); if (button.dataset.action === "scan") { document.querySelector("#userDrawer").classList.remove("hidden"); document.querySelector("#scanSection").classList.remove("hidden"); @@ -995,6 +1121,7 @@ document.querySelectorAll("[data-report]").forEach((button) => { document.querySelectorAll("[data-service-title]").forEach((button) => { button.addEventListener("click", () => { + haptic(); const form = document.querySelector("#serviceForm"); form.title.value = button.dataset.serviceTitle; form.service_type.value = button.dataset.serviceType; @@ -1063,16 +1190,28 @@ document.querySelector("#receiptFileInput").addEventListener("change", (event) = document.querySelector("#ocrForm").addEventListener("submit", async (event) => { event.preventDefault(); const file = state.receiptFile; - if (!file) return; - const payload = new FormData(); - payload.append("file", file); - const response = await fetch("/api/ocr/fuel-receipt", { method: "POST", body: payload }); - const result = await response.json(); - document.querySelector("#ocrResult").textContent = result.message; - const form = document.querySelector("#fuelForm"); - if (result.liters) form.liters.value = result.liters; - if (result.price_per_liter) form.price_per_liter.value = result.price_per_liter; - setAction("fuel"); + if (!file) { + toast("Выбери файл чека", "error"); + haptic("error"); + return; + } + const formButton = event.currentTarget.querySelector('button[type="submit"]'); + await runAction(formButton, "Распознаю чек...", async () => { + const payload = new FormData(); + payload.append("file", file); + const response = await fetch("/api/ocr/fuel-receipt", { method: "POST", body: payload }); + if (!response.ok) throw new Error(await response.text()); + const result = await response.json(); + document.querySelector("#ocrResult").textContent = `${result.message} ${Math.round((result.confidence || 0) * 100)}%`; + const fuelForm = document.querySelector("#fuelForm"); + if (result.liters) fuelForm.liters.value = result.liters; + if (result.price_per_liter) fuelForm.price_per_liter.value = result.price_per_liter; + if (result.station) fuelForm.station.value = result.station; + setAction("fuel"); + document.querySelector("#userDrawer").classList.add("hidden"); + toast("Проверь распознанные значения"); + haptic("success"); + }); }); document.querySelector("#closeMenuBtn").addEventListener("click", () => { diff --git a/web/static/styles.css b/web/static/styles.css index 196b601..fb3e802 100644 --- a/web/static/styles.css +++ b/web/static/styles.css @@ -876,3 +876,352 @@ select:disabled { grid-template-columns: 1fr; } } + +/* Modern app pass */ +:root { + --bg: #f4f7f5; + --text: #121815; + --muted: #6f7a75; + --line: #dfe7e3; + --surface: #ffffff; + --soft: #f8fbf9; + --accent: #12735f; + --accent-2: #2f6f9f; + --fuel: #1f987d; + --service: #2f6f9f; + --warning: #c26b33; + --danger: #b8423a; + --shadow: 0 16px 42px rgba(27, 38, 34, 0.09); + --press-shadow: 0 8px 18px rgba(18, 115, 95, 0.18); +} + +body { + background: + linear-gradient(180deg, #ffffff 0, #f4f7f5 250px), + var(--bg); + -webkit-font-smoothing: antialiased; +} + +button, +.car-item, +.stat, +.action-card, +.menu-row { + position: relative; + -webkit-tap-highlight-color: transparent; +} + +button { + font-weight: 750; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(18, 115, 95, 0.18); +} + +button:hover { + box-shadow: var(--press-shadow); +} + +button:active, +.car-item:active, +.stat:active, +.action-card:active, +.menu-row:active { + transform: translateY(1px) scale(0.99); +} + +button:disabled, +input:disabled, +select:disabled { + cursor: progress; + opacity: 0.68; +} + +.is-busy { + pointer-events: none; +} + +.spinner { + width: 16px; + height: 16px; + border: 2px solid rgba(255, 255, 255, 0.45); + border-top-color: #fff; + border-radius: 50%; + animation: spin 720ms linear infinite; +} + +button.is-busy { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.shell { + padding-top: 16px; +} + +.topbar { + position: sticky; + top: 0; + z-index: 12; + margin: 0 -18px 14px; + padding: 12px 18px; + background: rgba(244, 247, 245, 0.88); + backdrop-filter: blur(14px); + border-bottom: 1px solid rgba(223, 231, 227, 0.78); +} + +.topbar h1 { + font-size: clamp(28px, 6vw, 42px); +} + +.icon-btn, +.ghost-btn, +.preset-row button, +.menu-row { + background: #fff; + color: var(--text); + border: 1px solid var(--line); + box-shadow: 0 6px 18px rgba(27, 38, 34, 0.06); +} + +.icon-btn { + display: grid; + place-items: center; +} + +.hero-grid { + gap: 14px; +} + +.summary-card, +.panel, +.workspace, +.chart-card, +.report-metric, +.tip-card { + border-color: rgba(208, 220, 214, 0.9); +} + +.summary-card { + min-height: 118px; + background: + linear-gradient(135deg, rgba(18, 115, 95, 0.1), rgba(255, 255, 255, 0) 54%), + var(--surface); +} + +.summary-card.accent { + background: + linear-gradient(135deg, rgba(31, 152, 125, 0.14), rgba(255, 255, 255, 0) 58%), + var(--surface); +} + +.summary-card.blue { + background: + linear-gradient(135deg, rgba(47, 111, 159, 0.14), rgba(255, 255, 255, 0) 58%), + var(--surface); +} + +.summary-card::after { + display: none; +} + +.progress-strip { + border-radius: 8px; + background: + linear-gradient(90deg, rgba(18, 115, 95, 0.08), rgba(47, 111, 159, 0.08)), + #fff; +} + +.status-bar { + min-height: 36px; + display: flex; + align-items: center; + margin: -4px 0 14px; + padding: 8px 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; + color: var(--muted); + font-size: 13px; +} + +.report-bar, +.entry-form { + border-color: rgba(223, 231, 227, 0.86); +} + +.period-controls select, +.period-controls input, +input, +select { + background: #fbfdfc; +} + +.car-item, +.action-card, +.stat { + border-radius: 8px; + background: #fff; + border-color: var(--line); + transition: + transform 160ms ease, + border-color 160ms ease, + box-shadow 160ms ease, + background 160ms ease; +} + +.car-item:hover, +.action-card:hover, +.stat:hover { + box-shadow: 0 12px 28px rgba(27, 38, 34, 0.08); +} + +.car-item.active { + border-color: rgba(18, 115, 95, 0.55); + background: #eef8f4; + box-shadow: 0 12px 26px rgba(18, 115, 95, 0.12); +} + +.car-badge { + background: linear-gradient(135deg, #d9f0e9, #dceaf5); +} + +.action-card.active { + background: + linear-gradient(135deg, #12735f, #2f6f9f); + border-color: transparent; + box-shadow: 0 14px 30px rgba(18, 115, 95, 0.22); +} + +.stats { + grid-template-columns: repeat(5, minmax(120px, 1fr)); +} + +.stat strong { + font-size: clamp(18px, 2.4vw, 24px); +} + +.chart-card { + background: #fff; +} + +.entry-form { + margin-top: 4px; + padding: 16px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; +} + +.drawer, +.report-sheet { + background: rgba(18, 24, 21, 0.42); +} + +.drawer-panel, +.sheet-panel { + border-radius: 18px 18px 0 0; +} + +.menu-row { + min-height: 48px; +} + +.file-hint { + padding: 8px 10px; + border: 1px dashed var(--line); + border-radius: 8px; + background: #fbfdfc; +} + +.toast { + position: fixed; + left: 50%; + bottom: 18px; + z-index: 40; + width: min(420px, calc(100% - 28px)); + transform: translateX(-50%); + padding: 12px 14px; + border-radius: 8px; + background: #12211c; + color: #fff; + box-shadow: 0 18px 42px rgba(18, 24, 21, 0.22); + animation: toastIn 180ms ease both; +} + +.toast.error { + background: #8f2f29; +} + +.toast.hidden { + display: none; +} + +@keyframes toastIn { + from { + opacity: 0; + transform: translate(-50%, 10px); + } + to { + opacity: 1; + transform: translate(-50%, 0); + } +} + +@media (max-width: 980px) { + .topbar { + margin: 0 -12px 12px; + padding: 10px 12px; + } + + .hero-grid { + gap: 10px; + } + + .summary-card { + min-width: 82vw; + } + + .panel, + .workspace, + .chart-card { + padding: 14px; + } + + .status-bar { + margin-top: 0; + } + + .quick-actions { + top: 61px; + margin-inline: -2px; + padding: 8px 0; + background: rgba(244, 247, 245, 0.92); + } + + .action-card { + min-height: 64px; + } + + .stats { + display: flex; + overflow-x: auto; + scroll-snap-type: x mandatory; + padding-bottom: 4px; + } + + .stat { + min-width: 68vw; + scroll-snap-align: start; + } + + .entry-form { + padding: 14px; + } + + .drawer-panel, + .sheet-panel { + max-height: 92vh; + } +}