434 lines
20 KiB
HTML
434 lines
20 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 {
|
||
--bg: #f5f7fa;
|
||
--card: #ffffff;
|
||
--text: #1f2937;
|
||
--text-secondary: #6b7280;
|
||
--primary: #2563eb;
|
||
--primary-hover: #1d4ed8;
|
||
--success: #10b981;
|
||
--warning: #f59e0b;
|
||
--danger: #ef4444;
|
||
--info: #3b82f6;
|
||
--border: #e5e7eb;
|
||
--shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06);
|
||
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05);
|
||
}
|
||
* { margin: 0; box-sizing: border-box; }
|
||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
|
||
.container { max-width: 1400px; margin: 0 auto; padding: 24px; }
|
||
|
||
/* Header */
|
||
.header { background: var(--card); border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: var(--shadow); }
|
||
.header h1 { font-size: 28px; font-weight: 700; margin-bottom: 8px; color: var(--text); }
|
||
.header p { color: var(--text-secondary); font-size: 14px; }
|
||
|
||
/* Stats */
|
||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
||
.stat-card { background: var(--card); border-radius: 10px; padding: 20px; box-shadow: var(--shadow); text-align: center; }
|
||
.stat-value { font-size: 32px; font-weight: 700; color: var(--primary); }
|
||
.stat-label { font-size: 13px; color: var(--text-secondary); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||
.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.info .stat-value { color: var(--info); }
|
||
|
||
/* Controls */
|
||
.controls { background: var(--card); border-radius: 12px; padding: 20px; margin-bottom: 24px; box-shadow: var(--shadow); display: flex; flex-wrap: wrap; gap: 16px; align-items: center; justify-content: space-between; }
|
||
.search-box { flex: 1; min-width: 280px; position: relative; }
|
||
.search-box input { width: 100%; padding: 12px 16px 12px 44px; border: 1px solid var(--border); border-radius: 8px; font-size: 14px; transition: all 0.2s; }
|
||
.search-box input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); }
|
||
.search-box::before { content: "🔍"; position: absolute; left: 14px; top: 50%; transform: translateY(-50%); font-size: 16px; }
|
||
.chips { display: flex; gap: 8px; flex-wrap: wrap; }
|
||
.chip { padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 500; cursor: pointer; border: 2px solid var(--border); background: var(--card); transition: all 0.2s; }
|
||
.chip:hover { border-color: var(--primary); }
|
||
.chip.active { background: var(--primary); color: white; border-color: var(--primary); }
|
||
.btn { padding: 12px 24px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; border: none; transition: all 0.2s; display: inline-flex; align-items: center; gap: 8px; }
|
||
.btn-primary { background: var(--primary); color: white; }
|
||
.btn-primary:hover { background: var(--primary-hover); transform: translateY(-1px); box-shadow: var(--shadow-lg); }
|
||
.btn-secondary { background: var(--card); color: var(--text); border: 1px solid var(--border); }
|
||
.btn-secondary:hover { background: var(--bg); }
|
||
.btn-danger { background: var(--danger); color: white; }
|
||
.btn-danger:hover { background: #dc2626; }
|
||
.btn-sm { padding: 6px 12px; font-size: 13px; }
|
||
|
||
/* Table */
|
||
.table-container { background: var(--card); border-radius: 12px; overflow: hidden; box-shadow: var(--shadow); margin-bottom: 24px; }
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th, td { padding: 14px 16px; text-align: left; border-bottom: 1px solid var(--border); }
|
||
th { background: var(--bg); font-weight: 600; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); }
|
||
tr:hover { background: #f9fafb; }
|
||
.status-badge { padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600; text-transform: uppercase; }
|
||
.status-new { background: #dbeafe; color: #1e40af; }
|
||
.status-work { background: #fef3c7; color: #92400e; }
|
||
.status-done { background: #d1fae5; color: #065f46; }
|
||
.status-overdue { background: #fee2e2; color: #991b1b; }
|
||
.overdue-row { background: #fef2f2 !important; }
|
||
.overdue-row:hover { background: #fee2e2 !important; }
|
||
.actions { display: flex; gap: 8px; }
|
||
|
||
/* Log */
|
||
.log-container { background: var(--card); border-radius: 12px; padding: 20px; box-shadow: var(--shadow); }
|
||
.log-container h2 { font-size: 18px; margin-bottom: 16px; }
|
||
.log-list { max-height: 200px; overflow-y: auto; }
|
||
.log-item { padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||
.log-item:last-child { border-bottom: none; }
|
||
.log-time { color: var(--text-secondary); margin-right: 8px; }
|
||
|
||
/* Modal */
|
||
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: none; align-items: center; justify-content: center; z-index: 1000; }
|
||
.modal-overlay.open { display: flex; }
|
||
.modal { background: var(--card); border-radius: 12px; padding: 32px; width: 100%; max-width: 560px; max-height: 90vh; overflow-y: auto; box-shadow: var(--shadow-lg); }
|
||
.modal h2 { font-size: 22px; margin-bottom: 24px; }
|
||
.form-group { margin-bottom: 20px; }
|
||
.form-group label { display: block; font-size: 14px; font-weight: 500; margin-bottom: 8px; color: var(--text); }
|
||
.form-group input, .form-group select, .form-group textarea { width: 100%; padding: 12px; border: 1px solid var(--border); border-radius: 8px; font-size: 14px; transition: all 0.2s; }
|
||
.form-group input:focus, .form-group select:focus, .form-group textarea:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); }
|
||
.form-actions { display: flex; gap: 12px; justify-content: flex-end; margin-top: 24px; }
|
||
|
||
/* Empty state */
|
||
.empty { text-align: center; padding: 60px 20px; color: var(--text-secondary); }
|
||
.empty-icon { font-size: 48px; margin-bottom: 16px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<div class="header">
|
||
<h1>📋 Журнал ремонта оборудования</h1>
|
||
<p>Учёт заявок на ремонт: от создания до выполнения</p>
|
||
</div>
|
||
|
||
<div class="stats" id="stats"></div>
|
||
|
||
<div class="controls">
|
||
<div class="search-box">
|
||
<input type="text" id="search" placeholder="Поиск по таблице...">
|
||
</div>
|
||
<div class="chips" id="chips"></div>
|
||
<button class="btn btn-primary" onclick="openModal()">+ Добавить заявку</button>
|
||
</div>
|
||
|
||
<div class="table-container">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>№</th>
|
||
<th>Что сломалось</th>
|
||
<th>Где стоит</th>
|
||
<th>Кто заявил</th>
|
||
<th>Кто чинит</th>
|
||
<th>Срок</th>
|
||
<th>Статус</th>
|
||
<th>Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="tableBody"></tbody>
|
||
</table>
|
||
<div class="empty" id="empty" style="display:none;">
|
||
<div class="empty-icon">📭</div>
|
||
<p>Нет заявок. Создайте первую!</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="log-container">
|
||
<h2>📜 Журнал изменений</h2>
|
||
<div class="log-list" id="logList"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal -->
|
||
<div class="modal-overlay" id="modalOverlay">
|
||
<div class="modal">
|
||
<h2 id="modalTitle">Добавить заявку</h2>
|
||
<form id="form">
|
||
<input type="hidden" id="editId">
|
||
<div class="form-group">
|
||
<label>Что сломалось *</label>
|
||
<input type="text" id="issue" required placeholder="Например: Принтер не печатает">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Где стоит *</label>
|
||
<input type="text" id="location" required placeholder="Например: Офис Алматы, 2 этаж">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Кто заявил *</label>
|
||
<input type="text" id="requester" required placeholder="ФИО заявителя">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Кто чинит *</label>
|
||
<input type="text" id="assignee" required placeholder="ФИО исполнителя">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Срок *</label>
|
||
<input type="text" id="deadline" required placeholder="ДД.ММ.ГГГГ">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Статус *</label>
|
||
<select id="status" required>
|
||
<option value="new">Новая</option>
|
||
<option value="work">В работе</option>
|
||
<option value="done">Выполнена</option>
|
||
<option value="overdue">Просрочена</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-actions">
|
||
<button type="button" class="btn btn-secondary" onclick="closeModal()">Отмена</button>
|
||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// Demo data
|
||
const DEMO = [
|
||
{ id: 1, issue: "Принтер не печатает", location: "Офис Алматы, 2 этаж", requester: "Айдос К.", assignee: "Ерлан М.", deadline: formatDate(addDays(5)), status: "new" },
|
||
{ id: 2, issue: "Замена блока питания", location: "Склад Астана", requester: "Гульнар С.", assignee: "Серик Б.", deadline: formatDate(addDays(3)), status: "new" },
|
||
{ id: 3, issue: "Не работает моноблок", location: "Офис Шымкент", requester: "Мадина Т.", assignee: "Ерлан М.", deadline: formatDate(addDays(2)), status: "work" },
|
||
{ id: 4, issue: "Шумит кондиционер", location: "Караганда, офис 3", requester: "Нургуль А.", assignee: "Айдос К.", deadline: formatDate(addDays(7)), status: "work" },
|
||
{ id: 5, issue: "Роутер не включается", location: "Тараз, филиал", requester: "Серик Б.", assignee: "Гульнар С.", deadline: formatDate(-3), status: "overdue" },
|
||
{ id: 6, issue: "Замена картриджа", location: "Алматы, главный офис", requester: "Айдос К.", assignee: "Серик Б.", deadline: formatDate(-1), status: "done" },
|
||
{ id: 7, issue: "Настройка сканера", location: "Астана, отделение 2", requester: "Мадина Т.", assignee: "Ерлан М.", deadline: formatDate(1), status: "done" }
|
||
];
|
||
|
||
let items = load("items") || DEMO;
|
||
let log = load("log") || [];
|
||
let filterStatus = "all";
|
||
let searchTerm = "";
|
||
|
||
function load(key) { try { return JSON.parse(localStorage.getItem(key)); } catch { return null; } }
|
||
function save(key, val) { localStorage.setItem(key, JSON.stringify(val)); }
|
||
function addDays(d) { const dt = new Date(); dt.setDate(dt.getDate() + d); return dt; }
|
||
function formatDate(dt) { const d = typeof dt === "number" ? addDays(dt) : new Date(dt); return d.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric" }); }
|
||
function parseDate(s) { const [d, m, y] = s.split(".").map(Number); return new Date(y, m - 1, d); }
|
||
function isOverdue(s) { return parseDate(s) < new Date(new Date().toDateString()); }
|
||
function uid() { return Date.now(); }
|
||
|
||
function addLog(action, item) {
|
||
log.unshift({ time: new Date().toLocaleString("ru-RU"), action, item: item.issue, who: "Текущий пользователь" });
|
||
save("log", log);
|
||
renderLog();
|
||
}
|
||
|
||
function getStatusLabel(s) {
|
||
return { new: "Новая", work: "В работе", done: "Выполнена", overdue: "Просрочена" }[s];
|
||
}
|
||
|
||
function getStatusClass(s) {
|
||
return { new: "status-new", work: "status-work", done: "status-done", overdue: "status-overdue" }[s];
|
||
}
|
||
|
||
function getStats() {
|
||
const stats = { total: items.length, new: 0, work: 0, done: 0, overdue: 0 };
|
||
items.forEach(i => {
|
||
if (i.status === "overdue" || (i.status !== "done" && isOverdue(i.deadline))) stats.overdue++;
|
||
else stats[i.status]++;
|
||
});
|
||
return stats;
|
||
}
|
||
|
||
function renderStats() {
|
||
const s = getStats();
|
||
document.getElementById("stats").innerHTML = `
|
||
<div class="stat-card"><div class="stat-value">${s.total}</div><div class="stat-label">Всего</div></div>
|
||
<div class="stat-card info"><div class="stat-value">${s.new}</div><div class="stat-label">Новые</div></div>
|
||
<div class="stat-card warning"><div class="stat-value">${s.work}</div><div class="stat-label">В работе</div></div>
|
||
<div class="stat-card success"><div class="stat-value">${s.done}</div><div class="stat-label">Выполнены</div></div>
|
||
<div class="stat-card danger"><div class="stat-value">${s.overdue}</div><div class="stat-label">Просрочены</div></div>
|
||
`;
|
||
}
|
||
|
||
function renderChips() {
|
||
const chips = [
|
||
{ id: "all", label: "Все" },
|
||
{ id: "new", label: "Новые" },
|
||
{ id: "work", label: "В работе" },
|
||
{ id: "done", label: "Выполнены" },
|
||
{ id: "overdue", label: "Просрочены" }
|
||
];
|
||
document.getElementById("chips").innerHTML = chips.map(c =>
|
||
`<span class="chip${filterStatus === c.id ? " active" : ""}" onclick="setFilter('${c.id}')">${c.label}</span>`
|
||
).join("");
|
||
}
|
||
|
||
function setFilter(f) { filterStatus = f; render(); }
|
||
|
||
function getFiltered() {
|
||
let res = items;
|
||
if (filterStatus !== "all") {
|
||
res = res.filter(i => filterStatus === "overdue"
|
||
? (i.status === "overdue" || (i.status !== "done" && isOverdue(i.deadline)))
|
||
: i.status === filterStatus);
|
||
}
|
||
if (searchTerm) {
|
||
const s = searchTerm.toLowerCase();
|
||
res = res.filter(i =>
|
||
i.issue.toLowerCase().includes(s) ||
|
||
i.location.toLowerCase().includes(s) ||
|
||
i.requester.toLowerCase().includes(s) ||
|
||
i.assignee.toLowerCase().includes(s) ||
|
||
i.deadline.includes(s) ||
|
||
getStatusLabel(i.status).toLowerCase().includes(s)
|
||
);
|
||
}
|
||
return res;
|
||
}
|
||
|
||
function renderTable() {
|
||
const filtered = getFiltered();
|
||
const tbody = document.getElementById("tableBody");
|
||
const empty = document.getElementById("empty");
|
||
|
||
if (filtered.length === 0) {
|
||
tbody.innerHTML = "";
|
||
empty.style.display = "block";
|
||
return;
|
||
}
|
||
|
||
empty.style.display = "none";
|
||
tbody.innerHTML = filtered.map((i, idx) => {
|
||
const overdue = i.status === "overdue" || (i.status !== "done" && isOverdue(i.deadline));
|
||
return `
|
||
<tr class="${overdue ? "overdue-row" : ""}">
|
||
<td>${idx + 1}</td>
|
||
<td>${i.issue}</td>
|
||
<td>${i.location}</td>
|
||
<td>${i.requester}</td>
|
||
<td>${i.assignee}</td>
|
||
<td>${i.deadline}</td>
|
||
<td><span class="status-badge ${getStatusClass(i.status)}">${getStatusLabel(i.status)}</span></td>
|
||
<td class="actions">
|
||
<button class="btn btn-sm btn-secondary" onclick="editItem(${i.id})">✏️</button>
|
||
<button class="btn btn-sm btn-danger" onclick="deleteItem(${i.id})">🗑️</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join("");
|
||
}
|
||
|
||
function renderLog() {
|
||
document.getElementById("logList").innerHTML = log.map(l =>
|
||
`<div class="log-item"><span class="log-time">${l.time}</span>${l.action}: <b>${l.item}</b> (${l.who})</div>`
|
||
).join("");
|
||
}
|
||
|
||
function render() {
|
||
renderStats();
|
||
renderChips();
|
||
renderTable();
|
||
}
|
||
|
||
function openModal(id = null) {
|
||
const overlay = document.getElementById("modalOverlay");
|
||
const title = document.getElementById("modalTitle");
|
||
const form = document.getElementById("form");
|
||
|
||
form.reset();
|
||
document.getElementById("editId").value = "";
|
||
|
||
if (id) {
|
||
const item = items.find(i => i.id === id);
|
||
if (item) {
|
||
title.textContent = "Редактировать заявку";
|
||
document.getElementById("editId").value = item.id;
|
||
document.getElementById("issue").value = item.issue;
|
||
document.getElementById("location").value = item.location;
|
||
document.getElementById("requester").value = item.requester;
|
||
document.getElementById("assignee").value = item.assignee;
|
||
document.getElementById("deadline").value = item.deadline;
|
||
document.getElementById("status").value = item.status;
|
||
}
|
||
} else {
|
||
title.textContent = "Добавить заявку";
|
||
}
|
||
|
||
overlay.classList.add("open");
|
||
}
|
||
|
||
function closeModal() {
|
||
document.getElementById("modalOverlay").classList.remove("open");
|
||
}
|
||
|
||
function editItem(id) {
|
||
openModal(id);
|
||
}
|
||
|
||
function deleteItem(id) {
|
||
const item = items.find(i => i.id === id);
|
||
if (!item) return;
|
||
|
||
if (confirm(`Удалить заявку "${item.issue}"?`)) {
|
||
addLog("Удалена", item);
|
||
items = items.filter(i => i.id !== id);
|
||
save("items", items);
|
||
render();
|
||
}
|
||
}
|
||
|
||
function exportCSV() {
|
||
const head = ["№", "Что сломалось", "Где стоит", "Кто заявил", "Кто чинит", "Срок", "Статус"];
|
||
const rows = getFiltered().map((i, idx) => [
|
||
idx + 1, i.issue, i.location, i.requester, i.assignee, i.deadline, getStatusLabel(i.status)
|
||
]);
|
||
const csv = "\uFEFF" + [head, ...rows].map(r => r.map(v => '"' + String(v ?? "").replace(/"/g, '""') + '"').join(";")).join("\n");
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(new Blob([csv], { type: "text/csv" }));
|
||
a.download = "zhurnal_remonta.csv";
|
||
a.click();
|
||
addLog("Выгрузка в CSV", { issue: "Экспорт" });
|
||
}
|
||
|
||
// Event listeners
|
||
document.getElementById("form").addEventListener("submit", (e) => {
|
||
e.preventDefault();
|
||
const id = document.getElementById("editId").value;
|
||
const data = {
|
||
issue: document.getElementById("issue").value,
|
||
location: document.getElementById("location").value,
|
||
requester: document.getElementById("requester").value,
|
||
assignee: document.getElementById("assignee").value,
|
||
deadline: document.getElementById("deadline").value,
|
||
status: document.getElementById("status").value
|
||
};
|
||
|
||
if (id) {
|
||
const idx = items.findIndex(i => i.id == id);
|
||
if (idx !== -1) {
|
||
const old = items[idx];
|
||
items[idx] = { ...old, ...data };
|
||
addLog("Изменена", { issue: data.issue });
|
||
}
|
||
} else {
|
||
const newItem = { id: uid(), ...data };
|
||
items.push(newItem);
|
||
addLog("Создана", { issue: data.issue });
|
||
}
|
||
|
||
save("items", items);
|
||
closeModal();
|
||
render();
|
||
});
|
||
|
||
document.getElementById("search").addEventListener("input", (e) => {
|
||
searchTerm = e.target.value;
|
||
renderTable();
|
||
});
|
||
|
||
// Add export button
|
||
document.querySelector(".controls").insertAdjacentHTML("beforeend",
|
||
`<button class="btn btn-secondary" onclick="exportCSV()">📥 Выгрузить в CSV</button>`
|
||
);
|
||
|
||
// Init
|
||
render();
|
||
renderLog();
|
||
</script>
|
||
</body>
|
||
</html>
|