mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 15:04:50 +00:00
feat: warehouse module — ОТГРУЗКИ.xlsx в дашборде менеджера
- backend: новый модуль drive.py (Google Drive download + 5-мин кэш)
- backend: /api/shipments — читает xlsx из Drive, парсит листы «ЗОВ ДД.ММ.ГГ»,
возвращает позиции (Заказ/Дозаказ) сгруппированные по дате отгрузки с завода
- config: поле shipments_file_id (SHIPMENTS_FILE_ID env; дефолт = ID ОТГРУЗКИ.xlsx)
- frontend: секция «📦 Отгрузки» на главной менеджера (после активных проектов),
загружается параллельно с замерами и pending; показывает последние 3 партии
- CSS: стили .ship-group / .ship-row / .ship-badge / .ship-check
- deps: добавлен openpyxl>=3.1.0
ВАЖНО после деплоя: добавить сервис-аккаунт как Viewer к ОТГРУЗКИ.xlsx в Drive
и прописать SHIPMENTS_FILE_ID в /opt/zov-tech/deploy/.env на сервере.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
34ef51c4c8
commit
f5ee9e5b33
@ -26,6 +26,9 @@ class Config:
|
||||
# Внутренний секрет для вызовов бота → бэкенда (без initData)
|
||||
internal_secret: str
|
||||
|
||||
# Google Drive ID файла ОТГРУЗКИ.xlsx (склад/отгрузки с завода)
|
||||
shipments_file_id: str
|
||||
|
||||
|
||||
def _required(name: str) -> str:
|
||||
val = os.getenv(name)
|
||||
@ -50,4 +53,5 @@ def get_config() -> Config:
|
||||
proxy_static_list=os.getenv("PROXY_STATIC_LIST", ""),
|
||||
proxy_list_file=os.getenv("PROXY_LIST_FILE", ""),
|
||||
internal_secret=os.getenv("INTERNAL_SECRET", ""),
|
||||
shipments_file_id=os.getenv("SHIPMENTS_FILE_ID", "1fER4NmEgSznvPKJWXOqLDDkTxH6wm78E"),
|
||||
)
|
||||
|
||||
64
backend-py/app/drive.py
Normal file
64
backend-py/app/drive.py
Normal file
@ -0,0 +1,64 @@
|
||||
"""Загрузка файлов из Google Drive через service account.
|
||||
|
||||
Использует google-api-python-client (уже в requirements.txt).
|
||||
Scope drive.readonly — только чтение файлов, к которым у сервисного аккаунта есть доступ.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from googleapiclient.discovery import build # type: ignore
|
||||
from googleapiclient.http import MediaIoBaseDownload # type: ignore
|
||||
from google.oauth2.service_account import Credentials # type: ignore
|
||||
|
||||
from .config import get_config
|
||||
|
||||
_SCOPES = [
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
]
|
||||
|
||||
_lock = threading.Lock()
|
||||
_service: Any = None
|
||||
|
||||
# Простой in-memory кэш: {file_id: (bytes, timestamp)}
|
||||
_cache: dict[str, tuple[bytes, float]] = {}
|
||||
_CACHE_TTL = 300 # 5 минут
|
||||
|
||||
|
||||
def _get_service() -> Any:
|
||||
global _service
|
||||
with _lock:
|
||||
if _service is None:
|
||||
cfg = get_config()
|
||||
creds = Credentials.from_service_account_file(
|
||||
cfg.google_credentials_path, scopes=_SCOPES
|
||||
)
|
||||
_service = build("drive", "v3", credentials=creds, cache_discovery=False)
|
||||
return _service
|
||||
|
||||
|
||||
def download_file_bytes(file_id: str) -> bytes:
|
||||
"""Скачивает файл из Google Drive по его ID и возвращает байты.
|
||||
|
||||
Кэширует результат на 5 минут, чтобы не качать xlsx на каждый запрос дашборда.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
cached = _cache.get(file_id)
|
||||
if cached and (now - cached[1]) < _CACHE_TTL:
|
||||
return cached[0]
|
||||
|
||||
service = _get_service()
|
||||
request = service.files().get_media(fileId=file_id)
|
||||
fh = io.BytesIO()
|
||||
downloader = MediaIoBaseDownload(fh, request)
|
||||
done = False
|
||||
while not done:
|
||||
_, done = downloader.next_chunk()
|
||||
|
||||
data = fh.getvalue()
|
||||
_cache[file_id] = (data, now)
|
||||
return data
|
||||
@ -16,7 +16,7 @@ from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
from .config import get_config
|
||||
from .auth import verify_init_data
|
||||
from . import sheets, ai, telegram as tg, proxy_pool, catalog, geocoder
|
||||
from . import sheets, ai, telegram as tg, proxy_pool, catalog, geocoder, drive
|
||||
from . import parsers
|
||||
from .parsers import dns as parser_dns, wb as parser_wb, ozon as parser_ozon, yamarket as parser_ym, citilink as parser_cl
|
||||
|
||||
@ -359,6 +359,14 @@ async def api_daily_reminders(request: Request):
|
||||
return JSONResponse(_handle_daily_reminders())
|
||||
|
||||
|
||||
@app.post("/api/shipments")
|
||||
async def api_shipments(request: Request):
|
||||
"""Отгрузки из ОТГРУЗКИ.xlsx (Google Drive). Только для менеджера."""
|
||||
body = await _safe_json(request)
|
||||
import asyncio
|
||||
return JSONResponse(await asyncio.to_thread(_handle_shipments, body))
|
||||
|
||||
|
||||
def _handle_daily_reminders() -> dict[str, Any]:
|
||||
"""Находит клиентов с годовщиной договора сегодня по МСК.
|
||||
Дедуплицирует: один менеджер + один клиент = одно уведомление,
|
||||
@ -3123,6 +3131,155 @@ def _initial(name: str) -> str:
|
||||
return ((name or "").strip()[:1] or "?").upper()
|
||||
|
||||
|
||||
def _handle_shipments(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Читает ОТГРУЗКИ.xlsx из Google Drive и возвращает позиции, сгруппированные по дате отгрузки с завода.
|
||||
|
||||
Листы Excel называются "ЗОВ ДД.ММ.ГГ" — дата отгрузки с завода.
|
||||
Колонки: №, Товар (Заказ/Дозаказ), договор №, Срок, Кол мест,
|
||||
Фурн-ра СПБ, Панели/техника СПБ, (empty), Продавец,
|
||||
Сборщик, Примечание, Дата отгрузки, Кто забрал.
|
||||
"""
|
||||
import io
|
||||
try:
|
||||
import openpyxl
|
||||
except ImportError:
|
||||
return {"error": "openpyxl_not_installed"}
|
||||
|
||||
cfg = get_config()
|
||||
|
||||
# Auth — только менеджер
|
||||
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
|
||||
if not auth or not auth.get("user"):
|
||||
unsafe = body.get("initDataUnsafe") or {}
|
||||
if isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"):
|
||||
auth = {"user": unsafe["user"]}
|
||||
else:
|
||||
return {"error": "invalid_init_data"}
|
||||
tg_id = auth["user"]["id"]
|
||||
user = sheets.find_user(tg_id)
|
||||
if not user or not sheets.has_role(user, "manager"):
|
||||
return {"error": "only_manager"}
|
||||
|
||||
file_id = cfg.shipments_file_id
|
||||
if not file_id:
|
||||
return {"ok": True, "shipments": [], "note": "file_not_configured"}
|
||||
|
||||
try:
|
||||
file_bytes = drive.download_file_bytes(file_id)
|
||||
except Exception as e:
|
||||
log.exception("shipments: не удалось скачать файл drive=%s", file_id)
|
||||
return {"error": f"drive_error: {str(e)}"}
|
||||
|
||||
try:
|
||||
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True)
|
||||
except Exception as e:
|
||||
log.exception("shipments: не удалось распарсить xlsx")
|
||||
return {"error": f"xlsx_parse_error: {str(e)}"}
|
||||
|
||||
groups: list[dict[str, Any]] = []
|
||||
|
||||
for sheet_name in wb.sheetnames:
|
||||
if not sheet_name.upper().startswith("ЗОВ "):
|
||||
continue
|
||||
date_part = sheet_name[4:].strip()
|
||||
try:
|
||||
if len(date_part) == 8:
|
||||
factory_date = datetime.strptime(date_part, "%d.%m.%y").date()
|
||||
elif len(date_part) == 10:
|
||||
factory_date = datetime.strptime(date_part, "%d.%m.%Y").date()
|
||||
else:
|
||||
continue
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
ws = wb[sheet_name]
|
||||
all_rows = list(ws.iter_rows(values_only=True))
|
||||
if len(all_rows) < 2:
|
||||
continue
|
||||
|
||||
# Заголовки — первая строка
|
||||
raw_headers = all_rows[0]
|
||||
headers = [str(h).strip() if h is not None else "" for h in raw_headers]
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for row in all_rows[1:]:
|
||||
# Пропускаем полностью пустые строки
|
||||
if not any(v for v in row if v is not None and str(v).strip()):
|
||||
continue
|
||||
|
||||
rd: dict[str, Any] = {}
|
||||
for i, val in enumerate(row):
|
||||
key = headers[i] if i < len(headers) else f"_col{i}"
|
||||
rd[key] = val
|
||||
|
||||
tovar = str(rd.get("Товар") or "").strip()
|
||||
if not tovar or tovar.lower() in ("none", "товар", ""):
|
||||
continue
|
||||
|
||||
# Договор № — может быть в колонке с пустым заголовком (C) или "договор №"
|
||||
contract = ""
|
||||
for h in headers:
|
||||
if "договор" in h.lower() or "дог" in h.lower():
|
||||
contract = str(rd.get(h) or "").strip()
|
||||
break
|
||||
if not contract and len(headers) > 2:
|
||||
contract = str(rd.get(headers[2]) or "").strip()
|
||||
contract = "" if contract in ("None", "none") else contract
|
||||
|
||||
def _clean(v: Any) -> str:
|
||||
s = str(v or "").strip()
|
||||
return "" if s in ("None", "none") else s
|
||||
|
||||
items.append({
|
||||
"num": _clean(rd.get("№") or rd.get("№")),
|
||||
"tovar": tovar, # "Заказ" | "Дозаказ"
|
||||
"contract": contract,
|
||||
"deadline": _parse_xlsx_date(rd.get("Срок")),
|
||||
"places": _clean(rd.get("Кол мест") or rd.get("Кол. мест") or rd.get("Кол.мест")),
|
||||
"furn_spb": _clean(rd.get("Фурн-ра СПБ") or rd.get("Фурн СПБ")),
|
||||
"panels_spb": _clean(rd.get("Панели/техника СПБ") or rd.get("Панели СПБ")),
|
||||
"seller": _clean(rd.get("Продавец")),
|
||||
"assembler": _clean(rd.get("Сборщик")),
|
||||
"note": _clean(rd.get("Примечание")),
|
||||
"delivery_date": _parse_xlsx_date(rd.get("Дата отгрузки")),
|
||||
"picked_up_by": _clean(rd.get("Кто забрал")),
|
||||
})
|
||||
|
||||
if items:
|
||||
groups.append({
|
||||
"factory_date": factory_date.strftime("%d.%m.%Y"),
|
||||
"factory_date_iso": factory_date.isoformat(),
|
||||
"sheet_name": sheet_name,
|
||||
"count": len(items),
|
||||
"count_zakazov": sum(1 for i in items if "Заказ" in i["tovar"] and "Доз" not in i["tovar"]),
|
||||
"count_dozakazov": sum(1 for i in items if "Доз" in i["tovar"]),
|
||||
"items": items,
|
||||
})
|
||||
|
||||
groups.sort(key=lambda x: x["factory_date_iso"])
|
||||
return {"ok": True, "shipments": groups}
|
||||
|
||||
|
||||
def _parse_xlsx_date(val: Any) -> str:
|
||||
"""Парсит дату из ячейки Excel — datetime, date или строка."""
|
||||
if val is None:
|
||||
return ""
|
||||
from datetime import date as date_t
|
||||
if isinstance(val, datetime):
|
||||
return val.strftime("%d.%m.%Y")
|
||||
if isinstance(val, date_t):
|
||||
return val.strftime("%d.%m.%Y")
|
||||
s = str(val).strip()
|
||||
if not s or s.lower() in ("none", ""):
|
||||
return ""
|
||||
for fmt in ("%d.%m.%Y", "%Y-%m-%d", "%d.%m.%y"):
|
||||
try:
|
||||
return datetime.strptime(s, fmt).strftime("%d.%m.%Y")
|
||||
except ValueError:
|
||||
pass
|
||||
return s # вернём как есть если не распознали
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@ -9,3 +9,4 @@ python-dotenv>=1.0.0
|
||||
beautifulsoup4>=4.12.0
|
||||
lxml>=5.2.0
|
||||
playwright>=1.45.0
|
||||
openpyxl>=3.1.0
|
||||
|
||||
@ -28,3 +28,6 @@ GRACE_PERIOD_DAYS=14
|
||||
|
||||
# Внутренний секрет бот → бэкенд (годовщины договоров, не публичный)
|
||||
INTERNAL_SECRET=zov-internal-2026-k9mXpQr3wN8vLs1t
|
||||
|
||||
# Google Drive ID файла ОТГРУЗКИ.xlsx (склад/отгрузки с завода)
|
||||
SHIPMENTS_FILE_ID=1fER4NmEgSznvPKJWXOqLDDkTxH6wm78E
|
||||
|
||||
@ -170,32 +170,34 @@ async function renderManagerHome(me) {
|
||||
const projectsContainer = el(`<div id="projectsContainer"></div>`);
|
||||
app.appendChild(projectsContainer);
|
||||
|
||||
// Контейнер для отгрузок (под активными проектами)
|
||||
const shipmentsContainer = el(`<div id="shipmentsContainer"></div>`);
|
||||
app.appendChild(shipmentsContainer);
|
||||
|
||||
renderBottomNav("home", { unreadChats: 0 });
|
||||
|
||||
// Контейнер для карточек «Замер готов — что делать с подбором?»
|
||||
const pendingContainer = el(`<div id="pendingContainer"></div>`);
|
||||
app.insertBefore(pendingContainer, todayContainer);
|
||||
|
||||
// Параллельно грузим реальные данные
|
||||
// Параллельно грузим реальные данные (измерения + pending + отгрузки)
|
||||
try {
|
||||
const [resM, resP] = await Promise.all([
|
||||
fetch(`${BACKEND_URL}/api/measurements`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null }),
|
||||
}),
|
||||
fetch(`${BACKEND_URL}/api/manager_pending`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null }),
|
||||
}),
|
||||
const authBody = { initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null };
|
||||
const [resM, resP, resS] = await Promise.all([
|
||||
fetch(`${BACKEND_URL}/api/measurements`, { method: "POST", body: JSON.stringify(authBody) }),
|
||||
fetch(`${BACKEND_URL}/api/manager_pending`, { method: "POST", body: JSON.stringify(authBody) }),
|
||||
fetch(`${BACKEND_URL}/api/shipments`, { method: "POST", body: JSON.stringify(authBody) }),
|
||||
]);
|
||||
const data = await resM.json();
|
||||
const pendingData = await resP.json();
|
||||
const shipmentsData = await resS.json();
|
||||
const measurements = (data.measurements || []);
|
||||
const pending = (pendingData.pending || []);
|
||||
|
||||
renderManagerPending(pendingContainer, pending);
|
||||
renderManagerToday(todayContainer, measurements, firstName, greetingEl);
|
||||
renderManagerProjects(projectsContainer, measurements);
|
||||
renderManagerShipments(shipmentsContainer, shipmentsData.shipments || []);
|
||||
} catch (e) {
|
||||
todayContainer.innerHTML = `<div class="error">Не удалось загрузить данные: ${escHtml(e.message)}</div>`;
|
||||
}
|
||||
@ -479,6 +481,71 @@ function renderManagerProjects(container, measurements) {
|
||||
container.appendChild(list);
|
||||
}
|
||||
|
||||
/* ----------------- Менеджер: секция отгрузок (ОТГРУЗКИ.xlsx) ----------------- */
|
||||
function renderManagerShipments(container, groups) {
|
||||
container.innerHTML = "";
|
||||
if (!groups || !groups.length) return;
|
||||
|
||||
// Показываем последние 3 партии (ближайшие по дате отгрузки с завода)
|
||||
const visible = groups.slice(-3);
|
||||
|
||||
const totalItems = visible.reduce((s, g) => s + g.count, 0);
|
||||
container.appendChild(el(`
|
||||
<div class="section-head" style="margin-top:24px;">
|
||||
<span class="label">📦 Отгрузки <span class="count">· ${totalItems} поз.</span></span>
|
||||
</div>
|
||||
`));
|
||||
|
||||
for (const group of visible) {
|
||||
const zakazBadge = group.count_zakazov
|
||||
? `<span class="ship-badge order">Заказов ${group.count_zakazov}</span>` : "";
|
||||
const dozBadge = group.count_dozakazov
|
||||
? `<span class="ship-badge resupply">Дозаказов ${group.count_dozakazov}</span>` : "";
|
||||
|
||||
const groupEl = el(`
|
||||
<section class="ship-group">
|
||||
<div class="ship-group-head">
|
||||
<span class="ship-factory-date">${escHtml(group.factory_date)}</span>
|
||||
<span class="ship-badges">${zakazBadge}${dozBadge}</span>
|
||||
</div>
|
||||
<div class="ship-rows"></div>
|
||||
</section>
|
||||
`);
|
||||
|
||||
const rowsEl = groupEl.querySelector(".ship-rows");
|
||||
for (const item of group.items) {
|
||||
const typeClass = item.tovar.startsWith("Доз") ? "dozakaz" : "zakaz";
|
||||
const typeMark = item.tovar.startsWith("Доз") ? "Дозаказ" : "Заказ";
|
||||
|
||||
const delivStr = item.delivery_date ? `📬 ${escHtml(item.delivery_date)}` : "";
|
||||
const assembler = item.assembler ? `🔧 ${escHtml(item.assembler)}` : "";
|
||||
const places = item.places ? `📦 ${escHtml(item.places)} м.` : "";
|
||||
const meta = [delivStr, assembler, places].filter(Boolean).join(" · ");
|
||||
|
||||
const furn = item.furn_spb ? `<span class="ship-check ${item.furn_spb === "+" ? "yes" : "no"}">Фурн: ${escHtml(item.furn_spb)}</span>` : "";
|
||||
const pan = item.panels_spb ? `<span class="ship-check ${item.panels_spb === "+" ? "yes" : "no"}">Пан: ${escHtml(item.panels_spb)}</span>` : "";
|
||||
|
||||
const numStr = item.num ? `#${escHtml(item.num)} ` : "";
|
||||
const contractStr = item.contract ? `Дог ${escHtml(item.contract)}` : "";
|
||||
const noteStr = item.note ? `<div class="ship-note">${escHtml(item.note)}</div>` : "";
|
||||
|
||||
rowsEl.appendChild(el(`
|
||||
<div class="ship-row">
|
||||
<div class="ship-row-top">
|
||||
<span class="ship-type ${typeClass}">${typeMark}</span>
|
||||
<span class="ship-id">${numStr}${contractStr}</span>
|
||||
</div>
|
||||
${meta ? `<div class="ship-meta">${meta}</div>` : ""}
|
||||
${furn || pan ? `<div class="ship-supply">${furn}${pan}</div>` : ""}
|
||||
${noteStr}
|
||||
</div>
|
||||
`));
|
||||
}
|
||||
|
||||
container.appendChild(groupEl);
|
||||
}
|
||||
}
|
||||
|
||||
function renderBottomNav(active, opts = {}) {
|
||||
// Удаляем предыдущий, если есть
|
||||
const old = document.getElementById("bottom-nav");
|
||||
|
||||
@ -1833,6 +1833,138 @@
|
||||
box-shadow: 0 2px 10px rgba(107, 74, 43, 0.28);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Секция отгрузок на главном дашборде менеджера (ОТГРУЗКИ.xlsx)
|
||||
============================================================ */
|
||||
|
||||
.ship-group {
|
||||
margin: 0 16px 12px;
|
||||
background: var(--card, #fff);
|
||||
border: 1px solid var(--line, #e8e1d9);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ship-group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: var(--surface-2, #f4f1ed);
|
||||
border-bottom: 1px solid var(--line, #e8e1d9);
|
||||
}
|
||||
|
||||
.ship-factory-date {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.ship-badges {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ship-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 7px;
|
||||
border-radius: 20px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ship-badge.order {
|
||||
background: rgba(0, 62, 126, 0.10);
|
||||
color: #003E7E;
|
||||
}
|
||||
.ship-badge.resupply {
|
||||
background: rgba(192, 57, 43, 0.10);
|
||||
color: #C0392B;
|
||||
}
|
||||
|
||||
.ship-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ship-row {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--line, #e8e1d9);
|
||||
}
|
||||
.ship-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ship-row-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.ship-type {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 7px;
|
||||
border-radius: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ship-type.zakaz {
|
||||
background: rgba(0, 62, 126, 0.10);
|
||||
color: #003E7E;
|
||||
}
|
||||
.ship-type.dozakaz {
|
||||
background: rgba(192, 57, 43, 0.10);
|
||||
color: #C0392B;
|
||||
}
|
||||
|
||||
.ship-id {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ship-meta {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ship-supply {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ship-check {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.ship-check.yes {
|
||||
background: rgba(39, 174, 96, 0.12);
|
||||
color: #1a7a42;
|
||||
}
|
||||
.ship-check.no {
|
||||
background: rgba(192, 57, 43, 0.10);
|
||||
color: #C0392B;
|
||||
}
|
||||
|
||||
.ship-note {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
font-style: italic;
|
||||
line-height: 1.3;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.client-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
|
||||
@ -12,14 +12,14 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&family=Caveat:wght@500;700&display=swap">
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<link rel="stylesheet" href="assets/styles.css?v=20260515b">
|
||||
<link rel="stylesheet" href="assets/podbor.css?v=20260515b">
|
||||
<link rel="stylesheet" href="assets/styles.css?v=20260516a">
|
||||
<link rel="stylesheet" href="assets/podbor.css?v=20260516a">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск -->
|
||||
<div class="loader splash" id="splash">
|
||||
<div class="brand-logo-wrap">
|
||||
<img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260515b" alt="@wasrusgen1">
|
||||
<img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260516a" alt="@wasrusgen1">
|
||||
<div class="splash-dust" aria-hidden="true">
|
||||
<span class="dust d1"></span> <span class="dust d2"></span>
|
||||
<span class="dust d3"></span> <span class="dust d4"></span>
|
||||
@ -35,15 +35,15 @@
|
||||
<div class="brand-tagline-gold">CRM</div>
|
||||
</div>
|
||||
<main id="app"></main>
|
||||
<script src="assets/icons.js?v=20260515b"></script>
|
||||
<script src="assets/podbor.config.js?v=20260515b"></script>
|
||||
<script src="assets/podbor.picts.js?v=20260515b"></script>
|
||||
<script src="assets/podbor.js?v=20260515b"></script>
|
||||
<script src="assets/clients.js?v=20260515b"></script>
|
||||
<script src="assets/zamer-picts.js?v=20260515b"></script>
|
||||
<script src="assets/measurements.js?v=20260515b"></script>
|
||||
<script src="assets/request.js?v=20260515b"></script>
|
||||
<script src="assets/assembly.js?v=20260515b"></script>
|
||||
<script src="assets/app.js?v=20260515b"></script>
|
||||
<script src="assets/icons.js?v=20260516a"></script>
|
||||
<script src="assets/podbor.config.js?v=20260516a"></script>
|
||||
<script src="assets/podbor.picts.js?v=20260516a"></script>
|
||||
<script src="assets/podbor.js?v=20260516a"></script>
|
||||
<script src="assets/clients.js?v=20260516a"></script>
|
||||
<script src="assets/zamer-picts.js?v=20260516a"></script>
|
||||
<script src="assets/measurements.js?v=20260516a"></script>
|
||||
<script src="assets/request.js?v=20260516a"></script>
|
||||
<script src="assets/assembly.js?v=20260516a"></script>
|
||||
<script src="assets/app.js?v=20260516a"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user