improve mini app UX and analytics
This commit is contained in:
108
app/api/ocr.py
108
app/api/ocr.py
@@ -19,23 +19,111 @@ class ReceiptSuggestion(BaseModel):
|
|||||||
@router.post("/fuel-receipt", response_model=ReceiptSuggestion)
|
@router.post("/fuel-receipt", response_model=ReceiptSuggestion)
|
||||||
async def scan_fuel_receipt(file: UploadFile = File(...)) -> ReceiptSuggestion:
|
async def scan_fuel_receipt(file: UploadFile = File(...)) -> ReceiptSuggestion:
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
text = content.decode("utf-8", errors="ignore")
|
text = " ".join(
|
||||||
numbers = [Decimal(item.replace(",", ".")) for item in re.findall(r"\d+[,.]\d+|\d+", text)]
|
[
|
||||||
total = max(numbers) if numbers else None
|
file.filename or "",
|
||||||
liters = next((item for item in numbers if Decimal("5") <= item <= Decimal("120")), None)
|
content.decode("utf-8", errors="ignore"),
|
||||||
price = None
|
]
|
||||||
if total and liters and liters:
|
)
|
||||||
|
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"))
|
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(
|
return ReceiptSuggestion(
|
||||||
total_cost=total,
|
total_cost=total,
|
||||||
liters=liters,
|
liters=liters,
|
||||||
price_per_liter=price,
|
price_per_liter=price,
|
||||||
station=None,
|
station=station,
|
||||||
confidence=0.35 if numbers else 0,
|
confidence=round(confidence, 2) if numbers else 0,
|
||||||
message=(
|
message=(
|
||||||
"OCR-модуль готов к подключению движка распознавания. Сейчас извлекаю числа из текстового слоя/имени файла."
|
"Распознал данные чека и заполнил форму. Проверь значения перед сохранением."
|
||||||
if numbers
|
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
|
||||||
|
|||||||
@@ -85,5 +85,10 @@ class OdometerPrediction(BaseModel):
|
|||||||
predicted_30_days: int | None
|
predicted_30_days: int | None
|
||||||
avg_km_per_day: float | None
|
avg_km_per_day: float | None
|
||||||
avg_km_per_month: 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
|
confidence: float
|
||||||
insight: str
|
insight: str
|
||||||
|
|||||||
@@ -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:
|
async def predict_odometer(session: AsyncSession, car_id: int) -> OdometerPrediction:
|
||||||
|
price_prediction = await predict_fuel_price(session, car_id)
|
||||||
fuel = await dataframe_from_query(
|
fuel = await dataframe_from_query(
|
||||||
session,
|
session,
|
||||||
select(FuelEntry.entry_date.label("date"), FuelEntry.odometer.label("odometer")).where(
|
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,
|
predicted_30_days=None,
|
||||||
avg_km_per_day=None,
|
avg_km_per_day=None,
|
||||||
avg_km_per_month=None,
|
avg_km_per_month=None,
|
||||||
|
**price_prediction,
|
||||||
confidence=0,
|
confidence=0,
|
||||||
insight="Недостаточно данных: добавь одометр в заправках или сервисных записях.",
|
insight="Недостаточно данных: добавь одометр в заправках или сервисных записях.",
|
||||||
)
|
)
|
||||||
|
|
||||||
df = pd.concat([fuel, service]).dropna().drop_duplicates().sort_values("date")
|
df = pd.concat([fuel, service]).dropna().drop_duplicates().sort_values("date")
|
||||||
df["date"] = pd.to_datetime(df["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.sort_values(["date", "odometer"]).drop_duplicates(subset=["date"], keep="last")
|
||||||
|
df = df[df["odometer"].diff().fillna(0) >= 0]
|
||||||
if len(df) < 2:
|
if len(df) < 2:
|
||||||
current = int(df.iloc[-1]["odometer"])
|
current = int(df.iloc[-1]["odometer"])
|
||||||
return OdometerPrediction(
|
return OdometerPrediction(
|
||||||
@@ -102,24 +106,43 @@ async def predict_odometer(session: AsyncSession, car_id: int) -> OdometerPredic
|
|||||||
predicted_30_days=None,
|
predicted_30_days=None,
|
||||||
avg_km_per_day=None,
|
avg_km_per_day=None,
|
||||||
avg_km_per_month=None,
|
avg_km_per_month=None,
|
||||||
|
**price_prediction,
|
||||||
confidence=0.2,
|
confidence=0.2,
|
||||||
insight="Есть только одна точка пробега. Для прогноза нужны минимум две записи.",
|
insight="Есть только одна точка пробега. Для прогноза нужны минимум две записи.",
|
||||||
)
|
)
|
||||||
|
|
||||||
first = df.iloc[0]
|
|
||||||
last = df.iloc[-1]
|
last = df.iloc[-1]
|
||||||
days = max((last["date"] - first["date"]).days, 1)
|
df["days_delta"] = df["date"].diff().dt.days
|
||||||
distance = max(int(last["odometer"] - first["odometer"]), 0)
|
df["km_delta"] = df["odometer"].diff()
|
||||||
km_per_day = distance / days
|
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()
|
today = pd.Timestamp.utcnow().tz_localize(None).normalize()
|
||||||
days_since_last = max((today - last["date"]).days, 0)
|
days_since_last = max((today - last["date"]).days, 0)
|
||||||
predicted_today = int(last["odometer"] + km_per_day * days_since_last)
|
predicted_today = int(last["odometer"] + km_per_day * days_since_last)
|
||||||
predicted_30 = int(predicted_today + km_per_day * 30)
|
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 = (
|
insight = (
|
||||||
"Пробег стабилен, прогноз надежный."
|
"Пробег стабилен, прогноз надежный."
|
||||||
if confidence >= 0.75
|
if confidence >= 0.75
|
||||||
else "Прогноз предварительный: точность вырастет после нескольких новых записей."
|
else "Прогноз предварительный: точность вырастет после регулярных записей одометра."
|
||||||
)
|
)
|
||||||
return OdometerPrediction(
|
return OdometerPrediction(
|
||||||
car_id=car_id,
|
car_id=car_id,
|
||||||
@@ -129,6 +152,57 @@ async def predict_odometer(session: AsyncSession, car_id: int) -> OdometerPredic
|
|||||||
predicted_30_days=predicted_30,
|
predicted_30_days=predicted_30,
|
||||||
avg_km_per_day=round(km_per_day, 1),
|
avg_km_per_day=round(km_per_day, 1),
|
||||||
avg_km_per_month=round(km_per_day * 30.4, 1),
|
avg_km_per_month=round(km_per_day * 30.4, 1),
|
||||||
|
**price_prediction,
|
||||||
confidence=round(confidence, 2),
|
confidence=round(confidence, 2),
|
||||||
insight=insight,
|
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),
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
<div class="progress-track"><span id="scoreBar"></span></div>
|
<div class="progress-track"><span id="scoreBar"></span></div>
|
||||||
<small id="scoreHint">Добавь авто и первую запись, чтобы видеть точные отчеты</small>
|
<small id="scoreHint">Добавь авто и первую запись, чтобы видеть точные отчеты</small>
|
||||||
</section>
|
</section>
|
||||||
|
<div class="status-bar" id="statusBar" aria-live="polite">Готов к работе</div>
|
||||||
|
|
||||||
<section class="report-bar">
|
<section class="report-bar">
|
||||||
<div>
|
<div>
|
||||||
@@ -286,6 +287,7 @@
|
|||||||
<div id="reportBody"></div>
|
<div id="reportBody"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="toast hidden" id="toast" role="status" aria-live="polite"></div>
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -126,6 +126,17 @@ const i18n = {
|
|||||||
"PWA установлена и работает офлайн после первого открытия.": "PWA is installed and works offline after first open.",
|
"PWA установлена и работает офлайн после первого открытия.": "PWA is installed and works offline after first open.",
|
||||||
"Напоминания готовы": "Reminders are ready",
|
"Напоминания готовы": "Reminders are ready",
|
||||||
"Мы напомним о ТО, страховке и обновлении пробега.": "We'll remind you about maintenance, insurance and mileage updates.",
|
"Мы напомним о ТО, страховке и обновлении пробега.": "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: {
|
ko: {
|
||||||
"Гараж": "차고",
|
"Гараж": "차고",
|
||||||
@@ -243,6 +254,17 @@ const i18n = {
|
|||||||
"PWA установлена и работает офлайн после первого открытия.": "PWA는 첫 실행 후 오프라인에서도 작동합니다.",
|
"PWA установлена и работает офлайн после первого открытия.": "PWA는 첫 실행 후 오프라인에서도 작동합니다.",
|
||||||
"Напоминания готовы": "알림 준비 완료",
|
"Напоминания готовы": "알림 준비 완료",
|
||||||
"Мы напомним о ТО, страховке и обновлении пробега.": "정비, 보험, 주행거리 업데이트를 알려드릴게요.",
|
"Мы напомним о ТО, страховке и обновлении пробега.": "정비, 보험, 주행거리 업데이트를 알려드릴게요.",
|
||||||
|
"Готов к работе": "준비 완료",
|
||||||
|
"Обновляю данные...": "데이터 새로고침 중...",
|
||||||
|
"Сохраняю...": "저장 중...",
|
||||||
|
"Сохранено": "저장됨",
|
||||||
|
"Распознаю чек...": "영수증 인식 중...",
|
||||||
|
"Выбери файл чека": "영수증 파일을 선택하세요",
|
||||||
|
"Проверь распознанные значения": "인식된 값을 확인하세요",
|
||||||
|
"Ошибка": "오류",
|
||||||
|
"Прогноз цены": "가격 예측",
|
||||||
|
"Текущая цена": "현재 가격",
|
||||||
|
"Средняя цена": "평균 가격",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -380,6 +402,69 @@ async function api(path, options = {}) {
|
|||||||
return response.json();
|
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 = `<span class="spinner"></span><span>${t(label)}</span>`;
|
||||||
|
} 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() {
|
async function ensureUser() {
|
||||||
const tgUser = tg?.initDataUnsafe?.user || fallbackUser;
|
const tgUser = tg?.initDataUnsafe?.user || fallbackUser;
|
||||||
state.user = await api("/users", {
|
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() {
|
function renderCars() {
|
||||||
const root = document.querySelector("#cars");
|
const root = document.querySelector("#cars");
|
||||||
if (!state.cars.length) {
|
if (!state.cars.length) {
|
||||||
@@ -660,6 +750,8 @@ function openReport(type = "summary") {
|
|||||||
${reportMetric(t("Пробег"), `${stats.distance_km} км`)}
|
${reportMetric(t("Пробег"), `${stats.distance_km} км`)}
|
||||||
${reportMetric(t("Прогноз сегодня"), analytics?.predicted_today ? `${analytics.predicted_today} км` : "-")}
|
${reportMetric(t("Прогноз сегодня"), analytics?.predicted_today ? `${analytics.predicted_today} км` : "-")}
|
||||||
${reportMetric(t("+30 дней"), analytics?.predicted_30_days ? `${analytics.predicted_30_days} км` : "-")}
|
${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)} / л` : "-")}
|
||||||
</div>
|
</div>
|
||||||
<div class="tip-card">${analytics?.insight || t("Лучший рост точности даст привычка заносить одометр при каждой заправке и сервисе.")}</div>
|
<div class="tip-card">${analytics?.insight || t("Лучший рост точности даст привычка заносить одометр при каждой заправке и сервисе.")}</div>
|
||||||
`,
|
`,
|
||||||
@@ -829,6 +921,8 @@ function roundRect(ctx, x, y, width, height, radius) {
|
|||||||
|
|
||||||
async function loadCars() {
|
async function loadCars() {
|
||||||
document.body.classList.add("loading");
|
document.body.classList.add("loading");
|
||||||
|
setStatus("Обновляю данные...");
|
||||||
|
try {
|
||||||
state.cars = await api(`/cars?owner_id=${state.user.id}`);
|
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.length) state.selectedCarId = state.cars[0].id;
|
||||||
if (state.selectedCarId && !state.cars.some((car) => car.id === state.selectedCarId)) {
|
if (state.selectedCarId && !state.cars.some((car) => car.id === state.selectedCarId)) {
|
||||||
@@ -836,7 +930,10 @@ async function loadCars() {
|
|||||||
}
|
}
|
||||||
renderCars();
|
renderCars();
|
||||||
await loadSelectedCar();
|
await loadSelectedCar();
|
||||||
|
} finally {
|
||||||
document.body.classList.remove("loading");
|
document.body.classList.remove("loading");
|
||||||
|
setStatus("Готов к работе");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectCar(carId) {
|
async function selectCar(carId) {
|
||||||
@@ -877,24 +974,34 @@ document.querySelectorAll('input[type="date"]').forEach((input) => {
|
|||||||
|
|
||||||
applyPeriodPreset("month");
|
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) => {
|
document.querySelector("#periodPreset").addEventListener("change", async (event) => {
|
||||||
|
await runAction(event.currentTarget, "Обновляю данные...", async () => {
|
||||||
applyPeriodPreset(event.currentTarget.value);
|
applyPeriodPreset(event.currentTarget.value);
|
||||||
await loadSelectedCar();
|
await loadSelectedCar();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
document.querySelectorAll("#periodFrom, #periodTo").forEach((input) => {
|
document.querySelectorAll("#periodFrom, #periodTo").forEach((input) => {
|
||||||
input.addEventListener("change", async () => {
|
input.addEventListener("change", async () => {
|
||||||
|
await runAction(input, "Обновляю данные...", async () => {
|
||||||
document.querySelector("#periodPreset").value = "custom";
|
document.querySelector("#periodPreset").value = "custom";
|
||||||
applyPeriodPreset("custom");
|
applyPeriodPreset("custom");
|
||||||
await loadSelectedCar();
|
await loadSelectedCar();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
document.querySelector("#carForm").addEventListener("submit", async (event) => {
|
document.querySelector("#carForm").addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const data = formData(event.currentTarget);
|
const form = event.currentTarget;
|
||||||
|
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
|
||||||
|
const data = formData(form);
|
||||||
await api("/cars", {
|
await api("/cars", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -905,15 +1012,20 @@ document.querySelector("#carForm").addEventListener("submit", async (event) => {
|
|||||||
year: data.year ? Number(data.year) : null,
|
year: data.year ? Number(data.year) : null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
event.currentTarget.reset();
|
form.reset();
|
||||||
resetCarCatalog();
|
resetCarCatalog();
|
||||||
document.querySelector("#userDrawer").classList.add("hidden");
|
document.querySelector("#userDrawer").classList.add("hidden");
|
||||||
await loadCars();
|
await loadCars();
|
||||||
|
toast("Сохранено");
|
||||||
|
haptic("success");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelector("#settingsForm").addEventListener("submit", async (event) => {
|
document.querySelector("#settingsForm").addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const data = formData(event.currentTarget);
|
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`, {
|
state.user = await api(`/users/${state.user.id}/preferences`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ locale: data.locale, currency: data.currency }),
|
body: JSON.stringify({ locale: data.locale, currency: data.currency }),
|
||||||
@@ -922,12 +1034,17 @@ document.querySelector("#settingsForm").addEventListener("submit", async (event)
|
|||||||
initCarCatalog();
|
initCarCatalog();
|
||||||
await loadSelectedCar();
|
await loadSelectedCar();
|
||||||
document.querySelector("#userDrawer").classList.add("hidden");
|
document.querySelector("#userDrawer").classList.add("hidden");
|
||||||
|
toast("Сохранено");
|
||||||
|
haptic("success");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelector("#fuelForm").addEventListener("submit", async (event) => {
|
document.querySelector("#fuelForm").addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!state.selectedCarId) return;
|
if (!state.selectedCarId) return;
|
||||||
const data = formData(event.currentTarget);
|
const form = event.currentTarget;
|
||||||
|
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
|
||||||
|
const data = formData(form);
|
||||||
await api("/fuel", {
|
await api("/fuel", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -940,15 +1057,20 @@ document.querySelector("#fuelForm").addEventListener("submit", async (event) =>
|
|||||||
is_full_tank: Boolean(data.is_full_tank),
|
is_full_tank: Boolean(data.is_full_tank),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
event.currentTarget.reset();
|
form.reset();
|
||||||
event.currentTarget.entry_date.value = today();
|
form.entry_date.value = today();
|
||||||
await loadSelectedCar();
|
await loadSelectedCar();
|
||||||
|
toast("Сохранено");
|
||||||
|
haptic("success");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelector("#serviceForm").addEventListener("submit", async (event) => {
|
document.querySelector("#serviceForm").addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!state.selectedCarId) return;
|
if (!state.selectedCarId) return;
|
||||||
const data = formData(event.currentTarget);
|
const form = event.currentTarget;
|
||||||
|
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
|
||||||
|
const data = formData(form);
|
||||||
await api("/service", {
|
await api("/service", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -961,9 +1083,12 @@ document.querySelector("#serviceForm").addEventListener("submit", async (event)
|
|||||||
vendor: data.vendor || null,
|
vendor: data.vendor || null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
event.currentTarget.reset();
|
form.reset();
|
||||||
event.currentTarget.entry_date.value = today();
|
form.entry_date.value = today();
|
||||||
await loadSelectedCar();
|
await loadSelectedCar();
|
||||||
|
toast("Сохранено");
|
||||||
|
haptic("success");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function setAction(action) {
|
function setAction(action) {
|
||||||
@@ -976,6 +1101,7 @@ function setAction(action) {
|
|||||||
|
|
||||||
document.querySelectorAll("[data-action]").forEach((button) => {
|
document.querySelectorAll("[data-action]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
|
haptic();
|
||||||
if (button.dataset.action === "scan") {
|
if (button.dataset.action === "scan") {
|
||||||
document.querySelector("#userDrawer").classList.remove("hidden");
|
document.querySelector("#userDrawer").classList.remove("hidden");
|
||||||
document.querySelector("#scanSection").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) => {
|
document.querySelectorAll("[data-service-title]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
|
haptic();
|
||||||
const form = document.querySelector("#serviceForm");
|
const form = document.querySelector("#serviceForm");
|
||||||
form.title.value = button.dataset.serviceTitle;
|
form.title.value = button.dataset.serviceTitle;
|
||||||
form.service_type.value = button.dataset.serviceType;
|
form.service_type.value = button.dataset.serviceType;
|
||||||
@@ -1063,16 +1190,28 @@ document.querySelector("#receiptFileInput").addEventListener("change", (event) =
|
|||||||
document.querySelector("#ocrForm").addEventListener("submit", async (event) => {
|
document.querySelector("#ocrForm").addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const file = state.receiptFile;
|
const file = state.receiptFile;
|
||||||
if (!file) return;
|
if (!file) {
|
||||||
|
toast("Выбери файл чека", "error");
|
||||||
|
haptic("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const formButton = event.currentTarget.querySelector('button[type="submit"]');
|
||||||
|
await runAction(formButton, "Распознаю чек...", async () => {
|
||||||
const payload = new FormData();
|
const payload = new FormData();
|
||||||
payload.append("file", file);
|
payload.append("file", file);
|
||||||
const response = await fetch("/api/ocr/fuel-receipt", { method: "POST", body: payload });
|
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();
|
const result = await response.json();
|
||||||
document.querySelector("#ocrResult").textContent = result.message;
|
document.querySelector("#ocrResult").textContent = `${result.message} ${Math.round((result.confidence || 0) * 100)}%`;
|
||||||
const form = document.querySelector("#fuelForm");
|
const fuelForm = document.querySelector("#fuelForm");
|
||||||
if (result.liters) form.liters.value = result.liters;
|
if (result.liters) fuelForm.liters.value = result.liters;
|
||||||
if (result.price_per_liter) form.price_per_liter.value = result.price_per_liter;
|
if (result.price_per_liter) fuelForm.price_per_liter.value = result.price_per_liter;
|
||||||
|
if (result.station) fuelForm.station.value = result.station;
|
||||||
setAction("fuel");
|
setAction("fuel");
|
||||||
|
document.querySelector("#userDrawer").classList.add("hidden");
|
||||||
|
toast("Проверь распознанные значения");
|
||||||
|
haptic("success");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelector("#closeMenuBtn").addEventListener("click", () => {
|
document.querySelector("#closeMenuBtn").addEventListener("click", () => {
|
||||||
|
|||||||
@@ -876,3 +876,352 @@ select:disabled {
|
|||||||
grid-template-columns: 1fr;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user