484 lines
19 KiB
HTML
484 lines
19 KiB
HTML
<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Дашборд заказов - Аналитика</title>
|
||
<style>
|
||
:root{--ink:#0F1218;--cyan:#00E5FF;--cyan-50:#E8FCFF;--white:#fff;--gray-500:#5B6573;--gray-100:#F2F4F7;--success:#10B981;--warning:#F59E0B;--danger:#EF4444;--purple:#8B5CF6}
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;color:var(--ink);background:#f8f9fa}
|
||
.container{max-width:1600px;margin:0 auto;padding:24px}
|
||
.header{background:var(--ink);color:var(--white);padding:20px 24px;margin-bottom:24px;border-radius:12px}
|
||
.header h1{font-size:24px;font-weight:700}
|
||
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:16px;margin-bottom:24px}
|
||
.stat-card{background:var(--white);border-radius:12px;padding:20px;box-shadow:0 2px 8px rgba(0,0,0,0.08)}
|
||
.stat-label{font-size:12px;color:var(--gray-500);margin-bottom:8px;text-transform:uppercase}
|
||
.stat-value{font-size:28px;font-weight:700;color:var(--ink)}
|
||
.stat-card.primary .stat-value{color:var(--cyan)}
|
||
.stat-card.success .stat-value{color:var(--success)}
|
||
.stat-card.warning .stat-value{color:var(--warning)}
|
||
.stat-card.danger .stat-value{color:var(--danger)}
|
||
.stat-card.purple .stat-value{color:var(--purple)}
|
||
.charts-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:24px;margin-bottom:24px}
|
||
.chart-panel{background:var(--white);border-radius:12px;padding:24px;box-shadow:0 2px 8px rgba(0,0,0,0.08)}
|
||
.chart-panel h3{font-size:16px;font-weight:700;margin-bottom:16px}
|
||
.chart-container{height:250px;position:relative}
|
||
.bar-chart{display:flex;align-items:flex-end;gap:4px;height:200px;padding-top:20px}
|
||
.bar{flex:1;background:var(--cyan);border-radius:4px 4px 0 0;min-width:20px;position:relative;transition:opacity 0.3s}
|
||
.bar:hover{opacity:0.7}
|
||
.bar-label{position:absolute;bottom:-25px;left:50%;transform:translateX(-50%);font-size:10px;white-space:nowrap}
|
||
.bar-value{position:absolute;top:-20px;left:50%;transform:translateX(-50%);font-size:11px;font-weight:600}
|
||
.pie-chart{width:200px;height:200px;border-radius:50%;margin:0 auto;position:relative}
|
||
.legend{display:grid;gap:8px;margin-top:16px}
|
||
.legend-item{display:flex;align-items:center;gap:8px;font-size:12px}
|
||
.legend-color{width:16px;height:16px;border-radius:4px}
|
||
.main-grid{display:grid;grid-template-columns:1fr 450px;gap:24px}
|
||
.panel{background:var(--white);border-radius:12px;padding:24px;box-shadow:0 2px 8px rgba(0,0,0,0.08)}
|
||
.panel h2{font-size:18px;font-weight:700;margin-bottom:20px;display:flex;align-items:center;gap:10px}
|
||
.table-container{overflow-x:auto;max-height:500px;overflow-y:auto}
|
||
.table{width:100%;border-collapse:collapse;font-size:13px}
|
||
.table th,.table td{padding:10px 8px;text-align:left;border-bottom:1px solid var(--gray-100);white-space:nowrap}
|
||
.table th{font-size:12px;font-weight:600;color:var(--gray-500);text-transform:uppercase;position:sticky;top:0;background:var(--white)}
|
||
.table tr:hover{background:var(--cyan-50)}
|
||
.table td:nth-child(2){max-width:250px;white-space:normal}
|
||
.badge{display:inline-block;padding:4px 10px;border-radius:20px;font-size:11px;font-weight:600}
|
||
.badge-в_работе{background:#FEF3C7;color:#92400E}
|
||
.badge-выполнен{background:#DCFCE7;color:#166534}
|
||
.badge-ОТКАЗ{background:#FEE2E2;color:#991B1B}
|
||
.form-group{margin-bottom:14px}
|
||
.form-group label{display:block;font-size:13px;font-weight:600;margin-bottom:6px;color:var(--gray-500)}
|
||
.form-group input,.form-group select,.form-group textarea{width:100%;padding:10px;border:1px solid var(--gray-100);border-radius:8px;font-size:14px;font-family:inherit}
|
||
.form-group input:focus,.form-group select:focus,.form-group textarea:focus{outline:none;border-color:var(--cyan)}
|
||
.form-group textarea{min-height:80px;resize:vertical}
|
||
.btn{display:inline-block;background:var(--cyan);color:var(--ink);padding:12px 20px;border-radius:8px;font-weight:700;text-decoration:none;cursor:pointer;border:none;font-size:14px;width:100%}
|
||
.btn:hover{opacity:0.9}
|
||
.btn-sm{padding:5px 10px;font-size:12px;width:auto}
|
||
.btn-danger{background:var(--danger);color:var(--white)}
|
||
.btn-edit{background:var(--warning);color:var(--ink)}
|
||
.actions{display:flex;gap:6px}
|
||
.empty{text-align:center;padding:40px;color:var(--gray-500)}
|
||
.filter-row{display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap}
|
||
.filter-row input,.filter-row select{padding:8px 12px;border:1px solid var(--gray-100);border-radius:6px;font-size:13px}
|
||
.search-input{flex:1;min-width:200px}
|
||
.date-filter{display:flex;gap:8px;align-items:center;margin-bottom:16px}
|
||
.date-filter input{padding:8px 12px;border:1px solid var(--gray-100);border-radius:6px;font-size:13px}
|
||
@media(max-width:1100px){.main-grid{grid-template-columns:1fr}.charts-grid{grid-template-columns:1fr}}
|
||
@media(max-width:640px){.stats{grid-template-columns:1fr}.container{padding:16px}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="container">
|
||
<div class="header">
|
||
<h1>📊 Дашборд заказов - Костанай/Петропавловск</h1>
|
||
</div>
|
||
|
||
<div class="stats">
|
||
<div class="stat-card primary">
|
||
<div class="stat-label">Всего заказов</div>
|
||
<div class="stat-value" id="stat-total">0</div>
|
||
</div>
|
||
<div class="stat-card warning">
|
||
<div class="stat-label">В работе</div>
|
||
<div class="stat-value" id="stat-active">0</div>
|
||
</div>
|
||
<div class="stat-card success">
|
||
<div class="stat-label">Выполнено</div>
|
||
<div class="stat-value" id="stat-completed">0</div>
|
||
</div>
|
||
<div class="stat-card danger">
|
||
<div class="stat-label">Отказы</div>
|
||
<div class="stat-value" id="stat-rejected">0</div>
|
||
</div>
|
||
<div class="stat-card purple">
|
||
<div class="stat-label">Конверсия</div>
|
||
<div class="stat-value" id="stat-conversion">0%</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="charts-grid">
|
||
<div class="chart-panel">
|
||
<h3>📈 Динамика по дням</h3>
|
||
<div class="chart-container">
|
||
<div class="bar-chart" id="daily-chart"></div>
|
||
</div>
|
||
</div>
|
||
<div class="chart-panel">
|
||
<h3>📊 Статусы заказов</h3>
|
||
<div class="chart-container" style="display:flex;align-items:center;justify-content:center">
|
||
<div class="pie-chart" id="status-pie" style="background:conic-gradient(var(--cyan) 0deg 0deg,#10B981 0deg 0deg,#F59E0B 0deg 0deg)"></div>
|
||
</div>
|
||
<div class="legend" id="status-legend"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="charts-grid">
|
||
<div class="chart-panel">
|
||
<h3>📱 Каналы продаж</h3>
|
||
<div class="chart-container">
|
||
<div class="bar-chart" id="channel-chart"></div>
|
||
</div>
|
||
</div>
|
||
<div class="chart-panel">
|
||
<h3>👥 Топ операторов</h3>
|
||
<div class="chart-container">
|
||
<div class="bar-chart" id="operator-chart"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="main-grid">
|
||
<div class="panel">
|
||
<h2>📋 Все заказы</h2>
|
||
<div class="filter-row">
|
||
<input type="text" class="search-input" id="search" placeholder="Поиск по адресу или № заказа...">
|
||
<select id="filter-channel">
|
||
<option value="">Все каналы</option>
|
||
<option value="ЦАП">ЦАП</option>
|
||
<option value="УП">УП</option>
|
||
<option value="Дилер">Дилер</option>
|
||
<option value="ПСС">ПСС</option>
|
||
<option value="OAPI">OAPI</option>
|
||
<option value="Онлайн">Онлайн</option>
|
||
</select>
|
||
<select id="filter-status">
|
||
<option value="">Все статусы</option>
|
||
<option value="в работе">В работе</option>
|
||
<option value="выполнен">Выполнен</option>
|
||
<option value="ОТКАЗ">ОТКАЗ</option>
|
||
</select>
|
||
</div>
|
||
<div class="table-container">
|
||
<table class="table">
|
||
<thead>
|
||
<tr><th>№ заказа</th><th>Адрес</th><th>Канал</th><th>Оператор</th><th>Статус</th><th>Примечание</th><th>Действия</th></tr>
|
||
</thead>
|
||
<tbody id="table-body"></tbody>
|
||
</table>
|
||
</div>
|
||
<div id="empty" class="empty" style="display:none">Нет записей</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<h2 id="form-title">➕ Добавить заказ</h2>
|
||
<form id="form">
|
||
<div class="form-group">
|
||
<label>№ заказа *</label>
|
||
<input type="text" name="id" placeholder="86844268" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Адрес *</label>
|
||
<input type="text" name="address" placeholder="г.Костанай, ул...." required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Канал продаж</label>
|
||
<select name="channel">
|
||
<option value="">Выбрать...</option>
|
||
<option value="ЦАП">ЦАП</option>
|
||
<option value="УП">УП</option>
|
||
<option value="Дилер">Дилер</option>
|
||
<option value="ПСС">ПСС</option>
|
||
<option value="OAPI">OAPI</option>
|
||
<option value="Онлайн">Онлайн</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Оператор</label>
|
||
<input type="text" name="operator" placeholder="ФИО оператора">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Статус *</label>
|
||
<select name="status" required>
|
||
<option value="в работе">В работе</option>
|
||
<option value="выполнен">Выполнен</option>
|
||
<option value="ОТКАЗ">ОТКАЗ</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Примечание</label>
|
||
<textarea name="note" placeholder="Комментарий к заказу"></textarea>
|
||
</div>
|
||
<button type="submit" class="btn" id="submit-btn">Добавить</button>
|
||
<button type="button" class="btn btn-danger" id="cancel-btn" style="display:none;margin-top:8px">Отмена</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let editingId = null;
|
||
let allOrders = [];
|
||
|
||
function getStatusClass(status) {
|
||
if (status === 'в работе') return 'badge-в_работе';
|
||
if (status === 'выполнен') return 'badge-выполнен';
|
||
if (status === 'ОТКАЗ') return 'badge-ОТКАЗ';
|
||
return '';
|
||
}
|
||
|
||
function extractDateFromId(id) {
|
||
const idStr = String(id);
|
||
if (idStr.length >= 6) {
|
||
const day = idStr.slice(4, 6);
|
||
const month = idStr.slice(2, 4);
|
||
const year = '20' + idStr.slice(0, 2);
|
||
return year + '-' + month + '-' + day;
|
||
}
|
||
return new Date().toISOString().slice(0, 10);
|
||
}
|
||
|
||
function groupByDate(orders) {
|
||
const groups = {};
|
||
orders.forEach(o => {
|
||
const date = extractDateFromId(o.id);
|
||
if (!groups[date]) groups[date] = 0;
|
||
groups[date]++;
|
||
});
|
||
return Object.entries(groups).sort((a, b) => a[0].localeCompare(b[0]));
|
||
}
|
||
|
||
function groupByChannel(orders) {
|
||
const groups = {};
|
||
orders.forEach(o => {
|
||
const ch = o.channel || 'Без канала';
|
||
if (!groups[ch]) groups[ch] = 0;
|
||
groups[ch]++;
|
||
});
|
||
return Object.entries(groups).sort((a, b) => b[1] - a[1]);
|
||
}
|
||
|
||
function groupByOperator(orders) {
|
||
const groups = {};
|
||
orders.forEach(o => {
|
||
const op = o.operator || 'Без оператора';
|
||
if (!groups[op]) groups[op] = 0;
|
||
groups[op]++;
|
||
});
|
||
return Object.entries(groups).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
||
}
|
||
|
||
function groupByStatus(orders) {
|
||
const groups = {'в работе': 0, 'выполнен': 0, 'ОТКАЗ': 0};
|
||
orders.forEach(o => {
|
||
if (groups[o.status] !== undefined) groups[o.status]++;
|
||
});
|
||
return groups;
|
||
}
|
||
|
||
function renderDailyChart(data) {
|
||
const container = document.getElementById("daily-chart");
|
||
if (data.length === 0) {
|
||
container.innerHTML = '<div style="color:#999;text-align:center;padding:40px">Нет данных</div>';
|
||
return;
|
||
}
|
||
const max = Math.max(...data.map(d => d[1]));
|
||
container.innerHTML = data.map(d => {
|
||
const height = (d[1] / max) * 180;
|
||
const date = d[0].slice(5) + '.' + d[0].slice(0, 4);
|
||
return '<div class="bar" style="height:' + height + 'px"><span class="bar-value">' + d[1] + '</span><span class="bar-label">' + date + '</span></div>';
|
||
}).join("");
|
||
}
|
||
|
||
function renderChannelChart(data) {
|
||
const container = document.getElementById("channel-chart");
|
||
if (data.length === 0) {
|
||
container.innerHTML = '<div style="color:#999;text-align:center;padding:40px">Нет данных</div>';
|
||
return;
|
||
}
|
||
const max = Math.max(...data.map(d => d[1]));
|
||
container.innerHTML = data.map(d => {
|
||
const height = (d[1] / max) * 180;
|
||
return '<div class="bar" style="height:' + height + 'px;background:#8B5CF6"><span class="bar-value">' + d[1] + '</span><span class="bar-label" style="transform:translateX(-50%) rotate(-45deg);font-size:9px">' + d[0].slice(0, 8) + '</span></div>';
|
||
}).join("");
|
||
}
|
||
|
||
function renderOperatorChart(data) {
|
||
const container = document.getElementById("operator-chart");
|
||
if (data.length === 0) {
|
||
container.innerHTML = '<div style="color:#999;text-align:center;padding:40px">Нет данных</div>';
|
||
return;
|
||
}
|
||
const max = Math.max(...data.map(d => d[1]));
|
||
container.innerHTML = data.map(d => {
|
||
const height = (d[1] / max) * 180;
|
||
return '<div class="bar" style="height:' + height + 'px;background:#F59E0B"><span class="bar-value">' + d[1] + '</span><span class="bar-label" style="transform:translateX(-50%) rotate(-45deg);font-size:9px">' + d[0].slice(0, 10) + '</span></div>';
|
||
}).join("");
|
||
}
|
||
|
||
function renderStatusPie(statuses) {
|
||
const total = statuses['в работе'] + statuses['выполнен'] + statuses['ОТКАЗ'];
|
||
if (total === 0) return;
|
||
|
||
const activeDeg = (statuses['в работе'] / total) * 360;
|
||
const completedDeg = (statuses['выполнен'] / total) * 360;
|
||
const rejectedDeg = (statuses['ОТКАЗ'] / total) * 360;
|
||
|
||
const pie = document.getElementById("status-pie");
|
||
pie.style.background = 'conic-gradient(#F59E0B 0deg ' + activeDeg + 'deg, #10B981 ' + activeDeg + 'deg ' + (activeDeg + completedDeg) + 'deg, #EF4444 ' + (activeDeg + completedDeg) + 'deg 360deg)';
|
||
|
||
const legend = document.getElementById("status-legend");
|
||
legend.innerHTML = [
|
||
{label: 'В работе', count: statuses['в работе'], color: '#F59E0B'},
|
||
{label: 'Выполнен', count: statuses['выполнен'], color: '#10B981'},
|
||
{label: 'ОТКАЗ', count: statuses['ОТКАЗ'], color: '#EF4444'}
|
||
].map(item => '<div class="legend-item"><div class="legend-color" style="background:' + item.color + '"></div><span>' + item.label + ': ' + item.count + '</span></div>').join("");
|
||
}
|
||
|
||
async function load() {
|
||
const res = await fetch("/api/data");
|
||
const data = await res.json();
|
||
allOrders = data.orders || [];
|
||
renderTable(allOrders);
|
||
updateStats(allOrders);
|
||
updateCharts(allOrders);
|
||
}
|
||
|
||
function updateStats(orders) {
|
||
const total = orders.length;
|
||
const active = orders.filter(o => o.status === "в работе").length;
|
||
const completed = orders.filter(o => o.status === "выполнен").length;
|
||
const rejected = orders.filter(o => o.status === "ОТКАЗ").length;
|
||
const conversion = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||
|
||
document.getElementById("stat-total").textContent = total;
|
||
document.getElementById("stat-active").textContent = active;
|
||
document.getElementById("stat-completed").textContent = completed;
|
||
document.getElementById("stat-rejected").textContent = rejected;
|
||
document.getElementById("stat-conversion").textContent = conversion + '%';
|
||
}
|
||
|
||
function updateCharts(orders) {
|
||
renderDailyChart(groupByDate(orders));
|
||
renderChannelChart(groupByChannel(orders));
|
||
renderOperatorChart(groupByOperator(orders));
|
||
renderStatusPie(groupByStatus(orders));
|
||
}
|
||
|
||
function renderTable(orders) {
|
||
const tbody = document.getElementById("table-body");
|
||
const empty = document.getElementById("empty");
|
||
|
||
if (orders.length === 0) {
|
||
tbody.innerHTML = "";
|
||
empty.style.display = "block";
|
||
} else {
|
||
empty.style.display = "none";
|
||
tbody.innerHTML = orders.map(order => `
|
||
<tr>
|
||
<td><strong>${order.id}</strong></td>
|
||
<td title="${order.address}">${order.address.substring(0, 50)}${order.address.length > 50 ? '...' : ''}</td>
|
||
<td>${order.channel || '-'}</td>
|
||
<td>${order.operator || '-'}</td>
|
||
<td><span class="badge ${getStatusClass(order.status)}">${order.status}</span></td>
|
||
<td title="${order.note || ''}">${(order.note || '-').substring(0, 30)}${(order.note||'').length > 30 ? '...' : ''}</td>
|
||
<td class="actions">
|
||
<button class="btn btn-sm btn-edit" onclick="editItem(${order.id})">✏️</button>
|
||
<button class="btn btn-sm btn-danger" onclick="deleteItem(${order.id})">🗑️</button>
|
||
</td>
|
||
</tr>
|
||
`).join("");
|
||
}
|
||
}
|
||
|
||
function filterTable() {
|
||
const search = document.getElementById("search").value.toLowerCase();
|
||
const channel = document.getElementById("filter-channel").value;
|
||
const status = document.getElementById("filter-status").value;
|
||
|
||
let filtered = allOrders;
|
||
|
||
if (search) {
|
||
filtered = filtered.filter(o =>
|
||
String(o.id).includes(search) ||
|
||
o.address.toLowerCase().includes(search) ||
|
||
(o.operator && o.operator.toLowerCase().includes(search))
|
||
);
|
||
}
|
||
if (channel) filtered = filtered.filter(o => o.channel === channel);
|
||
if (status) filtered = filtered.filter(o => o.status === status);
|
||
|
||
renderTable(filtered);
|
||
}
|
||
|
||
document.getElementById("search").addEventListener("input", filterTable);
|
||
document.getElementById("filter-channel").addEventListener("change", filterTable);
|
||
document.getElementById("filter-status").addEventListener("change", filterTable);
|
||
|
||
document.getElementById("form").addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
const form = e.target;
|
||
const data = {
|
||
id: form.id.value,
|
||
address: form.address.value,
|
||
channel: form.channel.value,
|
||
operator: form.operator.value,
|
||
status: form.status.value,
|
||
note: form.note.value
|
||
};
|
||
|
||
if (editingId) {
|
||
await fetch(`/api/data/${editingId}`, {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(data)
|
||
});
|
||
} else {
|
||
await fetch("/api/data", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(data)
|
||
});
|
||
}
|
||
|
||
form.reset();
|
||
editingId = null;
|
||
document.getElementById("form-title").textContent = "➕ Добавить заказ";
|
||
document.getElementById("submit-btn").textContent = "Добавить";
|
||
document.getElementById("cancel-btn").style.display = "none";
|
||
load();
|
||
});
|
||
|
||
async function editItem(id) {
|
||
const order = allOrders.find(o => o.id === id);
|
||
if (!order) return;
|
||
|
||
const form = document.getElementById("form");
|
||
form.id.value = order.id;
|
||
form.address.value = order.address;
|
||
form.channel.value = order.channel || "";
|
||
form.operator.value = order.operator || "";
|
||
form.status.value = order.status;
|
||
form.note.value = order.note || "";
|
||
|
||
editingId = id;
|
||
document.getElementById("form-title").textContent = "✏️ Редактировать заказ";
|
||
document.getElementById("submit-btn").textContent = "Сохранить";
|
||
document.getElementById("cancel-btn").style.display = "inline-block";
|
||
}
|
||
|
||
document.getElementById("cancel-btn").addEventListener("click", () => {
|
||
document.getElementById("form").reset();
|
||
editingId = null;
|
||
document.getElementById("form-title").textContent = "➕ Добавить заказ";
|
||
document.getElementById("submit-btn").textContent = "Добавить";
|
||
document.getElementById("cancel-btn").style.display = "none";
|
||
});
|
||
|
||
async function deleteItem(id) {
|
||
if (!confirm("Удалить заказ №" + id + "?")) return;
|
||
await fetch(`/api/data/${id}`, { method: "DELETE" });
|
||
document.getElementById("form").reset();
|
||
editingId = null;
|
||
document.getElementById("form-title").textContent = "➕ Добавить заказ";
|
||
document.getElementById("submit-btn").textContent = "Добавить";
|
||
document.getElementById("cancel-btn").style.display = "none";
|
||
load();
|
||
}
|
||
|
||
load();
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|