diff --git a/backend-py/app/main.py b/backend-py/app/main.py
index 6058382..c970873 100644
--- a/backend-py/app/main.py
+++ b/backend-py/app/main.py
@@ -154,6 +154,7 @@ async def _dispatch_post(request: Request):
"assembly_rate_delete": _handle_assembly_rate_delete,
"assembler_analytics": _handle_assembler_analytics,
"assembler_earnings": _handle_assembler_earnings,
+ "staff_clients": _handle_staff_clients,
"contract_preview": _handle_contract_preview,
"contract_save": _handle_contract_save,
"proposal_brief": proposals_mod.handle_brief,
@@ -3084,6 +3085,147 @@ async def api_assembler_earnings(request: Request):
return _handle_assembler_earnings(body)
+def _handle_staff_clients(body: dict[str, Any]) -> dict[str, Any]:
+ """Список клиентов для сборщика / замерщика.
+ Assembler: все сборки где assigned_to_tg_id == self.
+ Measurer: все замеры где assigned_to_tg_id == self.
+ Оба: объединённый список, сгруппированный по клиенту.
+ body: {initData, initDataUnsafe, filter?: 'active'|'done'|'all'}
+ """
+ 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_assembler = sheets.has_role(user, "assembler") or sheets.is_master(user)
+ is_measurer = sheets.has_role(user, "measurer")
+ if not is_assembler and not is_measurer and not sheets.has_role(user, "manager"):
+ return {"error": "forbidden"}
+
+ flt = str(body.get("filter") or "active").lower() # active | done | all
+
+ def _row_visible_active(status: str) -> bool:
+ if flt == "all": return True
+ if flt == "done": return status in ("done", "completed", "cancelled")
+ return status not in ("done", "completed", "cancelled", "archived")
+
+ clients: dict[str, dict] = {} # key → client card
+
+ # ── Сборки ──────────────────────────────────────────────────────
+ if is_assembler or sheets.has_role(user, "manager"):
+ try:
+ _ensure_assemblies_sheet()
+ ws = sheets.sheet("Assemblies")
+ rows = ws.get_all_values()
+ if rows and len(rows) > 1:
+ headers = rows[0]
+ for r in rows[1:]:
+ row = dict(zip(headers, r + [""] * max(0, len(headers) - len(r))))
+ if row.get("archived_at"):
+ continue
+ if sheets.has_role(user, "manager"):
+ if str(row.get("manager_tg_id")) != str(tg_id):
+ continue
+ else:
+ if str(row.get("assigned_to_tg_id")) != str(tg_id):
+ continue
+ status = row.get("status", "")
+ if not _row_visible_active(status):
+ continue
+ ckey = row.get("client_tg_id") or row.get("client_name", "").lower().strip()
+ if ckey not in clients:
+ clients[ckey] = {
+ "client_name": row.get("client_name", ""),
+ "client_phone": row.get("client_phone", ""),
+ "client_tg_id": row.get("client_tg_id", ""),
+ "assemblies": [],
+ "measurements": [],
+ }
+ clients[ckey]["assemblies"].append({
+ "id": row.get("id", ""),
+ "address": row.get("address", ""),
+ "status": status,
+ "scheduled_at": row.get("scheduled_at", ""),
+ "scope_of_work": row.get("scope_of_work", ""),
+ "signed_by_name": row.get("signed_by_name", ""),
+ "manager_tg_id": row.get("manager_tg_id", ""),
+ })
+ except Exception as e:
+ log.warning("staff_clients assemblies error: %s", e)
+
+ # ── Замеры ───────────────────────────────────────────────────────
+ if is_measurer or sheets.has_role(user, "manager"):
+ try:
+ ws2 = sheets.sheet("Measurements")
+ rows2 = ws2.get_all_values()
+ if rows2 and len(rows2) > 1:
+ headers2 = rows2[0]
+ for r in rows2[1:]:
+ row = dict(zip(headers2, r + [""] * max(0, len(headers2) - len(r))))
+ if row.get("archived_at"):
+ continue
+ if sheets.has_role(user, "manager"):
+ if str(row.get("manager_tg_id")) != str(tg_id):
+ continue
+ else:
+ if str(row.get("assigned_to_tg_id")) != str(tg_id):
+ continue
+ status = row.get("status", "")
+ if not _row_visible_active(status):
+ continue
+ ckey = row.get("client_tg_id") or row.get("client_name", "").lower().strip()
+ if ckey not in clients:
+ clients[ckey] = {
+ "client_name": row.get("client_name", ""),
+ "client_phone": row.get("client_phone", ""),
+ "client_tg_id": row.get("client_tg_id", ""),
+ "assemblies": [],
+ "measurements": [],
+ }
+ clients[ckey]["measurements"].append({
+ "id": row.get("id", ""),
+ "address": row.get("address", ""),
+ "status": status,
+ "scheduled_at": row.get("scheduled_at", ""),
+ "zamer_no": row.get("zamer_no", ""),
+ "layout": row.get("layout", ""),
+ })
+ except Exception as e:
+ log.warning("staff_clients measurements error: %s", e)
+
+ # ── Сортировка: сначала с ближайшей датой ───────────────────────
+ def _latest_date(c: dict) -> str:
+ dates = (
+ [a["scheduled_at"] for a in c["assemblies"] if a["scheduled_at"]] +
+ [m["scheduled_at"] for m in c["measurements"] if m["scheduled_at"]]
+ )
+ return max(dates) if dates else ""
+
+ result = sorted(clients.values(), key=_latest_date, reverse=True)
+
+ return {
+ "ok": True,
+ "is_assembler": is_assembler,
+ "is_measurer": is_measurer,
+ "count": len(result),
+ "clients": result,
+ }
+
+
+@app.post("/api/staff_clients")
+async def api_staff_clients(request: Request):
+ body = await _safe_json(request)
+ return _handle_staff_clients(body)
+
+
def _handle_contract_preview(body: dict[str, Any]) -> dict[str, Any]:
"""Возвращает данные сборки + сохранённые поля контракта для предпросмотра акта.
body: {initData, initDataUnsafe, assembly_id}
diff --git a/miniapp/assets/app.js b/miniapp/assets/app.js
index fe7aa66..f0e951d 100644
--- a/miniapp/assets/app.js
+++ b/miniapp/assets/app.js
@@ -912,6 +912,22 @@ async function renderStaff(me) {
renderStaffAssemblies(assemblySection.querySelector("#assemblyList"));
}
+ // Список клиентов — сборщик и/или замерщик
+ if (caps.assembler || caps.measurer) {
+ const clientsBtn = el(`
+
+
+
+ `);
+ clientsBtn.querySelector("button").addEventListener("click", () => {
+ haptic && haptic("impact");
+ location.hash = "#/master/clients";
+ });
+ app.appendChild(clientsBtn);
+ }
+
// Шпаргалки + заработки сборщика
if (caps.assembler) {
const earningsBtn = el(`
@@ -1716,6 +1732,9 @@ function routeByHash() {
} else if (location.hash.startsWith("#/admin/rates")) {
if (typeof AdminRates !== "undefined") AdminRates.mount(app);
else init();
+ } else if (location.hash === "#/master/clients") {
+ if (typeof StaffClients !== "undefined") StaffClients.mount(app);
+ else init();
} else if (location.hash === "#/master/dashboard") {
if (typeof AssemblerDashboard !== "undefined") AssemblerDashboard.mount(app);
else init();
diff --git a/miniapp/assets/staff_clients.js b/miniapp/assets/staff_clients.js
new file mode 100644
index 0000000..a4239cc
--- /dev/null
+++ b/miniapp/assets/staff_clients.js
@@ -0,0 +1,326 @@
+/* ============================================================
+ StaffClients — список клиентов для сборщика / замерщика
+ #/master/clients
+ ============================================================ */
+
+const StaffClients = (function () {
+ "use strict";
+
+ function escHtml(s) {
+ return String(s == null ? "" : s)
+ .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+ }
+ function el(html) {
+ const t = document.createElement("template");
+ t.innerHTML = html.trim();
+ return t.content.firstChild;
+ }
+ function fmtDate(iso) {
+ if (!iso) return null;
+ try {
+ return new Date(iso).toLocaleDateString("ru-RU", {
+ day: "numeric", month: "short", year: "numeric",
+ });
+ } catch { return iso.slice(0, 10); }
+ }
+
+ async function _api(path, body = {}) {
+ const ctrl = new AbortController();
+ const t = setTimeout(() => ctrl.abort(), 20000);
+ try {
+ const res = await fetch(`${BACKEND_URL}/api/${path}`, {
+ method: "POST", signal: ctrl.signal,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ initData: (typeof Platform !== "undefined" ? Platform.initData : (window.tg?.initData || "")),
+ initDataUnsafe: (typeof Platform !== "undefined" ? Platform.initDataUnsafe : null),
+ ...body,
+ }),
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return await res.json();
+ } catch (e) {
+ if (e.name === "AbortError") throw new Error("Сервер не отвечает");
+ throw e;
+ } finally { clearTimeout(t); }
+ }
+
+ const ASM_STATUS = {
+ created: { icon: "🆕", text: "Создана", color: "#8e8e8e" },
+ scheduled: { icon: "📅", text: "Запланирована", color: "#2980B9" },
+ in_progress: { icon: "🔨", text: "В процессе", color: "#F39C12" },
+ done: { icon: "✅", text: "Завершена", color: "#27AE60" },
+ cancelled: { icon: "❌", text: "Отменена", color: "#C0392B" },
+ };
+ const MEAS_STATUS = {
+ new: { icon: "🆕", text: "Новый", color: "#8e8e8e" },
+ scheduled: { icon: "📅", text: "Назначен", color: "#2980B9" },
+ done: { icon: "✅", text: "Выполнен", color: "#27AE60" },
+ cancelled: { icon: "❌", text: "Отменён", color: "#C0392B" },
+ };
+
+ function _statusBadge(status, map) {
+ const s = map[status] || { icon: "•", text: status, color: "#aaa" };
+ return `${s.icon} ${escHtml(s.text)}`;
+ }
+
+ /* ── Главный экран ─────────────────────────────────────────── */
+ async function mount(container) {
+ container.innerHTML = "";
+ document.body.classList.remove("has-bottom-nav");
+ document.getElementById("bottom-nav")?.remove();
+
+ // Header
+ const h = el(`
+
+ `);
+ h.querySelector(".podbor-back").addEventListener("click", () => {
+ haptic && haptic("impact");
+ history.back();
+ });
+
+ // Фильтр
+ const filterEl = el(`
+
+
+
+
+
+ `);
+
+ const screen = el(``);
+ container.appendChild(h);
+ container.appendChild(filterEl);
+ container.appendChild(screen);
+
+ let currentFilter = "active";
+
+ const load = async (filter) => {
+ currentFilter = filter;
+ screen.innerHTML = ``;
+ try {
+ const data = await _api("staff_clients", { filter });
+ if (data.error) {
+ screen.innerHTML = `${escHtml(data.error)}
`;
+ return;
+ }
+ _render(screen, data, container);
+ } catch (e) {
+ screen.innerHTML = `Ошибка: ${escHtml(e.message)}
`;
+ }
+ };
+
+ filterEl.querySelectorAll(".sc-filter").forEach(btn => {
+ btn.addEventListener("click", () => {
+ filterEl.querySelectorAll(".sc-filter").forEach(b => {
+ b.style.background = "var(--surface)";
+ b.style.color = "var(--muted)";
+ b.style.borderColor = "var(--border)";
+ });
+ btn.style.background = "var(--accent)";
+ btn.style.color = "#fff";
+ btn.style.borderColor = "var(--accent)";
+ haptic && haptic("selection");
+ load(btn.dataset.f);
+ });
+ });
+
+ h.querySelector("#reloadBtn").addEventListener("click", () => {
+ haptic && haptic("impact");
+ load(currentFilter);
+ });
+
+ load("active");
+ }
+
+ /* ── Рендер ─────────────────────────────────────────────────── */
+ function _render(screen, data, container) {
+ screen.innerHTML = "";
+
+ const clients = data.clients || [];
+ if (!clients.length) {
+ screen.appendChild(el(`
+
+
📋
+
Клиентов нет
+
По выбранному фильтру ничего не найдено
+
+ `));
+ return;
+ }
+
+ // Роль-бейдж в шапке
+ const roles = [];
+ if (data.is_assembler) roles.push("сборщик");
+ if (data.is_measurer) roles.push("замерщик");
+ if (roles.length) {
+ screen.appendChild(el(`
+
+ ${escHtml(roles.join(" · "))} · ${clients.length} клиентов
+
+ `));
+ }
+
+ clients.forEach(c => {
+ const asmCount = c.assemblies.length;
+ const measCount = c.measurements.length;
+
+ // Ближайшая дата
+ const dates = [
+ ...c.assemblies.map(a => a.scheduled_at),
+ ...c.measurements.map(m => m.scheduled_at),
+ ].filter(Boolean).sort();
+ const nearestDate = dates[0] || null;
+
+ // Статусы для превью
+ const asmStatuses = c.assemblies.map(a => a.status);
+ const measStatuses = c.measurements.map(m => m.status);
+
+ const card = el(`
+
+
+
+
+ ${escHtml(c.client_name || "Без имени")}
+
+ ${c.client_phone ? `
${escHtml(c.client_phone)}
` : ""}
+
+ ${nearestDate ? `
${escHtml(fmtDate(nearestDate))}
` : ""}
+
+
+
+
+ ${asmCount ? c.assemblies.map(a => `
+
+ ${_statusBadge(a.status, ASM_STATUS)}
+ ${a.address ? `${escHtml(a.address.split(",")[0])}` : ""}
+
+ `).join("") : ""}
+ ${measCount ? c.measurements.map(m => `
+
+ 📐 ${_statusBadge(m.status, MEAS_STATUS).replace(/<[^>]+>/g,'').trim()}
+
+ `).join("") : ""}
+
+
+ `);
+
+ card.addEventListener("click", () => {
+ haptic && haptic("impact");
+ _openClientDetail(container, c, data);
+ });
+
+ screen.appendChild(card);
+ });
+
+ screen.appendChild(el(``));
+ }
+
+ /* ── Детальная карточка клиента ────────────────────────────── */
+ function _openClientDetail(container, c, listData) {
+ container.innerHTML = "";
+
+ const h = el(`
+
+ `);
+ h.querySelector(".podbor-back").addEventListener("click", () => {
+ haptic && haptic("impact");
+ mount(container);
+ });
+
+ const screen = el(``);
+ container.appendChild(h);
+ container.appendChild(screen);
+
+ // Контакты
+ const phone = c.client_phone || "";
+ screen.appendChild(el(`
+
+ `));
+
+ // Сборки
+ if (c.assemblies.length) {
+ screen.appendChild(el(`🔨 Сборки · ${c.assemblies.length}
`));
+ c.assemblies.forEach(a => {
+ const s = ASM_STATUS[a.status] || { icon: "•", text: a.status, color: "#aaa" };
+ const asmCard = el(`
+
+
+
+
${s.icon} ${escHtml(s.text)}
+ ${a.address ? `
${escHtml(a.address)}
` : ""}
+ ${a.scope_of_work ? `
${escHtml(a.scope_of_work.slice(0,60))}
` : ""}
+
+
+ ${a.scheduled_at ? `
${escHtml(fmtDate(a.scheduled_at))}
` : ""}
+ ${a.signed_by_name ? `
✅ Подписан
` : ""}
+
+
+
+ `);
+ asmCard.addEventListener("click", () => {
+ haptic && haptic("impact");
+ if (typeof AssemblyDetailScreen !== "undefined") {
+ location.hash = `#/assembly/${a.id}`;
+ }
+ });
+ screen.appendChild(asmCard);
+ });
+ }
+
+ // Замеры
+ if (c.measurements.length) {
+ screen.appendChild(el(`📐 Замеры · ${c.measurements.length}
`));
+ c.measurements.forEach(m => {
+ const s = MEAS_STATUS[m.status] || { icon: "•", text: m.status, color: "#aaa" };
+ const mCard = el(`
+
+
+
+
${s.icon} ${escHtml(s.text)}
+ ${m.address ? `
${escHtml(m.address)}
` : ""}
+ ${m.zamer_no ? `
Замер №${escHtml(m.zamer_no)}
` : ""}
+
+ ${m.scheduled_at ? `
${escHtml(fmtDate(m.scheduled_at))}
` : ""}
+
+
+ `);
+ screen.appendChild(mCard);
+ });
+ }
+
+ screen.appendChild(el(``));
+ }
+
+ return { mount };
+})();
diff --git a/miniapp/index.html b/miniapp/index.html
index e462f8d..ef8f6f0 100644
--- a/miniapp/index.html
+++ b/miniapp/index.html
@@ -56,8 +56,9 @@
+
-
+