mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 18:24:49 +00:00
feat: staff client list for assembler/measurer (#/master/clients)
- /api/staff_clients — returns clients grouped by client, filtered by role
(assembler sees assemblies, measurer sees measurements, both if combined)
Supports filter: active | done | all
- staff_clients.js — client list with status tags + detail card view
(phone link, assembly cards → AssemblyDetailScreen, measurement cards)
- app.js — route #/master/clients, button "👥 Мои клиенты" for assembler+measurer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
21fd0ff3e5
commit
e8b9c68c5c
@ -154,6 +154,7 @@ async def _dispatch_post(request: Request):
|
|||||||
"assembly_rate_delete": _handle_assembly_rate_delete,
|
"assembly_rate_delete": _handle_assembly_rate_delete,
|
||||||
"assembler_analytics": _handle_assembler_analytics,
|
"assembler_analytics": _handle_assembler_analytics,
|
||||||
"assembler_earnings": _handle_assembler_earnings,
|
"assembler_earnings": _handle_assembler_earnings,
|
||||||
|
"staff_clients": _handle_staff_clients,
|
||||||
"contract_preview": _handle_contract_preview,
|
"contract_preview": _handle_contract_preview,
|
||||||
"contract_save": _handle_contract_save,
|
"contract_save": _handle_contract_save,
|
||||||
"proposal_brief": proposals_mod.handle_brief,
|
"proposal_brief": proposals_mod.handle_brief,
|
||||||
@ -3084,6 +3085,147 @@ async def api_assembler_earnings(request: Request):
|
|||||||
return _handle_assembler_earnings(body)
|
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]:
|
def _handle_contract_preview(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Возвращает данные сборки + сохранённые поля контракта для предпросмотра акта.
|
"""Возвращает данные сборки + сохранённые поля контракта для предпросмотра акта.
|
||||||
body: {initData, initDataUnsafe, assembly_id}
|
body: {initData, initDataUnsafe, assembly_id}
|
||||||
|
|||||||
@ -912,6 +912,22 @@ async function renderStaff(me) {
|
|||||||
renderStaffAssemblies(assemblySection.querySelector("#assemblyList"));
|
renderStaffAssemblies(assemblySection.querySelector("#assemblyList"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Список клиентов — сборщик и/или замерщик
|
||||||
|
if (caps.assembler || caps.measurer) {
|
||||||
|
const clientsBtn = el(`
|
||||||
|
<div class="podbor-cta-row" style="margin-top:16px;">
|
||||||
|
<button class="btn-primary" style="gap:8px;">
|
||||||
|
👥 Мои клиенты
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
clientsBtn.querySelector("button").addEventListener("click", () => {
|
||||||
|
haptic && haptic("impact");
|
||||||
|
location.hash = "#/master/clients";
|
||||||
|
});
|
||||||
|
app.appendChild(clientsBtn);
|
||||||
|
}
|
||||||
|
|
||||||
// Шпаргалки + заработки сборщика
|
// Шпаргалки + заработки сборщика
|
||||||
if (caps.assembler) {
|
if (caps.assembler) {
|
||||||
const earningsBtn = el(`
|
const earningsBtn = el(`
|
||||||
@ -1716,6 +1732,9 @@ function routeByHash() {
|
|||||||
} else if (location.hash.startsWith("#/admin/rates")) {
|
} else if (location.hash.startsWith("#/admin/rates")) {
|
||||||
if (typeof AdminRates !== "undefined") AdminRates.mount(app);
|
if (typeof AdminRates !== "undefined") AdminRates.mount(app);
|
||||||
else init();
|
else init();
|
||||||
|
} else if (location.hash === "#/master/clients") {
|
||||||
|
if (typeof StaffClients !== "undefined") StaffClients.mount(app);
|
||||||
|
else init();
|
||||||
} else if (location.hash === "#/master/dashboard") {
|
} else if (location.hash === "#/master/dashboard") {
|
||||||
if (typeof AssemblerDashboard !== "undefined") AssemblerDashboard.mount(app);
|
if (typeof AssemblerDashboard !== "undefined") AssemblerDashboard.mount(app);
|
||||||
else init();
|
else init();
|
||||||
|
|||||||
326
miniapp/assets/staff_clients.js
Normal file
326
miniapp/assets/staff_clients.js
Normal file
@ -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, ">").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 `<span style="font-size:11px;color:${s.color};white-space:nowrap;">${s.icon} ${escHtml(s.text)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Главный экран ─────────────────────────────────────────── */
|
||||||
|
async function mount(container) {
|
||||||
|
container.innerHTML = "";
|
||||||
|
document.body.classList.remove("has-bottom-nav");
|
||||||
|
document.getElementById("bottom-nav")?.remove();
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const h = el(`
|
||||||
|
<header class="podbor-header">
|
||||||
|
<button class="podbor-back" aria-label="Назад">${(window.ICONS || {}).arrow_left || "‹"}</button>
|
||||||
|
<div class="podbor-title">Мои клиенты</div>
|
||||||
|
<button id="reloadBtn" style="background:none;border:none;font-size:18px;cursor:pointer;padding:4px 8px;" title="Обновить">↻</button>
|
||||||
|
</header>
|
||||||
|
`);
|
||||||
|
h.querySelector(".podbor-back").addEventListener("click", () => {
|
||||||
|
haptic && haptic("impact");
|
||||||
|
history.back();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Фильтр
|
||||||
|
const filterEl = el(`
|
||||||
|
<div style="padding:0 16px 8px;display:flex;gap:8px;">
|
||||||
|
<button class="sc-filter active" data-f="active" style="padding:6px 14px;border-radius:20px;border:1px solid var(--accent);background:var(--accent);color:#fff;font-size:12px;cursor:pointer;">Активные</button>
|
||||||
|
<button class="sc-filter" data-f="done" style="padding:6px 14px;border-radius:20px;border:1px solid var(--border);background:var(--surface);color:var(--muted);font-size:12px;cursor:pointer;">Завершённые</button>
|
||||||
|
<button class="sc-filter" data-f="all" style="padding:6px 14px;border-radius:20px;border:1px solid var(--border);background:var(--surface);color:var(--muted);font-size:12px;cursor:pointer;">Все</button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const screen = el(`<div class="podbor-screen"></div>`);
|
||||||
|
container.appendChild(h);
|
||||||
|
container.appendChild(filterEl);
|
||||||
|
container.appendChild(screen);
|
||||||
|
|
||||||
|
let currentFilter = "active";
|
||||||
|
|
||||||
|
const load = async (filter) => {
|
||||||
|
currentFilter = filter;
|
||||||
|
screen.innerHTML = `<div class="loader-inline"><div class="spinner"></div></div>`;
|
||||||
|
try {
|
||||||
|
const data = await _api("staff_clients", { filter });
|
||||||
|
if (data.error) {
|
||||||
|
screen.innerHTML = `<div class="error" style="margin:16px;">${escHtml(data.error)}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_render(screen, data, container);
|
||||||
|
} catch (e) {
|
||||||
|
screen.innerHTML = `<div class="error" style="margin:16px;">Ошибка: ${escHtml(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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(`
|
||||||
|
<div style="text-align:center;padding:48px 16px;color:var(--muted);">
|
||||||
|
<div style="font-size:36px;margin-bottom:12px;">📋</div>
|
||||||
|
<div style="font-size:14px;font-weight:600;color:var(--ink);">Клиентов нет</div>
|
||||||
|
<div style="font-size:12px;margin-top:6px;">По выбранному фильтру ничего не найдено</div>
|
||||||
|
</div>
|
||||||
|
`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Роль-бейдж в шапке
|
||||||
|
const roles = [];
|
||||||
|
if (data.is_assembler) roles.push("сборщик");
|
||||||
|
if (data.is_measurer) roles.push("замерщик");
|
||||||
|
if (roles.length) {
|
||||||
|
screen.appendChild(el(`
|
||||||
|
<div style="margin:0 16px 10px;font-size:11px;color:var(--muted);">
|
||||||
|
${escHtml(roles.join(" · "))} · ${clients.length} клиентов
|
||||||
|
</div>
|
||||||
|
`));
|
||||||
|
}
|
||||||
|
|
||||||
|
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(`
|
||||||
|
<div style="margin:6px 16px;padding:14px;background:var(--surface);
|
||||||
|
border:1px solid var(--border);border-radius:14px;cursor:pointer;"
|
||||||
|
role="button" tabindex="0">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;">
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div style="font-size:15px;font-weight:700;color:var(--ink);
|
||||||
|
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
|
||||||
|
${escHtml(c.client_name || "Без имени")}
|
||||||
|
</div>
|
||||||
|
${c.client_phone ? `<div style="font-size:12px;color:var(--muted);margin-top:2px;">${escHtml(c.client_phone)}</div>` : ""}
|
||||||
|
</div>
|
||||||
|
${nearestDate ? `<div style="font-size:11px;color:var(--accent);white-space:nowrap;flex-shrink:0;font-weight:600;">${escHtml(fmtDate(nearestDate))}</div>` : ""}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Теги: сборки + замеры -->
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:10px;">
|
||||||
|
${asmCount ? c.assemblies.map(a => `
|
||||||
|
<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 8px;
|
||||||
|
background:var(--bg);border:1px solid var(--border);border-radius:10px;">
|
||||||
|
${_statusBadge(a.status, ASM_STATUS)}
|
||||||
|
${a.address ? `<span style="font-size:10px;color:var(--muted);max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escHtml(a.address.split(",")[0])}</span>` : ""}
|
||||||
|
</span>
|
||||||
|
`).join("") : ""}
|
||||||
|
${measCount ? c.measurements.map(m => `
|
||||||
|
<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 8px;
|
||||||
|
background:var(--bg);border:1px solid #7B68EE22;border-radius:10px;">
|
||||||
|
<span style="font-size:11px;color:#7B68EE;">📐 ${_statusBadge(m.status, MEAS_STATUS).replace(/<[^>]+>/g,'').trim()}</span>
|
||||||
|
</span>
|
||||||
|
`).join("") : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
card.addEventListener("click", () => {
|
||||||
|
haptic && haptic("impact");
|
||||||
|
_openClientDetail(container, c, data);
|
||||||
|
});
|
||||||
|
|
||||||
|
screen.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
screen.appendChild(el(`<div style="height:32px;"></div>`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Детальная карточка клиента ────────────────────────────── */
|
||||||
|
function _openClientDetail(container, c, listData) {
|
||||||
|
container.innerHTML = "";
|
||||||
|
|
||||||
|
const h = el(`
|
||||||
|
<header class="podbor-header">
|
||||||
|
<button class="podbor-back" aria-label="Назад">${(window.ICONS || {}).arrow_left || "‹"}</button>
|
||||||
|
<div class="podbor-title" style="font-size:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
||||||
|
${escHtml(c.client_name || "Клиент")}
|
||||||
|
</div>
|
||||||
|
<div style="width:32px;"></div>
|
||||||
|
</header>
|
||||||
|
`);
|
||||||
|
h.querySelector(".podbor-back").addEventListener("click", () => {
|
||||||
|
haptic && haptic("impact");
|
||||||
|
mount(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
const screen = el(`<div class="podbor-screen"></div>`);
|
||||||
|
container.appendChild(h);
|
||||||
|
container.appendChild(screen);
|
||||||
|
|
||||||
|
// Контакты
|
||||||
|
const phone = c.client_phone || "";
|
||||||
|
screen.appendChild(el(`
|
||||||
|
<div style="margin:0 16px 16px;padding:14px;background:var(--surface);
|
||||||
|
border:1px solid var(--border);border-radius:14px;">
|
||||||
|
<div style="font-size:16px;font-weight:700;color:var(--ink);margin-bottom:6px;">
|
||||||
|
${escHtml(c.client_name || "Без имени")}
|
||||||
|
</div>
|
||||||
|
${phone ? `
|
||||||
|
<a href="tel:${escHtml(phone)}" style="display:flex;align-items:center;gap:8px;
|
||||||
|
padding:10px 0;text-decoration:none;color:var(--accent);">
|
||||||
|
<span style="font-size:18px;">📞</span>
|
||||||
|
<span style="font-size:15px;font-weight:600;">${escHtml(phone)}</span>
|
||||||
|
</a>
|
||||||
|
` : `<div style="font-size:12px;color:var(--muted);">Телефон не указан</div>`}
|
||||||
|
</div>
|
||||||
|
`));
|
||||||
|
|
||||||
|
// Сборки
|
||||||
|
if (c.assemblies.length) {
|
||||||
|
screen.appendChild(el(`<div class="section-head"><span class="label">🔨 Сборки · ${c.assemblies.length}</span></div>`));
|
||||||
|
c.assemblies.forEach(a => {
|
||||||
|
const s = ASM_STATUS[a.status] || { icon: "•", text: a.status, color: "#aaa" };
|
||||||
|
const asmCard = el(`
|
||||||
|
<div style="margin:4px 16px;padding:12px 14px;background:var(--surface);
|
||||||
|
border:1px solid var(--border);border-radius:12px;cursor:pointer;">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:12px;font-weight:600;color:${s.color};">${s.icon} ${escHtml(s.text)}</div>
|
||||||
|
${a.address ? `<div style="font-size:12px;color:var(--muted);margin-top:2px;">${escHtml(a.address)}</div>` : ""}
|
||||||
|
${a.scope_of_work ? `<div style="font-size:11px;color:var(--muted);margin-top:2px;">${escHtml(a.scope_of_work.slice(0,60))}</div>` : ""}
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right;flex-shrink:0;">
|
||||||
|
${a.scheduled_at ? `<div style="font-size:11px;color:var(--accent);font-weight:600;">${escHtml(fmtDate(a.scheduled_at))}</div>` : ""}
|
||||||
|
${a.signed_by_name ? `<div style="font-size:10px;color:#27AE60;margin-top:2px;">✅ Подписан</div>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
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(`<div class="section-head" style="margin-top:16px;"><span class="label">📐 Замеры · ${c.measurements.length}</span></div>`));
|
||||||
|
c.measurements.forEach(m => {
|
||||||
|
const s = MEAS_STATUS[m.status] || { icon: "•", text: m.status, color: "#aaa" };
|
||||||
|
const mCard = el(`
|
||||||
|
<div style="margin:4px 16px;padding:12px 14px;background:var(--surface);
|
||||||
|
border:1px solid var(--border);border-radius:12px;">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:12px;font-weight:600;color:${s.color};">${s.icon} ${escHtml(s.text)}</div>
|
||||||
|
${m.address ? `<div style="font-size:12px;color:var(--muted);margin-top:2px;">${escHtml(m.address)}</div>` : ""}
|
||||||
|
${m.zamer_no ? `<div style="font-size:11px;color:var(--muted);">Замер №${escHtml(m.zamer_no)}</div>` : ""}
|
||||||
|
</div>
|
||||||
|
${m.scheduled_at ? `<div style="font-size:11px;color:var(--accent);font-weight:600;">${escHtml(fmtDate(m.scheduled_at))}</div>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
screen.appendChild(mCard);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
screen.appendChild(el(`<div style="height:32px;"></div>`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mount };
|
||||||
|
})();
|
||||||
@ -56,8 +56,9 @@
|
|||||||
<script src="assets/admin_rates.js?v=20260519a"></script>
|
<script src="assets/admin_rates.js?v=20260519a"></script>
|
||||||
<script src="assets/assembler_analytics.js?v=20260519a"></script>
|
<script src="assets/assembler_analytics.js?v=20260519a"></script>
|
||||||
<script src="assets/assembler_dashboard.js?v=20260519b"></script>
|
<script src="assets/assembler_dashboard.js?v=20260519b"></script>
|
||||||
|
<script src="assets/staff_clients.js?v=20260519a"></script>
|
||||||
<script src="assets/assembly_detail.js?v=20260519c"></script>
|
<script src="assets/assembly_detail.js?v=20260519c"></script>
|
||||||
<script src="assets/contracts.js?v=20260519a"></script>
|
<script src="assets/contracts.js?v=20260519a"></script>
|
||||||
<script src="assets/app.js?v=20260519c"></script>
|
<script src="assets/app.js?v=20260519d"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user