452 lines
20 KiB
HTML
452 lines
20 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru" data-theme="light">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Управление заявками</title>
|
||
<link rel="stylesheet" href="design-system/kt-ai-fonts.css">
|
||
<link rel="stylesheet" href="design-system/kt-ai-tokens.css">
|
||
<link rel="stylesheet" href="design-system/kt-ai-components.css">
|
||
<link rel="stylesheet" href="design-system/kt-ai-page.css">
|
||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📋</text></svg>">
|
||
<style>
|
||
.kt-ai-app { min-height: 100vh; display: flex; flex-direction: column; }
|
||
.app-header { display: flex; align-items: center; gap: var(--kt-ai-space-4); padding: var(--kt-ai-space-4) 0; }
|
||
.app-logo { height: 32px; }
|
||
.app-title { font-size: var(--kt-ai-text-lg); font-weight: var(--kt-ai-weight-semibold); }
|
||
.main-content { flex: 1; padding: var(--kt-ai-space-6) 0; }
|
||
.kpi-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--kt-ai-space-4); margin-bottom: var(--kt-ai-space-6); }
|
||
.kt-ai-kpi { background: var(--kt-ai-card-bg); border: 1px solid var(--kt-ai-card-border); border-radius: var(--kt-ai-radius-xl); padding: var(--kt-ai-space-5); }
|
||
.kt-ai-kpi .value { font-size: var(--kt-ai-text-3xl); font-weight: var(--kt-ai-weight-bold); color: var(--kt-ai-fg); }
|
||
.kt-ai-kpi .label { font-size: var(--kt-ai-text-sm); color: var(--kt-ai-fg-muted); margin-top: var(--kt-ai-space-1); }
|
||
.kt-ai-kpi .hint { font-size: var(--kt-ai-text-xs); color: var(--kt-ai-fg-faint); margin-top: var(--kt-ai-space-2); }
|
||
.content-grid { display: grid; grid-template-columns: 1fr 320px; gap: var(--kt-ai-space-6); }
|
||
.table-card { background: var(--kt-ai-card-bg); border: 1px solid var(--kt-ai-card-border); border-radius: var(--kt-ai-radius-xl); padding: var(--kt-ai-space-5); }
|
||
.table-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--kt-ai-space-4); }
|
||
.table-title { font-size: var(--kt-ai-text-lg); font-weight: var(--kt-ai-weight-semibold); }
|
||
.filters { display: flex; gap: var(--kt-ai-space-3); flex-wrap: wrap; }
|
||
.kt-ai-table-wrap { overflow-x: auto; }
|
||
.kt-ai-table { width: 100%; border-collapse: collapse; }
|
||
.kt-ai-table th { text-align: left; font-size: var(--kt-ai-text-xs); font-weight: var(--kt-ai-weight-medium); color: var(--kt-ai-fg-muted); padding: var(--kt-ai-space-3); border-bottom: 1px solid var(--kt-ai-border); }
|
||
.kt-ai-table td { padding: var(--kt-ai-space-3); font-size: var(--kt-ai-text-sm); color: var(--kt-ai-fg); border-bottom: 1px solid var(--kt-ai-border-strong); }
|
||
.kt-ai-table tr:hover td { background: var(--kt-ai-bg-soft); }
|
||
.status-chip { display: inline-flex; align-items: center; padding: var(--kt-ai-space-1) var(--kt-ai-space-2); border-radius: var(--kt-ai-radius-md); font-size: var(--kt-ai-text-xs); font-weight: var(--kt-ai-weight-medium); }
|
||
.status-new { background: var(--kt-ai-status-ok-bg); color: var(--kt-ai-status-ok-fg); }
|
||
.status-progress { background: var(--kt-ai-status-warn-bg); color: var(--kt-ai-status-warn-fg); }
|
||
.status-done { background: var(--kt-ai-status-success-bg); color: var(--kt-ai-status-success-fg); }
|
||
.status-cancelled { background: var(--kt-ai-status-risk-bg); color: var(--kt-ai-status-risk-fg); }
|
||
.form-card { background: var(--kt-ai-card-bg); border: 1px solid var(--kt-ai-card-border); border-radius: var(--kt-ai-radius-xl); padding: var(--kt-ai-space-5); position: sticky; top: var(--kt-ai-space-6); }
|
||
.form-title { font-size: var(--kt-ai-text-lg); font-weight: var(--kt-ai-weight-semibold); margin-bottom: var(--kt-ai-space-4); }
|
||
.kt-ai-field { margin-bottom: var(--kt-ai-space-4); }
|
||
.kt-ai-field label { display: block; font-size: var(--kt-ai-text-sm); font-weight: var(--kt-ai-weight-medium); color: var(--kt-ai-fg); margin-bottom: var(--kt-ai-space-2); }
|
||
.kt-ai-input, .kt-ai-select, .kt-ai-textarea { width: 100%; padding: var(--kt-ai-space-2) var(--kt-ai-space-3); border: 1px solid var(--kt-ai-border); border-radius: var(--kt-ai-radius-md); background: var(--kt-ai-bg); color: var(--kt-ai-fg); font-size: var(--kt-ai-text-sm); }
|
||
.kt-ai-input:focus, .kt-ai-select:focus, .kt-ai-textarea:focus { outline: 2px solid var(--kt-ai-primary); border-color: transparent; }
|
||
.kt-ai-textarea { min-height: 100px; resize: vertical; }
|
||
.chart-container { margin-top: var(--kt-ai-space-6); padding: var(--kt-ai-space-5); background: var(--kt-ai-card-bg); border: 1px solid var(--kt-ai-card-border); border-radius: var(--kt-ai-radius-xl); }
|
||
.chart-title { font-size: var(--kt-ai-text-lg); font-weight: var(--kt-ai-weight-semibold); margin-bottom: var(--kt-ai-space-4); }
|
||
.chart-svg { width: 100%; height: 200px; }
|
||
.chart-bar { transition: opacity 0.2s; }
|
||
.chart-bar:hover { opacity: 0.7; }
|
||
.empty-state { text-align: center; padding: var(--kt-ai-space-10); color: var(--kt-ai-fg-muted); }
|
||
.empty-icon { font-size: 48px; margin-bottom: var(--kt-ai-space-4); }
|
||
.empty-title { font-size: var(--kt-ai-text-lg); font-weight: var(--kt-ai-weight-medium); margin-bottom: var(--kt-ai-space-2); }
|
||
.empty-desc { font-size: var(--kt-ai-text-sm); }
|
||
.toast { position: fixed; bottom: var(--kt-ai-space-6); right: var(--kt-ai-space-6); background: var(--kt-ai-card-bg); border: 1px solid var(--kt-ai-border); border-radius: var(--kt-ai-radius-xl); padding: var(--kt-ai-space-4); box-shadow: 0 8px 32px rgba(0,0,0,0.12); z-index: 1000; transform: translateY(100px); opacity: 0; transition: transform 0.3s, opacity 0.3s; }
|
||
.toast.show { transform: translateY(0); opacity: 1; }
|
||
.toast-message { font-size: var(--kt-ai-text-sm); color: var(--kt-ai-fg); }
|
||
</style>
|
||
</head>
|
||
<body class="kt-ai-app" data-kt-data-mode="demo">
|
||
|
||
<header class="kt-ai-pagehead">
|
||
<div class="kt-ai-wrap kt-ai-pagehead-in">
|
||
<div class="app-header">
|
||
<img src="https://code.vibe42.kz/tore/aitu/kt-logo.svg" alt="Казахтелеком" class="app-logo">
|
||
<span class="app-title">Управление заявками</span>
|
||
<span class="kt-ai-chip" data-tone="orange">Демо-данные</span>
|
||
</div>
|
||
<span class="kt-ai-spacer"></span>
|
||
<button class="kt-ai-btn" data-variant="ghost" id="resetDataBtn">Сбросить данные</button>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="main-content">
|
||
<div class="kt-ai-wrap">
|
||
|
||
<div class="kpi-strip" id="kpiStrip">
|
||
<div class="kt-ai-kpi">
|
||
<div class="value" id="kpiTotal">0</div>
|
||
<div class="label">Всего заявок</div>
|
||
<div class="hint">за все время</div>
|
||
</div>
|
||
<div class="kt-ai-kpi">
|
||
<div class="value" id="kpiActive">0</div>
|
||
<div class="label">В работе</div>
|
||
<div class="hint">новые и активные</div>
|
||
</div>
|
||
<div class="kt-ai-kpi">
|
||
<div class="value" id="kpiCompleted">0</div>
|
||
<div class="label">Завершено</div>
|
||
<div class="hint">успешно закрыто</div>
|
||
</div>
|
||
<div class="kt-ai-kpi">
|
||
<div class="value" id="kpiAvgTime">0</div>
|
||
<div class="label">Ср. время</div>
|
||
<div class="hint">дней на выполнение</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="content-grid">
|
||
<div class="table-section">
|
||
<div class="table-card">
|
||
<div class="table-header">
|
||
<div class="table-title">Заявки</div>
|
||
<div class="filters">
|
||
<select class="kt-ai-select" id="statusFilter" data-kt-filter="status">
|
||
<option value="">Все статусы</option>
|
||
<option value="new">Новые</option>
|
||
<option value="in_progress">В работе</option>
|
||
<option value="completed">Завершено</option>
|
||
<option value="cancelled">Отменено</option>
|
||
</select>
|
||
<select class="kt-ai-select" id="periodFilter" data-kt-filter="period">
|
||
<option value="7">7 дней</option>
|
||
<option value="14">14 дней</option>
|
||
<option value="30">30 дней</option>
|
||
<option value="all">Все время</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="kt-ai-table-wrap" id="tableWrap">
|
||
<table class="kt-ai-table">
|
||
<thead>
|
||
<tr>
|
||
<th>ID</th>
|
||
<th>Тема</th>
|
||
<th>Заявитель</th>
|
||
<th>Дата</th>
|
||
<th>Статус</th>
|
||
<th>Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="tableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="empty-state" id="emptyState" style="display: none;">
|
||
<div class="empty-icon">📭</div>
|
||
<div class="empty-title">Нет данных</div>
|
||
<div class="empty-desc">По выбранным фильтрам заявок не найдено</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="chart-container">
|
||
<div class="chart-title">Заявки по дням</div>
|
||
<svg class="chart-svg" id="chartSvg" viewBox="0 0 600 200"></svg>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-section">
|
||
<div class="form-card">
|
||
<div class="form-title">Новая заявка</div>
|
||
<form id="createForm">
|
||
<div class="kt-ai-field">
|
||
<label for="subject">Тема</label>
|
||
<input type="text" class="kt-ai-input" id="subject" required placeholder="Краткое описание">
|
||
</div>
|
||
<div class="kt-ai-field">
|
||
<label for="applicant">Заявитель</label>
|
||
<input type="text" class="kt-ai-input" id="applicant" required placeholder="ФИО или отдел">
|
||
</div>
|
||
<div class="kt-ai-field">
|
||
<label for="description">Описание</label>
|
||
<textarea class="kt-ai-textarea" id="description" required placeholder="Подробности задачи"></textarea>
|
||
</div>
|
||
<div class="kt-ai-field">
|
||
<label for="priority">Приоритет</label>
|
||
<select class="kt-ai-select" id="priority">
|
||
<option value="low">Низкий</option>
|
||
<option value="normal" selected>Обычный</option>
|
||
<option value="high">Высокий</option>
|
||
<option value="urgent">Срочный</option>
|
||
</select>
|
||
</div>
|
||
<button type="submit" class="kt-ai-btn" data-variant="primary" data-size="lg" style="width: 100%;">Создать заявку</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</main>
|
||
|
||
<div class="toast" id="toast">
|
||
<div class="toast-message" id="toastMessage"></div>
|
||
</div>
|
||
|
||
<script>
|
||
const STORAGE_KEY = 'tickets_data_v1';
|
||
|
||
const defaultData = {
|
||
tickets: [
|
||
{ id: 1001, subject: 'Настройка рабочего места', applicant: 'Иванов А.С.', description: 'Установка ПО для нового сотрудника', priority: 'normal', status: 'completed', createdAt: '2024-01-15', completedAt: '2024-01-16' },
|
||
{ id: 1002, subject: 'Ремонт принтера', applicant: 'Отдел продаж', description: 'Замятие бумаги, требуется ремонт', priority: 'high', status: 'in_progress', createdAt: '2024-01-18', completedAt: null },
|
||
{ id: 1003, subject: 'Доступ к CRM', applicant: 'Петрова Е.В.', description: 'Запрос прав доступа к системе', priority: 'normal', status: 'new', createdAt: '2024-01-19', completedAt: null },
|
||
{ id: 1004, subject: 'Замена монитора', applicant: 'Сидоров К.М.', description: 'Мерцание экрана, артефакты', priority: 'low', status: 'cancelled', createdAt: '2024-01-17', completedAt: null },
|
||
{ id: 1005, subject: 'Подключение телефона', applicant: 'Бухгалтерия', description: 'Добавить внутренний номер', priority: 'normal', status: 'completed', createdAt: '2024-01-20', completedAt: '2024-01-20' },
|
||
{ id: 1006, subject: 'Обновление антивируса', applicant: 'IT отдел', description: 'Плановое обновление баз', priority: 'high', status: 'in_progress', createdAt: '2024-01-21', completedAt: null },
|
||
{ id: 1007, subject: 'Настройка VPN', applicant: 'Ахметов Р.Б.', description: 'Удалённый доступ для работы из дома', priority: 'urgent', status: 'new', createdAt: '2024-01-22', completedAt: null },
|
||
]
|
||
};
|
||
|
||
let data = loadData();
|
||
|
||
function loadData() {
|
||
const stored = localStorage.getItem(STORAGE_KEY);
|
||
if (stored) {
|
||
try {
|
||
return JSON.parse(stored);
|
||
} catch (e) {
|
||
return defaultData;
|
||
}
|
||
}
|
||
return defaultData;
|
||
}
|
||
|
||
function saveData() {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||
}
|
||
|
||
function resetData() {
|
||
localStorage.removeItem(STORAGE_KEY);
|
||
data = defaultData;
|
||
renderAll();
|
||
showToast('Данные сброшены к демо-версии');
|
||
}
|
||
|
||
function generateId() {
|
||
const max = data.tickets.reduce((max, t) => Math.max(max, t.id), 1000);
|
||
return max + 1;
|
||
}
|
||
|
||
function formatDate(dateStr) {
|
||
const d = new Date(dateStr);
|
||
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' });
|
||
}
|
||
|
||
function getStatusLabel(status) {
|
||
const labels = { new: 'Новая', in_progress: 'В работе', completed: 'Завершено', cancelled: 'Отменено' };
|
||
return labels[status] || status;
|
||
}
|
||
|
||
function getStatusClass(status) {
|
||
const classes = { new: 'status-new', in_progress: 'status-progress', completed: 'status-done', cancelled: 'status-cancelled' };
|
||
return classes[status] || '';
|
||
}
|
||
|
||
function getFilteredTickets() {
|
||
const statusFilter = document.getElementById('statusFilter').value;
|
||
const periodFilter = document.getElementById('periodFilter').value;
|
||
|
||
let filtered = [...data.tickets];
|
||
|
||
if (statusFilter) {
|
||
filtered = filtered.filter(t => t.status === statusFilter);
|
||
}
|
||
|
||
if (periodFilter !== 'all') {
|
||
const days = parseInt(periodFilter);
|
||
const cutoff = new Date();
|
||
cutoff.setDate(cutoff.getDate() - days);
|
||
filtered = filtered.filter(t => new Date(t.createdAt) >= cutoff);
|
||
}
|
||
|
||
return filtered.sort((a, b) => b.id - a.id);
|
||
}
|
||
|
||
function renderKPIs() {
|
||
const tickets = data.tickets;
|
||
const total = tickets.length;
|
||
const active = tickets.filter(t => t.status === 'new' || t.status === 'in_progress').length;
|
||
const completed = tickets.filter(t => t.status === 'completed').length;
|
||
|
||
const completedTickets = tickets.filter(t => t.status === 'completed' && t.completedAt);
|
||
let avgDays = 0;
|
||
if (completedTickets.length > 0) {
|
||
const totalDays = completedTickets.reduce((sum, t) => {
|
||
const start = new Date(t.createdAt);
|
||
const end = new Date(t.completedAt);
|
||
return sum + (end - start) / (1000 * 60 * 60 * 24);
|
||
}, 0);
|
||
avgDays = totalDays / completedTickets.length;
|
||
}
|
||
|
||
document.getElementById('kpiTotal').textContent = total;
|
||
document.getElementById('kpiActive').textContent = active;
|
||
document.getElementById('kpiCompleted').textContent = completed;
|
||
document.getElementById('kpiAvgTime').textContent = avgDays.toFixed(1);
|
||
}
|
||
|
||
function renderTable() {
|
||
const filtered = getFilteredTickets();
|
||
const tbody = document.getElementById('tableBody');
|
||
const tableWrap = document.getElementById('tableWrap');
|
||
const emptyState = document.getElementById('emptyState');
|
||
|
||
if (filtered.length === 0) {
|
||
tableWrap.style.display = 'none';
|
||
emptyState.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
tableWrap.style.display = 'block';
|
||
emptyState.style.display = 'none';
|
||
|
||
tbody.innerHTML = filtered.map(t => `
|
||
<tr>
|
||
<td style="font-family: var(--kt-ai-font-mono);">${t.id}</td>
|
||
<td>${t.subject}</td>
|
||
<td>${t.applicant}</td>
|
||
<td>${formatDate(t.createdAt)}</td>
|
||
<td><span class="status-chip ${getStatusClass(t.status)}">${getStatusLabel(t.status)}</span></td>
|
||
<td>
|
||
${t.status === 'new' ? `<button class="kt-ai-btn" data-variant="ghost" data-size="sm" onclick="updateStatus(${t.id}, 'in_progress')">В работу</button>` : ''}
|
||
${t.status === 'in_progress' ? `<button class="kt-ai-btn" data-variant="ghost" data-size="sm" onclick="updateStatus(${t.id}, 'completed')">Завершить</button>` : ''}
|
||
${t.status === 'new' || t.status === 'in_progress' ? `<button class="kt-ai-btn" data-variant="ghost" data-size="sm" onclick="updateStatus(${t.id}, 'cancelled')">Отменить</button>` : ''}
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
function updateStatus(id, status) {
|
||
const ticket = data.tickets.find(t => t.id === id);
|
||
if (ticket) {
|
||
ticket.status = status;
|
||
if (status === 'completed') {
|
||
ticket.completedAt = new Date().toISOString().split('T')[0];
|
||
} else {
|
||
ticket.completedAt = null;
|
||
}
|
||
saveData();
|
||
renderAll();
|
||
showToast(`Заявка #${id}: ${getStatusLabel(status)}`);
|
||
}
|
||
}
|
||
|
||
function renderChart() {
|
||
const svg = document.getElementById('chartSvg');
|
||
const periodFilter = document.getElementById('periodFilter').value;
|
||
const days = periodFilter === 'all' ? 30 : parseInt(periodFilter);
|
||
|
||
const counts = {};
|
||
const today = new Date();
|
||
for (let i = days - 1; i >= 0; i--) {
|
||
const d = new Date(today);
|
||
d.setDate(d.getDate() - i);
|
||
const key = d.toISOString().split('T')[0];
|
||
counts[key] = 0;
|
||
}
|
||
|
||
data.tickets.forEach(t => {
|
||
if (counts.hasOwnProperty(t.createdAt)) {
|
||
counts[t.createdAt]++;
|
||
}
|
||
});
|
||
|
||
const labels = Object.keys(counts);
|
||
const values = Object.values(counts);
|
||
const max = Math.max(...values, 1);
|
||
|
||
const barWidth = 24;
|
||
const gap = 8;
|
||
const chartWidth = labels.length * (barWidth + gap);
|
||
const chartHeight = 180;
|
||
const padding = { top: 10, right: 10, bottom: 30, left: 30 };
|
||
|
||
let svgContent = `<rect width="100%" height="100%" fill="none"/>`;
|
||
|
||
labels.forEach((label, i) => {
|
||
const x = padding.left + i * (barWidth + gap);
|
||
const h = (values[i] / max) * (chartHeight - padding.bottom);
|
||
const y = chartHeight - padding.bottom - h;
|
||
|
||
const date = new Date(label);
|
||
const shortLabel = date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' });
|
||
|
||
svgContent += `
|
||
<rect class="chart-bar" x="${x}" y="${y}" width="${barWidth}" height="${h}"
|
||
fill="var(--kt-ai-chart-blue)" rx="2"
|
||
data-kt-tip="${values[i]} заявок (${shortLabel})">
|
||
<title>${values[i]} заявок — ${shortLabel}</title>
|
||
</rect>
|
||
<text x="${x + barWidth/2}" y="${chartHeight - 8}"
|
||
text-anchor="middle" font-size="9" fill="var(--kt-ai-fg-muted)">
|
||
${date.getDate()}.${date.getMonth()+1}
|
||
</text>
|
||
`;
|
||
});
|
||
|
||
svg.innerHTML = svgContent;
|
||
}
|
||
|
||
function showToast(message) {
|
||
const toast = document.getElementById('toast');
|
||
const toastMessage = document.getElementById('toastMessage');
|
||
toastMessage.textContent = message;
|
||
toast.classList.add('show');
|
||
setTimeout(() => toast.classList.remove('show'), 3000);
|
||
}
|
||
|
||
function renderAll() {
|
||
renderKPIs();
|
||
renderTable();
|
||
renderChart();
|
||
}
|
||
|
||
document.getElementById('createForm').addEventListener('submit', function(e) {
|
||
e.preventDefault();
|
||
|
||
const subject = document.getElementById('subject').value.trim();
|
||
const applicant = document.getElementById('applicant').value.trim();
|
||
const description = document.getElementById('description').value.trim();
|
||
const priority = document.getElementById('priority').value;
|
||
|
||
if (!subject || !applicant || !description) {
|
||
showToast('Заполните все обязательные поля');
|
||
return;
|
||
}
|
||
|
||
const ticket = {
|
||
id: generateId(),
|
||
subject,
|
||
applicant,
|
||
description,
|
||
priority,
|
||
status: 'new',
|
||
createdAt: new Date().toISOString().split('T')[0],
|
||
completedAt: null
|
||
};
|
||
|
||
data.tickets.push(ticket);
|
||
saveData();
|
||
|
||
this.reset();
|
||
renderAll();
|
||
showToast(`Заявка #${ticket.id} создана`);
|
||
});
|
||
|
||
document.getElementById('statusFilter').addEventListener('change', renderAll);
|
||
document.getElementById('periodFilter').addEventListener('change', renderAll);
|
||
document.getElementById('resetDataBtn').addEventListener('click', function() {
|
||
if (confirm('Сбросить все данные к демо-версии?')) {
|
||
resetData();
|
||
}
|
||
});
|
||
|
||
renderAll();
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|