mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 16:44:48 +00:00
F: кабинет замерщика — week-strip + группировка по дням + 📞 звонок
Шапка → 📅 strip недели → 📥 заявки группами. Week strip (компакт): - 7 дней начиная с понедельника текущей недели - Каждый день: название (Пн/Вт/...), число, полоса загрузки (высота = доля от max за неделю), счётчик замеров - Цвет полосы: 1-2 = walnut light, 3-4 = walnut, 5+ = #C0392B (перегруз) - Сегодня — выделен walnut-рамкой + warm-фоном; прошлые дни приглушены Группированный инбокс: ⚠️ Просрочено (scheduled_at в прошлом, но не completed) 🔥 Сегодня 📅 Завтра 🗓️ На неделе (до воскресенья) 📆 Позже 📞 Без даты (нужно согласовать) Каждый ряд: - Слева время (10:00 / Пт 15.05 14:00) — крупно, walnut, моно - Центр: ФИО клиента + адрес (truncate) - Справа: chevron → переход к заявке - Зелёная кнопка 📞 — звонок в один тап (tel: ссылка) Cache bust v=20260513zb.
This commit is contained in:
parent
8201fee9f2
commit
b8d9ff937f
@ -448,35 +448,37 @@ async function renderStaff(me) {
|
||||
</div>
|
||||
`));
|
||||
|
||||
// Реальный инбокс — загружаем из /api/measurement_inbox
|
||||
// Загружаем заявки и рендерим: week strip + сгруппированный инбокс
|
||||
const stripPlaceholder = el(`<div id="weekStrip"></div>`);
|
||||
const inboxSection = el(`
|
||||
<section class="block">
|
||||
<div class="block-head">📥 Входящие заявки на замер</div>
|
||||
<div class="block-head">📥 Заявки</div>
|
||||
<div id="inboxList"><div class="loader-inline"><div class="spinner"></div></div></div>
|
||||
</section>
|
||||
`);
|
||||
app.appendChild(stripPlaceholder);
|
||||
app.appendChild(inboxSection);
|
||||
|
||||
if (caps.measurer) {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/measurement_inbox`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ initData: tg?.initData || "" }),
|
||||
body: JSON.stringify({
|
||||
initData: tg?.initData || "",
|
||||
initDataUnsafe: tg?.initDataUnsafe || null,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
const list = document.getElementById("inboxList");
|
||||
if (!list) return;
|
||||
if (data.error) {
|
||||
list.innerHTML = `<div class="error">Ошибка: ${data.error}</div>`;
|
||||
} else if (!data.measurements || !data.measurements.length) {
|
||||
list.innerHTML = `
|
||||
<div class="empty" style="padding:18px 12px;text-align:center;color:var(--muted);">
|
||||
Заявок пока нет. Когда менеджер назначит замер — увидите здесь.
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
list.innerHTML = "";
|
||||
data.measurements.forEach(m => list.appendChild(renderInboxItem(m)));
|
||||
const measurements = data.measurements || [];
|
||||
// Week strip — заменяет placeholder
|
||||
document.getElementById("weekStrip").replaceWith(renderWeekStrip(measurements));
|
||||
// Группированный инбокс
|
||||
renderGroupedInbox(list, measurements);
|
||||
}
|
||||
} catch (e) {
|
||||
const list = document.getElementById("inboxList");
|
||||
@ -505,35 +507,166 @@ async function renderStaff(me) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderInboxItem(m) {
|
||||
const statusLabel = ({
|
||||
requested: "🟡 ждёт даты",
|
||||
scheduled: "📅 назначен",
|
||||
in_progress: "🔵 в работе",
|
||||
})[m.status] || m.status;
|
||||
// Когда: точная дата если назначена, иначе приблизительная
|
||||
let whenText;
|
||||
if (m.scheduled_at) {
|
||||
whenText = "📅 " + formatDateHuman(m.scheduled_at);
|
||||
} else {
|
||||
whenText = "🕐 " + formatPreferredHuman(m);
|
||||
/* ----------------- Группировка инбокса замерщика по дням ----------------- */
|
||||
|
||||
function _startOfDay(d) {
|
||||
const x = new Date(d);
|
||||
x.setHours(0, 0, 0, 0);
|
||||
return x;
|
||||
}
|
||||
|
||||
const item = el(`
|
||||
<button class="lead-item" style="text-align:left;">
|
||||
<div style="flex:1; min-width:0;">
|
||||
<div class="lead-date" style="font-weight:600; color:var(--ink);">${escHtml(m.client_name || "—")}</div>
|
||||
<div class="lead-id" style="font-size:12px; color:var(--muted); margin-top:2px;">
|
||||
${escHtml(m.address || "адрес не указан")}
|
||||
function _daysBetween(a, b) {
|
||||
return Math.round((_startOfDay(b) - _startOfDay(a)) / 86400000);
|
||||
}
|
||||
|
||||
function _groupForMeasurement(m, today, weekEnd) {
|
||||
if (!m.scheduled_at) {
|
||||
// Без даты — отделяем requested от scheduled (по идее scheduled без даты быть не должно)
|
||||
return { key: "no_date", title: "📞 Без даты — нужно согласовать", order: 5 };
|
||||
}
|
||||
const d = new Date(m.scheduled_at);
|
||||
const diff = _daysBetween(today, d);
|
||||
if (diff < 0) return { key: "overdue", title: "⚠️ Просрочено", order: 0 };
|
||||
if (diff === 0) return { key: "today", title: "🔥 Сегодня", order: 1 };
|
||||
if (diff === 1) return { key: "tomorrow", title: "📅 Завтра", order: 2 };
|
||||
if (d <= weekEnd) return { key: "this_week", title: "🗓️ На неделе", order: 3 };
|
||||
return { key: "later", title: "📆 Позже", order: 4 };
|
||||
}
|
||||
|
||||
function renderGroupedInbox(container, measurements) {
|
||||
container.innerHTML = "";
|
||||
if (!measurements.length) {
|
||||
container.innerHTML = `
|
||||
<div class="empty" style="padding:18px 12px;text-align:center;color:var(--muted);">
|
||||
Заявок пока нет. Когда менеджер назначит замер — увидите здесь.
|
||||
</div>
|
||||
<div class="lead-id" style="font-size:11px; color:var(--muted); margin-top:2px;">
|
||||
${escHtml(whenText)} · ${statusLabel}
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const today = _startOfDay(new Date());
|
||||
// Конец этой недели: воскресенье вечером
|
||||
const weekEnd = new Date(today);
|
||||
const dayIdx = (today.getDay() + 6) % 7; // 0 = Пн, 6 = Вс
|
||||
weekEnd.setDate(today.getDate() + (6 - dayIdx));
|
||||
weekEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
// Группируем
|
||||
const groups = new Map();
|
||||
for (const m of measurements) {
|
||||
const g = _groupForMeasurement(m, today, weekEnd);
|
||||
if (!groups.has(g.key)) groups.set(g.key, { ...g, items: [] });
|
||||
groups.get(g.key).items.push(m);
|
||||
}
|
||||
// Сортируем группы и внутри — по дате
|
||||
const sortedGroups = [...groups.values()].sort((a, b) => a.order - b.order);
|
||||
for (const g of sortedGroups) {
|
||||
g.items.sort((a, b) => (a.scheduled_at || "").localeCompare(b.scheduled_at || ""));
|
||||
const groupEl = el(`
|
||||
<div class="inbox-group">
|
||||
<div class="inbox-group-head">${g.title}<span class="count">${g.items.length}</span></div>
|
||||
<div class="inbox-group-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lead-arrow">${ICONS.chevron || "›"}</div>
|
||||
</button>
|
||||
`);
|
||||
item.addEventListener("click", () => {
|
||||
const list = groupEl.querySelector(".inbox-group-list");
|
||||
g.items.forEach(m => list.appendChild(renderInboxItem(m, g.key)));
|
||||
container.appendChild(groupEl);
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------- Week strip — загрузка по дням ----------------- */
|
||||
|
||||
function renderWeekStrip(measurements) {
|
||||
const today = _startOfDay(new Date());
|
||||
const dayIdx = (today.getDay() + 6) % 7; // Пн = 0
|
||||
const monday = new Date(today);
|
||||
monday.setDate(today.getDate() - dayIdx);
|
||||
const days = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(monday);
|
||||
d.setDate(monday.getDate() + i);
|
||||
days.push(d);
|
||||
}
|
||||
// Считаем сколько замеров на каждый день
|
||||
const countByDay = days.map(d => {
|
||||
const start = _startOfDay(d).getTime();
|
||||
const end = start + 86400000;
|
||||
return measurements.filter(m => {
|
||||
if (!m.scheduled_at) return false;
|
||||
const t = new Date(m.scheduled_at).getTime();
|
||||
return t >= start && t < end;
|
||||
}).length;
|
||||
});
|
||||
const maxCount = Math.max(1, ...countByDay);
|
||||
|
||||
const dayNames = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
|
||||
const section = el(`
|
||||
<section class="cal-strip-block">
|
||||
<div class="cal-strip-head">
|
||||
${monday.getDate()}–${days[6].getDate()} ${monday.toLocaleString("ru-RU", { month: "long" })}
|
||||
</div>
|
||||
<div class="cal-strip">
|
||||
${days.map((d, i) => {
|
||||
const cnt = countByDay[i];
|
||||
const heightPct = cnt ? Math.round((cnt / maxCount) * 100) : 0;
|
||||
const isToday = _startOfDay(d).getTime() === today.getTime();
|
||||
const isPast = _startOfDay(d).getTime() < today.getTime();
|
||||
const loadClass = cnt >= 5 ? "load-hot" : cnt >= 3 ? "load-mid" : cnt > 0 ? "load-low" : "load-zero";
|
||||
return `
|
||||
<div class="cal-day ${isToday ? "today" : ""} ${isPast ? "past" : ""}">
|
||||
<div class="cal-day-name">${dayNames[i]}</div>
|
||||
<div class="cal-day-num">${d.getDate()}</div>
|
||||
<div class="cal-day-bar"><div class="bar ${loadClass}" style="height:${heightPct}%"></div></div>
|
||||
<div class="cal-day-count">${cnt || "—"}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</section>
|
||||
`);
|
||||
return section;
|
||||
}
|
||||
|
||||
/* ----------------- Карточка заявки в инбоксе ----------------- */
|
||||
|
||||
function renderInboxItem(m, groupKey) {
|
||||
// Когда: точное время если назначено + день недели для не-today
|
||||
let timeLine;
|
||||
if (m.scheduled_at) {
|
||||
const d = new Date(m.scheduled_at);
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mi = String(d.getMinutes()).padStart(2, "0");
|
||||
if (groupKey === "today" || groupKey === "tomorrow") {
|
||||
timeLine = `${hh}:${mi}`;
|
||||
} else if (groupKey === "overdue") {
|
||||
timeLine = `${String(d.getDate()).padStart(2,"0")}.${String(d.getMonth()+1).padStart(2,"0")} ${hh}:${mi}`;
|
||||
} else {
|
||||
const dayNames = ["Вс", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"];
|
||||
timeLine = `${dayNames[d.getDay()]} ${String(d.getDate()).padStart(2,"0")}.${String(d.getMonth()+1).padStart(2,"0")} ${hh}:${mi}`;
|
||||
}
|
||||
} else {
|
||||
timeLine = formatPreferredHuman(m);
|
||||
}
|
||||
|
||||
const phoneClean = (m.client_phone || "").replace(/[^\d+]/g, "");
|
||||
const callHref = phoneClean ? `tel:${phoneClean}` : "";
|
||||
|
||||
const item = el(`
|
||||
<div class="inbox-row">
|
||||
<button class="inbox-row-main" type="button">
|
||||
<div class="inbox-time">${escHtml(timeLine)}</div>
|
||||
<div class="inbox-row-body">
|
||||
<div class="inbox-client">${escHtml(m.client_name || "—")}</div>
|
||||
<div class="inbox-addr">${escHtml(m.address || "адрес не указан")}</div>
|
||||
</div>
|
||||
<div class="inbox-arrow">${ICONS.chevron || "›"}</div>
|
||||
</button>
|
||||
${callHref
|
||||
? `<a class="inbox-call" href="${callHref}" aria-label="Позвонить" title="${escHtml(m.client_phone || "")}">📞</a>`
|
||||
: ""}
|
||||
</div>
|
||||
`);
|
||||
item.querySelector(".inbox-row-main").addEventListener("click", () => {
|
||||
haptic && haptic("impact");
|
||||
location.hash = `#/inbox/${m.id}`;
|
||||
});
|
||||
|
||||
@ -2538,6 +2538,173 @@
|
||||
.checklist-md .cl-table th, .checklist-md .cl-table td { border: 1px solid rgba(107, 74, 43, 0.18); padding: 4px 8px; text-align: left; }
|
||||
.checklist-md .cl-table th { background: rgba(107, 74, 43, 0.08); font-weight: 600; }
|
||||
|
||||
/* ===== Кабинет замерщика: week strip + grouped inbox ===== */
|
||||
|
||||
/* Week strip — загрузка по дням */
|
||||
.cal-strip-block {
|
||||
background: var(--card, #fff);
|
||||
border: 1px solid rgba(107, 74, 43, 0.12);
|
||||
border-radius: 14px;
|
||||
padding: 12px 10px 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.cal-strip-head {
|
||||
font-family: var(--font-display, "Newsreader", serif);
|
||||
font-style: italic;
|
||||
font-size: 16px;
|
||||
color: var(--ink, #1F1A14);
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.cal-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
.cal-day {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 6px 2px 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--paper, #FBF7F0);
|
||||
border: 1px solid transparent;
|
||||
position: relative;
|
||||
}
|
||||
.cal-day.today {
|
||||
border-color: var(--walnut, #6B4A2B);
|
||||
background: var(--warm, rgba(107, 74, 43, 0.08));
|
||||
}
|
||||
.cal-day.past { opacity: 0.5; }
|
||||
.cal-day-name {
|
||||
font-size: 9px;
|
||||
font-family: var(--font-mono, "JetBrains Mono", monospace);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted, #998877);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.cal-day-num {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ink, #1F1A14);
|
||||
line-height: 1;
|
||||
}
|
||||
.cal-day.today .cal-day-num { color: var(--walnut, #6B4A2B); }
|
||||
.cal-day-bar {
|
||||
width: 18px;
|
||||
height: 28px;
|
||||
margin: 6px 0 4px;
|
||||
background: rgba(107, 74, 43, 0.08);
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cal-day-bar .bar {
|
||||
width: 100%;
|
||||
border-radius: 3px;
|
||||
transition: height 0.25s ease;
|
||||
}
|
||||
.cal-day-bar .bar.load-zero { background: transparent; }
|
||||
.cal-day-bar .bar.load-low { background: rgba(107, 74, 43, 0.5); }
|
||||
.cal-day-bar .bar.load-mid { background: var(--walnut, #6B4A2B); }
|
||||
.cal-day-bar .bar.load-hot { background: #C0392B; }
|
||||
.cal-day-count {
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono, "JetBrains Mono", monospace);
|
||||
color: var(--muted, #998877);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* Grouped inbox */
|
||||
.inbox-group { margin-bottom: 14px; }
|
||||
.inbox-group-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin: 12px 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ink, #1F1A14);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.inbox-group-head .count {
|
||||
font-size: 11px;
|
||||
color: var(--muted, #998877);
|
||||
font-family: var(--font-mono, "JetBrains Mono", monospace);
|
||||
font-weight: 400;
|
||||
}
|
||||
.inbox-group-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.inbox-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
background: var(--card, #fff);
|
||||
border: 1px solid rgba(107, 74, 43, 0.14);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.inbox-row-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
min-width: 0;
|
||||
}
|
||||
.inbox-row-main:active { background: rgba(107, 74, 43, 0.06); }
|
||||
.inbox-time {
|
||||
font-family: var(--font-mono, "JetBrains Mono", monospace);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--walnut, #6B4A2B);
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
.inbox-row-body { flex: 1; min-width: 0; }
|
||||
.inbox-client {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--ink, #1F1A14);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.inbox-addr {
|
||||
font-size: 12px;
|
||||
color: var(--muted, #998877);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.inbox-arrow {
|
||||
color: var(--muted, #998877);
|
||||
font-size: 18px;
|
||||
}
|
||||
.inbox-call {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
background: rgba(39, 174, 96, 0.08);
|
||||
color: #27AE60;
|
||||
text-decoration: none;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid rgba(107, 74, 43, 0.12);
|
||||
}
|
||||
.inbox-call:active { background: rgba(39, 174, 96, 0.18); }
|
||||
|
||||
/* ===== Кабинет сотрудника (замерщик/сборщик) ===== */
|
||||
.staff-head {
|
||||
display: flex;
|
||||
|
||||
@ -12,8 +12,8 @@
|
||||
<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&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&display=swap">
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<link rel="stylesheet" href="assets/styles.css?v=20260513za">
|
||||
<link rel="stylesheet" href="assets/podbor.css?v=20260513za">
|
||||
<link rel="stylesheet" href="assets/styles.css?v=20260513zb">
|
||||
<link rel="stylesheet" href="assets/podbor.css?v=20260513zb">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Splash — за пределами #app, render-функции его не смывают -->
|
||||
@ -31,14 +31,14 @@
|
||||
<div class="loader-tagline">Сделано с душой!</div>
|
||||
</div>
|
||||
<main id="app"></main>
|
||||
<script src="assets/icons.js?v=20260513za"></script>
|
||||
<script src="assets/podbor.config.js?v=20260513za"></script>
|
||||
<script src="assets/podbor.picts.js?v=20260513za"></script>
|
||||
<script src="assets/podbor.js?v=20260513za"></script>
|
||||
<script src="assets/clients.js?v=20260513za"></script>
|
||||
<script src="assets/zamer-picts.js?v=20260513za"></script>
|
||||
<script src="assets/measurements.js?v=20260513za"></script>
|
||||
<script src="assets/request.js?v=20260513za"></script>
|
||||
<script src="assets/app.js?v=20260513za"></script>
|
||||
<script src="assets/icons.js?v=20260513zb"></script>
|
||||
<script src="assets/podbor.config.js?v=20260513zb"></script>
|
||||
<script src="assets/podbor.picts.js?v=20260513zb"></script>
|
||||
<script src="assets/podbor.js?v=20260513zb"></script>
|
||||
<script src="assets/clients.js?v=20260513zb"></script>
|
||||
<script src="assets/zamer-picts.js?v=20260513zb"></script>
|
||||
<script src="assets/measurements.js?v=20260513zb"></script>
|
||||
<script src="assets/request.js?v=20260513zb"></script>
|
||||
<script src="assets/app.js?v=20260513zb"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user