mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 18:04:47 +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)
|
# Внутренний секрет для вызовов бота → бэкенда (без initData)
|
||||||
internal_secret: str
|
internal_secret: str
|
||||||
|
|
||||||
|
# Google Drive ID файла ОТГРУЗКИ.xlsx (склад/отгрузки с завода)
|
||||||
|
shipments_file_id: str
|
||||||
|
|
||||||
|
|
||||||
def _required(name: str) -> str:
|
def _required(name: str) -> str:
|
||||||
val = os.getenv(name)
|
val = os.getenv(name)
|
||||||
@ -50,4 +53,5 @@ def get_config() -> Config:
|
|||||||
proxy_static_list=os.getenv("PROXY_STATIC_LIST", ""),
|
proxy_static_list=os.getenv("PROXY_STATIC_LIST", ""),
|
||||||
proxy_list_file=os.getenv("PROXY_LIST_FILE", ""),
|
proxy_list_file=os.getenv("PROXY_LIST_FILE", ""),
|
||||||
internal_secret=os.getenv("INTERNAL_SECRET", ""),
|
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 .config import get_config
|
||||||
from .auth import verify_init_data
|
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 . 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
|
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())
|
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]:
|
def _handle_daily_reminders() -> dict[str, Any]:
|
||||||
"""Находит клиентов с годовщиной договора сегодня по МСК.
|
"""Находит клиентов с годовщиной договора сегодня по МСК.
|
||||||
Дедуплицирует: один менеджер + один клиент = одно уведомление,
|
Дедуплицирует: один менеджер + один клиент = одно уведомление,
|
||||||
@ -3123,6 +3131,155 @@ def _initial(name: str) -> str:
|
|||||||
return ((name or "").strip()[:1] or "?").upper()
|
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:
|
def _now_iso() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|||||||
@ -9,3 +9,4 @@ python-dotenv>=1.0.0
|
|||||||
beautifulsoup4>=4.12.0
|
beautifulsoup4>=4.12.0
|
||||||
lxml>=5.2.0
|
lxml>=5.2.0
|
||||||
playwright>=1.45.0
|
playwright>=1.45.0
|
||||||
|
openpyxl>=3.1.0
|
||||||
|
|||||||
@ -28,3 +28,6 @@ GRACE_PERIOD_DAYS=14
|
|||||||
|
|
||||||
# Внутренний секрет бот → бэкенд (годовщины договоров, не публичный)
|
# Внутренний секрет бот → бэкенд (годовщины договоров, не публичный)
|
||||||
INTERNAL_SECRET=zov-internal-2026-k9mXpQr3wN8vLs1t
|
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>`);
|
const projectsContainer = el(`<div id="projectsContainer"></div>`);
|
||||||
app.appendChild(projectsContainer);
|
app.appendChild(projectsContainer);
|
||||||
|
|
||||||
|
// Контейнер для отгрузок (под активными проектами)
|
||||||
|
const shipmentsContainer = el(`<div id="shipmentsContainer"></div>`);
|
||||||
|
app.appendChild(shipmentsContainer);
|
||||||
|
|
||||||
renderBottomNav("home", { unreadChats: 0 });
|
renderBottomNav("home", { unreadChats: 0 });
|
||||||
|
|
||||||
// Контейнер для карточек «Замер готов — что делать с подбором?»
|
// Контейнер для карточек «Замер готов — что делать с подбором?»
|
||||||
const pendingContainer = el(`<div id="pendingContainer"></div>`);
|
const pendingContainer = el(`<div id="pendingContainer"></div>`);
|
||||||
app.insertBefore(pendingContainer, todayContainer);
|
app.insertBefore(pendingContainer, todayContainer);
|
||||||
|
|
||||||
// Параллельно грузим реальные данные
|
// Параллельно грузим реальные данные (измерения + pending + отгрузки)
|
||||||
try {
|
try {
|
||||||
const [resM, resP] = await Promise.all([
|
const authBody = { initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null };
|
||||||
fetch(`${BACKEND_URL}/api/measurements`, {
|
const [resM, resP, resS] = await Promise.all([
|
||||||
method: "POST",
|
fetch(`${BACKEND_URL}/api/measurements`, { method: "POST", body: JSON.stringify(authBody) }),
|
||||||
body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null }),
|
fetch(`${BACKEND_URL}/api/manager_pending`, { method: "POST", body: JSON.stringify(authBody) }),
|
||||||
}),
|
fetch(`${BACKEND_URL}/api/shipments`, { method: "POST", body: JSON.stringify(authBody) }),
|
||||||
fetch(`${BACKEND_URL}/api/manager_pending`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null }),
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
const data = await resM.json();
|
const data = await resM.json();
|
||||||
const pendingData = await resP.json();
|
const pendingData = await resP.json();
|
||||||
|
const shipmentsData = await resS.json();
|
||||||
const measurements = (data.measurements || []);
|
const measurements = (data.measurements || []);
|
||||||
const pending = (pendingData.pending || []);
|
const pending = (pendingData.pending || []);
|
||||||
|
|
||||||
renderManagerPending(pendingContainer, pending);
|
renderManagerPending(pendingContainer, pending);
|
||||||
renderManagerToday(todayContainer, measurements, firstName, greetingEl);
|
renderManagerToday(todayContainer, measurements, firstName, greetingEl);
|
||||||
renderManagerProjects(projectsContainer, measurements);
|
renderManagerProjects(projectsContainer, measurements);
|
||||||
|
renderManagerShipments(shipmentsContainer, shipmentsData.shipments || []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
todayContainer.innerHTML = `<div class="error">Не удалось загрузить данные: ${escHtml(e.message)}</div>`;
|
todayContainer.innerHTML = `<div class="error">Не удалось загрузить данные: ${escHtml(e.message)}</div>`;
|
||||||
}
|
}
|
||||||
@ -479,6 +481,71 @@ function renderManagerProjects(container, measurements) {
|
|||||||
container.appendChild(list);
|
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 = {}) {
|
function renderBottomNav(active, opts = {}) {
|
||||||
// Удаляем предыдущий, если есть
|
// Удаляем предыдущий, если есть
|
||||||
const old = document.getElementById("bottom-nav");
|
const old = document.getElementById("bottom-nav");
|
||||||
|
|||||||
@ -1833,6 +1833,138 @@
|
|||||||
box-shadow: 0 2px 10px rgba(107, 74, 43, 0.28);
|
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 {
|
.client-card {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
|
|||||||
@ -12,14 +12,14 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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">
|
<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>
|
<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/styles.css?v=20260516a">
|
||||||
<link rel="stylesheet" href="assets/podbor.css?v=20260515b">
|
<link rel="stylesheet" href="assets/podbor.css?v=20260516a">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск -->
|
<!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск -->
|
||||||
<div class="loader splash" id="splash">
|
<div class="loader splash" id="splash">
|
||||||
<div class="brand-logo-wrap">
|
<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">
|
<div class="splash-dust" aria-hidden="true">
|
||||||
<span class="dust d1"></span> <span class="dust d2"></span>
|
<span class="dust d1"></span> <span class="dust d2"></span>
|
||||||
<span class="dust d3"></span> <span class="dust d4"></span>
|
<span class="dust d3"></span> <span class="dust d4"></span>
|
||||||
@ -35,15 +35,15 @@
|
|||||||
<div class="brand-tagline-gold">CRM</div>
|
<div class="brand-tagline-gold">CRM</div>
|
||||||
</div>
|
</div>
|
||||||
<main id="app"></main>
|
<main id="app"></main>
|
||||||
<script src="assets/icons.js?v=20260515b"></script>
|
<script src="assets/icons.js?v=20260516a"></script>
|
||||||
<script src="assets/podbor.config.js?v=20260515b"></script>
|
<script src="assets/podbor.config.js?v=20260516a"></script>
|
||||||
<script src="assets/podbor.picts.js?v=20260515b"></script>
|
<script src="assets/podbor.picts.js?v=20260516a"></script>
|
||||||
<script src="assets/podbor.js?v=20260515b"></script>
|
<script src="assets/podbor.js?v=20260516a"></script>
|
||||||
<script src="assets/clients.js?v=20260515b"></script>
|
<script src="assets/clients.js?v=20260516a"></script>
|
||||||
<script src="assets/zamer-picts.js?v=20260515b"></script>
|
<script src="assets/zamer-picts.js?v=20260516a"></script>
|
||||||
<script src="assets/measurements.js?v=20260515b"></script>
|
<script src="assets/measurements.js?v=20260516a"></script>
|
||||||
<script src="assets/request.js?v=20260515b"></script>
|
<script src="assets/request.js?v=20260516a"></script>
|
||||||
<script src="assets/assembly.js?v=20260515b"></script>
|
<script src="assets/assembly.js?v=20260516a"></script>
|
||||||
<script src="assets/app.js?v=20260515b"></script>
|
<script src="assets/app.js?v=20260516a"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user