mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 17:44:48 +00:00
feat(6.12): dispatcher cabinet — shipment → arrival → dispatch pipeline
- dispatcher_dashboard.js: 3-step pipeline UI (shipped/arrived/scheduled) - Assemblies: +shipment_date, packages_count, arrival_date, arrival_packages_count, arrival_confirmed_by_tg_id - /api/dispatcher_inbox: full assembly list sorted by pipeline stage - /api/assembly_set_shipment: fixes factory shipment date + package count → status=shipped - /api/assembly_set_arrival: confirms warehouse receipt, alerts manager on package mismatch → status=arrived - /api/assembly_assign_dispatch: sets date + expeditor, notifies both → status=scheduled - app.js: dispatcher role routing (#/dispatcher), capability flag Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
02f8dba469
commit
7865b3f699
@ -859,6 +859,7 @@ def _handle_me(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"measurer": has_measurer,
|
"measurer": has_measurer,
|
||||||
"assembler": has_assembler,
|
"assembler": has_assembler,
|
||||||
"expeditor": has_expeditor,
|
"expeditor": has_expeditor,
|
||||||
|
"dispatcher": sheets.has_role(user, "dispatcher"),
|
||||||
},
|
},
|
||||||
"equipment": equipment_list,
|
"equipment": equipment_list,
|
||||||
"equipment_ok": equipment_ok,
|
"equipment_ok": equipment_ok,
|
||||||
@ -1530,6 +1531,237 @@ def _handle_act4_save_signature(body):
|
|||||||
return {"ok": True, "signed": True, "signed_by_name": name}
|
return {"ok": True, "signed": True, "signed_by_name": name}
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_dispatcher_inbox(body):
|
||||||
|
cfg = get_config()
|
||||||
|
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:
|
||||||
|
return {"error": "user_not_found"}
|
||||||
|
is_disp = sheets.has_role(user, "dispatcher")
|
||||||
|
is_mgr = sheets.has_role(user, "manager")
|
||||||
|
if not (is_disp or is_mgr):
|
||||||
|
return {"error": "forbidden"}
|
||||||
|
|
||||||
|
_ensure_assemblies_sheet()
|
||||||
|
try:
|
||||||
|
ws = sheets.sheet("Assemblies")
|
||||||
|
rows = ws.get_all_values()
|
||||||
|
except Exception:
|
||||||
|
return {"ok": True, "assemblies": []}
|
||||||
|
if not rows or len(rows) < 2:
|
||||||
|
return {"ok": True, "assemblies": []}
|
||||||
|
|
||||||
|
headers = rows[0]
|
||||||
|
STATUS_ORDER = {"created": 0, "shipped": 1, "arrived": 2, "scheduled": 3,
|
||||||
|
"in_progress": 4, "completed": 5, "cancelled": 9}
|
||||||
|
out = []
|
||||||
|
for r in rows[1:]:
|
||||||
|
row = dict(zip(headers, r + [""] * max(0, len(headers) - len(r))))
|
||||||
|
if row.get("archived_at"):
|
||||||
|
continue
|
||||||
|
st = row.get("status", "created")
|
||||||
|
if st == "cancelled":
|
||||||
|
continue
|
||||||
|
out.append({
|
||||||
|
"id": row.get("id", ""),
|
||||||
|
"client_name": row.get("client_name", ""),
|
||||||
|
"client_phone": row.get("client_phone", ""),
|
||||||
|
"address": row.get("address", ""),
|
||||||
|
"scope_of_work": row.get("scope_of_work", ""),
|
||||||
|
"status": st,
|
||||||
|
"scheduled_at": row.get("scheduled_at", ""),
|
||||||
|
"shipment_date": row.get("shipment_date", ""),
|
||||||
|
"packages_count": row.get("packages_count", ""),
|
||||||
|
"arrival_date": row.get("arrival_date", ""),
|
||||||
|
"arrival_packages_count": row.get("arrival_packages_count", ""),
|
||||||
|
"expeditor_tg_id": row.get("expeditor_tg_id", ""),
|
||||||
|
"manager_note": row.get("manager_note", ""),
|
||||||
|
"ts": row.get("ts", ""),
|
||||||
|
})
|
||||||
|
out.sort(key=lambda x: (STATUS_ORDER.get(x["status"], 9), x.get("ts", "")))
|
||||||
|
return {"ok": True, "assemblies": out}
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_assembly_set_shipment(body):
|
||||||
|
cfg = get_config()
|
||||||
|
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:
|
||||||
|
return {"error": "user_not_found"}
|
||||||
|
if not (sheets.has_role(user, "dispatcher") or sheets.has_role(user, "manager")):
|
||||||
|
return {"error": "forbidden"}
|
||||||
|
|
||||||
|
assembly_id = (body.get("assembly_id") or "").strip()
|
||||||
|
shipment_date = (body.get("shipment_date") or "").strip()
|
||||||
|
packages_count = str(body.get("packages_count") or "").strip()
|
||||||
|
if not assembly_id or not shipment_date:
|
||||||
|
return {"error": "missing_fields"}
|
||||||
|
|
||||||
|
_ensure_assemblies_sheet()
|
||||||
|
asm = sheets.find_row("Assemblies", "id", assembly_id)
|
||||||
|
if not asm:
|
||||||
|
return {"error": "assembly_not_found"}
|
||||||
|
|
||||||
|
sheets.update_cell_by_key("Assemblies", "id", assembly_id, "shipment_date", shipment_date)
|
||||||
|
sheets.update_cell_by_key("Assemblies", "id", assembly_id, "packages_count", packages_count)
|
||||||
|
sheets.update_cell_by_key("Assemblies", "id", assembly_id, "status", "shipped")
|
||||||
|
|
||||||
|
# Уведомить менеджера
|
||||||
|
try:
|
||||||
|
mgr_id = asm.get("manager_tg_id")
|
||||||
|
if mgr_id:
|
||||||
|
tg.send_message(int(mgr_id),
|
||||||
|
f"🚚 <b>Отгрузка с фабрики зафиксирована</b>\n"
|
||||||
|
f"Клиент: {asm.get('client_name','')}\n"
|
||||||
|
f"Дата отгрузки: {shipment_date}\n"
|
||||||
|
f"Упаковок: {packages_count or '—'}")
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("dispatcher notify error: %s", e)
|
||||||
|
|
||||||
|
return {"ok": True, "status": "shipped"}
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_assembly_set_arrival(body):
|
||||||
|
cfg = get_config()
|
||||||
|
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:
|
||||||
|
return {"error": "user_not_found"}
|
||||||
|
if not (sheets.has_role(user, "dispatcher") or sheets.has_role(user, "manager")):
|
||||||
|
return {"error": "forbidden"}
|
||||||
|
|
||||||
|
assembly_id = (body.get("assembly_id") or "").strip()
|
||||||
|
arrival_date = (body.get("arrival_date") or "").strip()
|
||||||
|
arrival_packages_count = str(body.get("arrival_packages_count") or "").strip()
|
||||||
|
if not assembly_id or not arrival_date:
|
||||||
|
return {"error": "missing_fields"}
|
||||||
|
|
||||||
|
_ensure_assemblies_sheet()
|
||||||
|
asm = sheets.find_row("Assemblies", "id", assembly_id)
|
||||||
|
if not asm:
|
||||||
|
return {"error": "assembly_not_found"}
|
||||||
|
|
||||||
|
sheets.update_cell_by_key("Assemblies", "id", assembly_id, "arrival_date", arrival_date)
|
||||||
|
sheets.update_cell_by_key("Assemblies", "id", assembly_id, "arrival_packages_count", arrival_packages_count)
|
||||||
|
sheets.update_cell_by_key("Assemblies", "id", assembly_id, "arrival_confirmed_by_tg_id", str(tg_id))
|
||||||
|
sheets.update_cell_by_key("Assemblies", "id", assembly_id, "status", "arrived")
|
||||||
|
|
||||||
|
# Проверяем расхождение упаковок
|
||||||
|
try:
|
||||||
|
expected = int(asm.get("packages_count") or 0)
|
||||||
|
actual = int(arrival_packages_count or 0)
|
||||||
|
if expected > 0 and actual != expected:
|
||||||
|
mgr_id = asm.get("manager_tg_id")
|
||||||
|
if mgr_id:
|
||||||
|
tg.send_message(int(mgr_id),
|
||||||
|
f"⚠️ <b>Расхождение упаковок на складе</b>\n"
|
||||||
|
f"Клиент: {asm.get('client_name','')}\n"
|
||||||
|
f"Ожидалось: {expected} · Принято: {actual}")
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("arrival check error: %s", e)
|
||||||
|
|
||||||
|
return {"ok": True, "status": "arrived"}
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_assembly_assign_dispatch(body):
|
||||||
|
cfg = get_config()
|
||||||
|
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:
|
||||||
|
return {"error": "user_not_found"}
|
||||||
|
if not (sheets.has_role(user, "dispatcher") or sheets.has_role(user, "manager")):
|
||||||
|
return {"error": "forbidden"}
|
||||||
|
|
||||||
|
assembly_id = (body.get("assembly_id") or "").strip()
|
||||||
|
scheduled_at = (body.get("scheduled_at") or "").strip()
|
||||||
|
expeditor_tg_id = str(body.get("expeditor_tg_id") or "").strip()
|
||||||
|
if not assembly_id or not scheduled_at:
|
||||||
|
return {"error": "missing_fields"}
|
||||||
|
|
||||||
|
_ensure_assemblies_sheet()
|
||||||
|
asm = sheets.find_row("Assemblies", "id", assembly_id)
|
||||||
|
if not asm:
|
||||||
|
return {"error": "assembly_not_found"}
|
||||||
|
|
||||||
|
sheets.update_cell_by_key("Assemblies", "id", assembly_id, "scheduled_at", scheduled_at)
|
||||||
|
if expeditor_tg_id:
|
||||||
|
sheets.update_cell_by_key("Assemblies", "id", assembly_id, "expeditor_tg_id", expeditor_tg_id)
|
||||||
|
sheets.update_cell_by_key("Assemblies", "id", assembly_id, "status", "scheduled")
|
||||||
|
|
||||||
|
# Уведомить экспедитора
|
||||||
|
try:
|
||||||
|
exp_id = int(expeditor_tg_id) if expeditor_tg_id else None
|
||||||
|
if exp_id:
|
||||||
|
tg.send_message(exp_id,
|
||||||
|
f"📋 <b>Назначена сборка</b>\n"
|
||||||
|
f"Клиент: {asm.get('client_name','')}\n"
|
||||||
|
f"Адрес: {asm.get('address','')}\n"
|
||||||
|
f"Дата: {scheduled_at[:10]}")
|
||||||
|
mgr_id = asm.get("manager_tg_id")
|
||||||
|
if mgr_id:
|
||||||
|
tg.send_message(int(mgr_id),
|
||||||
|
f"✅ <b>Сборка назначена</b>\n"
|
||||||
|
f"Клиент: {asm.get('client_name','')}\n"
|
||||||
|
f"Дата: {scheduled_at[:10]}\n"
|
||||||
|
f"Экспедитор: tg:{expeditor_tg_id or '—'}")
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("dispatch notify error: %s", e)
|
||||||
|
|
||||||
|
return {"ok": True, "status": "scheduled"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/dispatcher_inbox")
|
||||||
|
async def api_dispatcher_inbox(request: Request):
|
||||||
|
body = await _safe_json(request)
|
||||||
|
return JSONResponse(_handle_dispatcher_inbox(body))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/assembly_set_shipment")
|
||||||
|
async def api_assembly_set_shipment(request: Request):
|
||||||
|
body = await _safe_json(request)
|
||||||
|
return JSONResponse(_handle_assembly_set_shipment(body))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/assembly_set_arrival")
|
||||||
|
async def api_assembly_set_arrival(request: Request):
|
||||||
|
body = await _safe_json(request)
|
||||||
|
return JSONResponse(_handle_assembly_set_arrival(body))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/assembly_assign_dispatch")
|
||||||
|
async def api_assembly_assign_dispatch(request: Request):
|
||||||
|
body = await _safe_json(request)
|
||||||
|
return JSONResponse(_handle_assembly_assign_dispatch(body))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/expeditor_inbox")
|
@app.post("/api/expeditor_inbox")
|
||||||
async def api_expeditor_inbox(request: Request):
|
async def api_expeditor_inbox(request: Request):
|
||||||
body = await _safe_json(request)
|
body = await _safe_json(request)
|
||||||
@ -4721,6 +4953,9 @@ def _assembly_columns() -> list[str]:
|
|||||||
# Обратная связь
|
# Обратная связь
|
||||||
"client_feedback_at", # ISO — когда клиент оставил оценку
|
"client_feedback_at", # ISO — когда клиент оставил оценку
|
||||||
"archived_at",
|
"archived_at",
|
||||||
|
# Логистика (диспетчер)
|
||||||
|
"shipment_date", "packages_count",
|
||||||
|
"arrival_date", "arrival_packages_count", "arrival_confirmed_by_tg_id",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -890,9 +890,14 @@ async function renderStaff(me) {
|
|||||||
if (caps.measurer) labels.push("замерщик");
|
if (caps.measurer) labels.push("замерщик");
|
||||||
if (caps.assembler) labels.push("сборщик");
|
if (caps.assembler) labels.push("сборщик");
|
||||||
if (caps.expeditor) labels.push("экспедитор");
|
if (caps.expeditor) labels.push("экспедитор");
|
||||||
|
if (caps.dispatcher) labels.push("диспетчер");
|
||||||
const subtitle = labels.length ? labels.join(" · ") : "сотрудник";
|
const subtitle = labels.length ? labels.join(" · ") : "сотрудник";
|
||||||
|
|
||||||
// Экспедитор — отдельный экран
|
// Экспедитор — отдельный экран
|
||||||
|
if (caps.dispatcher && !caps.measurer && !caps.assembler && !caps.expeditor) {
|
||||||
|
location.hash = "#/dispatcher";
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (caps.expeditor && !caps.measurer && !caps.assembler) {
|
if (caps.expeditor && !caps.measurer && !caps.assembler) {
|
||||||
_renderExpeditorScreen(app, me);
|
_renderExpeditorScreen(app, me);
|
||||||
return;
|
return;
|
||||||
@ -1960,6 +1965,9 @@ function routeByHash() {
|
|||||||
} else if (location.hash === "#/expeditor") {
|
} else if (location.hash === "#/expeditor") {
|
||||||
if (typeof ExpeditorDashboard !== "undefined") ExpeditorDashboard.mount(app);
|
if (typeof ExpeditorDashboard !== "undefined") ExpeditorDashboard.mount(app);
|
||||||
else init();
|
else init();
|
||||||
|
} else if (location.hash === "#/dispatcher") {
|
||||||
|
if (typeof DispatcherDashboard !== "undefined") DispatcherDashboard.mount(app);
|
||||||
|
else init();
|
||||||
} else if (location.hash.startsWith("#/expeditor/act/")) {
|
} else if (location.hash.startsWith("#/expeditor/act/")) {
|
||||||
const asmId = location.hash.replace("#/expeditor/act/", "").split("?")[0];
|
const asmId = location.hash.replace("#/expeditor/act/", "").split("?")[0];
|
||||||
if (typeof ExpeditorDashboard !== "undefined") ExpeditorDashboard.mountAct(app, asmId);
|
if (typeof ExpeditorDashboard !== "undefined") ExpeditorDashboard.mountAct(app, asmId);
|
||||||
|
|||||||
301
miniapp/assets/dispatcher_dashboard.js
Normal file
301
miniapp/assets/dispatcher_dashboard.js
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
// dispatcher_dashboard.js v=20260521a
|
||||||
|
const DispatcherDashboard = (function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const STATUS_LABEL = {
|
||||||
|
created: "Новая",
|
||||||
|
shipped: "В пути",
|
||||||
|
arrived: "На складе",
|
||||||
|
scheduled: "Назначена",
|
||||||
|
in_progress: "В работе",
|
||||||
|
completed: "Завершена",
|
||||||
|
};
|
||||||
|
const STATUS_COLOR = {
|
||||||
|
created: "#FFF3CD",
|
||||||
|
shipped: "#CCE5FF",
|
||||||
|
arrived: "#D4EDDA",
|
||||||
|
scheduled: "#E2D9F3",
|
||||||
|
in_progress: "#FFE0B2",
|
||||||
|
completed: "#E8F5E9",
|
||||||
|
};
|
||||||
|
const STATUS_TEXT_COLOR = {
|
||||||
|
created: "#856404",
|
||||||
|
shipped: "#004085",
|
||||||
|
arrived: "#155724",
|
||||||
|
scheduled: "#4A235A",
|
||||||
|
in_progress: "#E65100",
|
||||||
|
completed: "#1B5E20",
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmt(iso) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = iso.slice(0, 10).split("-");
|
||||||
|
return d[2] + "." + d[1] + "." + d[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function el(html) {
|
||||||
|
const t = document.createElement("div");
|
||||||
|
t.innerHTML = html.trim();
|
||||||
|
return t.firstChild;
|
||||||
|
}
|
||||||
|
|
||||||
|
function badge(status) {
|
||||||
|
return `<span style="background:${STATUS_COLOR[status] || "#eee"};color:${STATUS_TEXT_COLOR[status] || "#333"};padding:2px 8px;border-radius:6px;font-size:12px;font-weight:600">${STATUS_LABEL[status] || status}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(container, msg) {
|
||||||
|
container.innerHTML = `<div style="padding:32px;text-align:center;color:var(--danger)">${msg}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MAIN LIST ────────────────────────────────────────────────
|
||||||
|
async function mount(container) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;padding:16px;background:var(--surface);border-bottom:1px solid #eee;position:sticky;top:0;z-index:10">
|
||||||
|
<button onclick="location.hash='#/'" style="background:none;border:none;font-size:22px;cursor:pointer;padding:0;line-height:1">←</button>
|
||||||
|
<span style="font-size:18px;font-weight:700">📦 Диспетчер</span>
|
||||||
|
</div>
|
||||||
|
<div id="disp-body" style="padding:12px"></div>`;
|
||||||
|
|
||||||
|
const body = container.querySelector("#disp-body");
|
||||||
|
body.innerHTML = `<div style="text-align:center;padding:40px;color:var(--muted)">Загрузка…</div>`;
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await _api("/api/dispatcher_inbox", {});
|
||||||
|
} catch (e) {
|
||||||
|
showError(body, "Ошибка сети: " + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.error) { showError(body, data.error); return; }
|
||||||
|
|
||||||
|
const items = data.assemblies || [];
|
||||||
|
if (!items.length) {
|
||||||
|
body.innerHTML = `<div style="text-align:center;padding:40px;color:var(--muted)">Нет сборок</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by status
|
||||||
|
const GROUPS = ["created","shipped","arrived","scheduled","in_progress","completed"];
|
||||||
|
const grouped = {};
|
||||||
|
GROUPS.forEach(s => { grouped[s] = []; });
|
||||||
|
items.forEach(a => { (grouped[a.status] || (grouped["created"])).push(a); });
|
||||||
|
|
||||||
|
body.innerHTML = "";
|
||||||
|
GROUPS.forEach(status => {
|
||||||
|
const list = grouped[status];
|
||||||
|
if (!list.length) return;
|
||||||
|
const section = el(`<div style="margin-bottom:20px">
|
||||||
|
<div style="font-size:13px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;padding:4px 0 8px">${STATUS_LABEL[status]} (${list.length})</div>
|
||||||
|
</div>`);
|
||||||
|
list.forEach(a => section.appendChild(_card(a, container)));
|
||||||
|
body.appendChild(section);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _card(a, container) {
|
||||||
|
const card = el(`<div style="background:var(--surface);border-radius:var(--radius);padding:14px 16px;margin-bottom:8px;cursor:pointer;box-shadow:0 1px 4px rgba(0,0,0,.07)">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px">
|
||||||
|
<span style="font-weight:600;font-size:15px">${a.client_name || "—"}</span>
|
||||||
|
${badge(a.status)}
|
||||||
|
</div>
|
||||||
|
<div style="font-size:13px;color:var(--muted);margin-bottom:4px">📍 ${a.address || "—"}</div>
|
||||||
|
${a.shipment_date ? `<div style="font-size:12px;color:var(--muted)">🚚 Отгружено: ${fmt(a.shipment_date)} · ${a.packages_count || "?"} уп.</div>` : ""}
|
||||||
|
${a.arrival_date ? `<div style="font-size:12px;color:var(--muted)">🏭 Принято: ${fmt(a.arrival_date)} · ${a.arrival_packages_count || "?"} уп.</div>` : ""}
|
||||||
|
${a.scheduled_at ? `<div style="font-size:12px;color:var(--muted)">📅 Назначено: ${fmt(a.scheduled_at)}</div>` : ""}
|
||||||
|
</div>`);
|
||||||
|
card.addEventListener("click", () => mountDetail(container, a));
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── DETAIL / EDIT ────────────────────────────────────────────
|
||||||
|
async function mountDetail(container, a) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;padding:16px;background:var(--surface);border-bottom:1px solid #eee;position:sticky;top:0;z-index:10">
|
||||||
|
<button id="back-btn" style="background:none;border:none;font-size:22px;cursor:pointer;padding:0;line-height:1">←</button>
|
||||||
|
<span style="font-size:17px;font-weight:700;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${a.client_name}</span>
|
||||||
|
${badge(a.status)}
|
||||||
|
</div>
|
||||||
|
<div style="padding:16px" id="detail-body"></div>`;
|
||||||
|
|
||||||
|
container.querySelector("#back-btn").addEventListener("click", () => mount(container));
|
||||||
|
const body = container.querySelector("#detail-body");
|
||||||
|
|
||||||
|
// Info block
|
||||||
|
body.appendChild(el(`<div style="background:var(--surface);border-radius:var(--radius);padding:14px;margin-bottom:16px;box-shadow:0 1px 4px rgba(0,0,0,.07)">
|
||||||
|
<div style="font-size:13px;color:var(--muted);margin-bottom:4px">📍 ${a.address || "—"}</div>
|
||||||
|
${a.scope_of_work ? `<div style="font-size:13px;margin-bottom:4px">📝 ${a.scope_of_work}</div>` : ""}
|
||||||
|
${a.client_phone ? `<div style="font-size:13px;color:var(--muted)">📞 ${a.client_phone}</div>` : ""}
|
||||||
|
${a.manager_note ? `<div style="font-size:13px;color:var(--muted);margin-top:4px">💬 ${a.manager_note}</div>` : ""}
|
||||||
|
</div>`));
|
||||||
|
|
||||||
|
// Step 1: Shipment
|
||||||
|
body.appendChild(_stepShipment(a, container));
|
||||||
|
// Step 2: Arrival
|
||||||
|
body.appendChild(_stepArrival(a, container));
|
||||||
|
// Step 3: Dispatch
|
||||||
|
body.appendChild(_stepDispatch(a, container));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── STEP 1: ОТГРУЗКА ─────────────────────────────────────────
|
||||||
|
function _stepShipment(a, container) {
|
||||||
|
const done = !!a.shipment_date;
|
||||||
|
const wrap = el(`<div style="background:var(--surface);border-radius:var(--radius);padding:14px;margin-bottom:12px;box-shadow:0 1px 4px rgba(0,0,0,.07)">
|
||||||
|
<div style="font-weight:700;margin-bottom:10px;display:flex;align-items:center;gap:8px">
|
||||||
|
<span style="font-size:18px">${done ? "✅" : "1️⃣"}</span>
|
||||||
|
<span>Отгрузка с фабрики</span>
|
||||||
|
</div>
|
||||||
|
${done ? `<div style="font-size:13px;color:var(--muted)">Дата: ${fmt(a.shipment_date)} · Упаковок: ${a.packages_count || "—"}</div>` : ""}
|
||||||
|
</div>`);
|
||||||
|
|
||||||
|
if (!done) {
|
||||||
|
const form = el(`<div>
|
||||||
|
<div style="margin-bottom:8px">
|
||||||
|
<label style="font-size:13px;color:var(--muted);display:block;margin-bottom:4px">Дата отгрузки</label>
|
||||||
|
<input type="date" id="ship-date" style="width:100%;padding:10px;border:1px solid #ddd;border-radius:8px;font-size:15px;box-sizing:border-box">
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:12px">
|
||||||
|
<label style="font-size:13px;color:var(--muted);display:block;margin-bottom:4px">Кол-во упаковок</label>
|
||||||
|
<input type="number" id="ship-count" min="1" placeholder="0" style="width:100%;padding:10px;border:1px solid #ddd;border-radius:8px;font-size:15px;box-sizing:border-box">
|
||||||
|
</div>
|
||||||
|
<button id="ship-save" style="width:100%;padding:12px;background:var(--accent);color:#fff;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer">Сохранить отгрузку</button>
|
||||||
|
</div>`);
|
||||||
|
form.querySelector("#ship-save").addEventListener("click", async () => {
|
||||||
|
const date = form.querySelector("#ship-date").value;
|
||||||
|
const count = form.querySelector("#ship-count").value;
|
||||||
|
if (!date) { alert("Укажите дату отгрузки"); return; }
|
||||||
|
haptic && haptic("impact");
|
||||||
|
const btn = form.querySelector("#ship-save");
|
||||||
|
btn.disabled = true; btn.textContent = "Сохраняем…";
|
||||||
|
const res = await _api("/api/assembly_set_shipment", {
|
||||||
|
assembly_id: a.id, shipment_date: date, packages_count: count
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
a.shipment_date = date; a.packages_count = count; a.status = "shipped";
|
||||||
|
mountDetail(container, a);
|
||||||
|
} else {
|
||||||
|
alert(res.error || "Ошибка"); btn.disabled = false; btn.textContent = "Сохранить отгрузку";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
wrap.appendChild(form);
|
||||||
|
}
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── STEP 2: ПРИЁМКА НА СКЛАД ─────────────────────────────────
|
||||||
|
function _stepArrival(a, container) {
|
||||||
|
const done = !!a.arrival_date;
|
||||||
|
const enabled = !!a.shipment_date;
|
||||||
|
const wrap = el(`<div style="background:var(--surface);border-radius:var(--radius);padding:14px;margin-bottom:12px;box-shadow:0 1px 4px rgba(0,0,0,.07);opacity:${enabled ? 1 : .45}">
|
||||||
|
<div style="font-weight:700;margin-bottom:10px;display:flex;align-items:center;gap:8px">
|
||||||
|
<span style="font-size:18px">${done ? "✅" : "2️⃣"}</span>
|
||||||
|
<span>Приёмка на склад</span>
|
||||||
|
</div>
|
||||||
|
${done ? `<div style="font-size:13px;color:var(--muted)">Дата: ${fmt(a.arrival_date)} · Принято: ${a.arrival_packages_count || "—"} уп.</div>` : ""}
|
||||||
|
</div>`);
|
||||||
|
|
||||||
|
if (enabled && !done) {
|
||||||
|
const form = el(`<div>
|
||||||
|
<div style="margin-bottom:8px">
|
||||||
|
<label style="font-size:13px;color:var(--muted);display:block;margin-bottom:4px">Дата приёмки</label>
|
||||||
|
<input type="date" id="arr-date" style="width:100%;padding:10px;border:1px solid #ddd;border-radius:8px;font-size:15px;box-sizing:border-box">
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:12px">
|
||||||
|
<label style="font-size:13px;color:var(--muted);display:block;margin-bottom:4px">Фактически принято упаковок</label>
|
||||||
|
<input type="number" id="arr-count" min="0" placeholder="${a.packages_count || '0'}" style="width:100%;padding:10px;border:1px solid #ddd;border-radius:8px;font-size:15px;box-sizing:border-box">
|
||||||
|
</div>
|
||||||
|
<button id="arr-save" style="width:100%;padding:12px;background:#2e7d32;color:#fff;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer">Подтвердить приёмку</button>
|
||||||
|
</div>`);
|
||||||
|
form.querySelector("#arr-save").addEventListener("click", async () => {
|
||||||
|
const date = form.querySelector("#arr-date").value;
|
||||||
|
const count = form.querySelector("#arr-count").value;
|
||||||
|
if (!date) { alert("Укажите дату приёмки"); return; }
|
||||||
|
haptic && haptic("impact");
|
||||||
|
const btn = form.querySelector("#arr-save");
|
||||||
|
btn.disabled = true; btn.textContent = "Сохраняем…";
|
||||||
|
const res = await _api("/api/assembly_set_arrival", {
|
||||||
|
assembly_id: a.id, arrival_date: date, arrival_packages_count: count
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
a.arrival_date = date; a.arrival_packages_count = count; a.status = "arrived";
|
||||||
|
mountDetail(container, a);
|
||||||
|
} else {
|
||||||
|
alert(res.error || "Ошибка"); btn.disabled = false; btn.textContent = "Подтвердить приёмку";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
wrap.appendChild(form);
|
||||||
|
}
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── STEP 3: НАЗНАЧИТЬ ЭКСПЕДИТОРА И ДАТУ ─────────────────────
|
||||||
|
function _stepDispatch(a, container) {
|
||||||
|
const done = a.status === "scheduled" || a.status === "in_progress" || a.status === "completed";
|
||||||
|
const enabled = !!a.arrival_date;
|
||||||
|
const wrap = el(`<div style="background:var(--surface);border-radius:var(--radius);padding:14px;margin-bottom:12px;box-shadow:0 1px 4px rgba(0,0,0,.07);opacity:${enabled ? 1 : .45}">
|
||||||
|
<div style="font-weight:700;margin-bottom:10px;display:flex;align-items:center;gap:8px">
|
||||||
|
<span style="font-size:18px">${done ? "✅" : "3️⃣"}</span>
|
||||||
|
<span>Назначить дату и экспедитора</span>
|
||||||
|
</div>
|
||||||
|
${done ? `<div style="font-size:13px;color:var(--muted)">Дата: ${fmt(a.scheduled_at)}${a.expeditor_tg_id ? " · Экспедитор: tg:" + a.expeditor_tg_id : ""}</div>` : ""}
|
||||||
|
</div>`);
|
||||||
|
|
||||||
|
if (enabled && !done) {
|
||||||
|
const form = el(`<div>
|
||||||
|
<div style="margin-bottom:8px">
|
||||||
|
<label style="font-size:13px;color:var(--muted);display:block;margin-bottom:4px">Дата сборки у клиента</label>
|
||||||
|
<input type="date" id="disp-date" style="width:100%;padding:10px;border:1px solid #ddd;border-radius:8px;font-size:15px;box-sizing:border-box">
|
||||||
|
</div>
|
||||||
|
<div id="exp-picker" style="margin-bottom:12px">
|
||||||
|
<label style="font-size:13px;color:var(--muted);display:block;margin-bottom:4px">Экспедитор</label>
|
||||||
|
<div style="font-size:13px;color:var(--muted)">Загрузка…</div>
|
||||||
|
</div>
|
||||||
|
<button id="disp-save" style="width:100%;padding:12px;background:var(--accent);color:#fff;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer">Назначить</button>
|
||||||
|
</div>`);
|
||||||
|
|
||||||
|
// Load expeditors list
|
||||||
|
_loadExpeditors(form.querySelector("#exp-picker"));
|
||||||
|
|
||||||
|
form.querySelector("#disp-save").addEventListener("click", async () => {
|
||||||
|
const date = form.querySelector("#disp-date").value;
|
||||||
|
const sel = form.querySelector("#exp-select");
|
||||||
|
const expId = sel ? sel.value : "";
|
||||||
|
if (!date) { alert("Укажите дату сборки"); return; }
|
||||||
|
haptic && haptic("impact");
|
||||||
|
const btn = form.querySelector("#disp-save");
|
||||||
|
btn.disabled = true; btn.textContent = "Назначаем…";
|
||||||
|
const res = await _api("/api/assembly_assign_dispatch", {
|
||||||
|
assembly_id: a.id, scheduled_at: date, expeditor_tg_id: expId
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
a.scheduled_at = date; a.expeditor_tg_id = expId; a.status = "scheduled";
|
||||||
|
mountDetail(container, a);
|
||||||
|
} else {
|
||||||
|
alert(res.error || "Ошибка"); btn.disabled = false; btn.textContent = "Назначить";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
wrap.appendChild(form);
|
||||||
|
}
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _loadExpeditors(container) {
|
||||||
|
try {
|
||||||
|
const res = await _api("/api/staff_list", { role: "expeditor" });
|
||||||
|
const users = res.users || res.staff || [];
|
||||||
|
if (!users.length) {
|
||||||
|
container.innerHTML = `<label style="font-size:13px;color:var(--muted);display:block;margin-bottom:4px">Экспедитор</label><div style="font-size:13px;color:var(--muted)">Нет экспедиторов</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const opts = users.map(u => `<option value="${u.tg_id}">${u.name || u.first_name || u.tg_id}</option>`).join("");
|
||||||
|
container.innerHTML = `<label style="font-size:13px;color:var(--muted);display:block;margin-bottom:4px">Экспедитор</label>
|
||||||
|
<select id="exp-select" style="width:100%;padding:10px;border:1px solid #ddd;border-radius:8px;font-size:15px;background:#fff;box-sizing:border-box">
|
||||||
|
<option value="">— не назначен —</option>${opts}
|
||||||
|
</select>`;
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = `<label style="font-size:13px;color:var(--muted);display:block;margin-bottom:4px">Экспедитор</label>
|
||||||
|
<input id="exp-select" placeholder="Telegram ID" style="width:100%;padding:10px;border:1px solid #ddd;border-radius:8px;font-size:15px;box-sizing:border-box">`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mount, mountDetail };
|
||||||
|
})();
|
||||||
@ -67,6 +67,7 @@
|
|||||||
<script src="assets/act4.js?v=20260519a"></script>
|
<script src="assets/act4.js?v=20260519a"></script>
|
||||||
<script src="assets/feedback.js?v=20260519a"></script>
|
<script src="assets/feedback.js?v=20260519a"></script>
|
||||||
<script src="assets/expeditor_dashboard.js?v=20260521b"></script>
|
<script src="assets/expeditor_dashboard.js?v=20260521b"></script>
|
||||||
|
<script src="assets/dispatcher_dashboard.js?v=20260521a"></script>
|
||||||
<script src="assets/app.js?v=20260521c"></script>
|
<script src="assets/app.js?v=20260521c"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user