330 lines
15 KiB
HTML
330 lines
15 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>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f7fa; color: #1a1a1a; padding: 20px; }
|
||
.container { max-width: 1400px; margin: 0 auto; }
|
||
h1 { font-size: 28px; margin-bottom: 10px; color: #1a1a1a; }
|
||
.subtitle { color: #666; margin-bottom: 30px; }
|
||
.summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 30px; }
|
||
.summary-card { background: white; padding: 20px; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||
.summary-card h3 { font-size: 14px; color: #666; margin-bottom: 8px; }
|
||
.summary-card .value { font-size: 24px; font-weight: bold; color: #1a1a1a; }
|
||
.summary-card .value.warning { color: #f59e0b; }
|
||
.summary-card .value.success { color: #10b981; }
|
||
.apartments { display: grid; gap: 20px; }
|
||
.apartment { background: white; border-radius: 12px; padding: 25px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||
.apartment-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 15px; }
|
||
.apartment-header h2 { font-size: 20px; color: #1a1a1a; }
|
||
.apartment-header .tenant { color: #666; font-size: 14px; }
|
||
.status { padding: 6px 14px; border-radius: 20px; font-size: 13px; font-weight: 500; }
|
||
.status.not-paid { background: #fee2e2; color: #dc2626; }
|
||
.status.paid { background: #d1fae5; color: #059669; }
|
||
.readings-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 20px; }
|
||
.reading-item { background: #f8fafc; padding: 15px; border-radius: 8px; }
|
||
.reading-item label { display: block; font-size: 13px; color: #64748b; margin-bottom: 8px; }
|
||
.reading-item .reading-row { display: flex; gap: 10px; align-items: center; }
|
||
.reading-item input { width: 100%; padding: 8px 12px; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 14px; }
|
||
.reading-item .diff { font-size: 12px; color: #64748b; white-space: nowrap; }
|
||
.costs-section { background: #f8fafc; padding: 15px; border-radius: 8px; margin-bottom: 20px; }
|
||
.costs-section h3 { font-size: 16px; margin-bottom: 12px; color: #1a1a1a; }
|
||
.cost-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #e2e8f0; font-size: 14px; }
|
||
.cost-row:last-child { border-bottom: none; font-weight: bold; }
|
||
.cost-row .label { color: #64748b; }
|
||
.cost-row .value { font-weight: 500; }
|
||
.cost-row.total { font-size: 16px; color: #1a1a1a; margin-top: 10px; padding-top: 10px; border-top: 2px solid #1a1a1a; }
|
||
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||
button { padding: 10px 20px; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; }
|
||
.btn-primary { background: #3b82f6; color: white; }
|
||
.btn-primary:hover { background: #2563eb; }
|
||
.btn-success { background: #10b981; color: white; }
|
||
.btn-success:hover { background: #059669; }
|
||
.btn-secondary { background: #f1f5f9; color: #475569; }
|
||
.btn-secondary:hover { background: #e2e8f0; }
|
||
.payment-info { background: #fef3c7; border: 1px solid #f59e0b; padding: 15px; border-radius: 8px; margin-top: 20px; }
|
||
.payment-info h3 { font-size: 15px; color: #92400e; margin-bottom: 8px; }
|
||
.payment-info p { font-size: 13px; color: #92400e; }
|
||
.notification { position: fixed; top: 20px; right: 20px; background: #10b981; color: white; padding: 15px 25px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); transform: translateX(400px); transition: transform 0.3s; z-index: 1000; }
|
||
.notification.show { transform: translateX(0); }
|
||
.notification.error { background: #ef4444; }
|
||
@media (max-width: 768px) {
|
||
.apartment-header { flex-direction: column; align-items: flex-start; }
|
||
.readings-grid { grid-template-columns: 1fr; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<h1>🏠 Мониторинг коммунальных платежей</h1>
|
||
<p class="subtitle">Учёт оплаты аренды и коммунальных услуг</p>
|
||
|
||
<div class="summary" id="summary"></div>
|
||
<div class="apartments" id="apartments"></div>
|
||
</div>
|
||
|
||
<div class="notification" id="notification"></div>
|
||
|
||
<script>
|
||
let data = { apartments: [], log: [] };
|
||
|
||
async function loadData() {
|
||
const resp = await fetch("/api/data");
|
||
data = await resp.json();
|
||
render();
|
||
}
|
||
|
||
function calcConsumption(last, current) {
|
||
return Math.max(0, current - last);
|
||
}
|
||
|
||
function calcCost(consumption, rate) {
|
||
return Math.round(consumption * rate);
|
||
}
|
||
|
||
function render() {
|
||
const now = new Date();
|
||
const currentMonth = now.getMonth();
|
||
const currentYear = now.getFullYear();
|
||
|
||
let totalUnpaid = 0;
|
||
let totalApartments = data.apartments.length;
|
||
let paidCount = 0;
|
||
let overdueCount = 0;
|
||
|
||
const summaryEl = document.getElementById("summary");
|
||
const aptsEl = document.getElementById("apartments");
|
||
|
||
let summaryHTML = "";
|
||
let aptsHTML = "";
|
||
|
||
data.apartments.forEach(apt => {
|
||
const electricityCost = calcCost(
|
||
calcConsumption(apt.lastReadings.electricity, apt.currentReadings.electricity),
|
||
apt.meterRates.electricity
|
||
);
|
||
const coldWaterCost = calcCost(
|
||
calcConsumption(apt.lastReadings.coldWater, apt.currentReadings.coldWater),
|
||
apt.meterRates.coldWater
|
||
);
|
||
const hotWaterCost = calcCost(
|
||
calcConsumption(apt.lastReadings.hotWater, apt.currentReadings.hotWater),
|
||
apt.meterRates.hotWater
|
||
);
|
||
const gasCost = calcCost(
|
||
calcConsumption(apt.lastReadings.gas, apt.currentReadings.gas),
|
||
apt.meterRates.gas
|
||
);
|
||
|
||
const variableCosts = electricityCost + coldWaterCost + hotWaterCost + gasCost;
|
||
const totalCost = variableCosts + apt.fixedCosts;
|
||
|
||
if (apt.status === "оплачено") {
|
||
paidCount++;
|
||
} else {
|
||
totalUnpaid += totalCost;
|
||
if (now.getDate() > apt.paymentDay) {
|
||
overdueCount++;
|
||
}
|
||
}
|
||
|
||
const isPaid = apt.status === "оплачено";
|
||
const isOverdue = now.getDate() > apt.paymentDay && !isPaid;
|
||
|
||
aptsHTML += `
|
||
<div class="apartment">
|
||
<div class="apartment-header">
|
||
<div>
|
||
<h2>${apt.name}</h2>
|
||
<p class="tenant">Арендатор: ${apt.tenant} | День оплаты: ${apt.paymentDay}-е число</p>
|
||
</div>
|
||
<span class="status ${isPaid ? 'paid' : (isOverdue ? 'not-paid' : 'not-paid')}">
|
||
${isPaid ? '✓ Оплачено' : (isOverdue ? '⚠ Просрочено' : '⏳ Не оплачено')}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="readings-grid">
|
||
<div class="reading-item">
|
||
<label>⚡ Электроэнергия (тг/кВт·ч: ${apt.meterRates.electricity})</label>
|
||
<div class="reading-row">
|
||
<input type="number" id="elec-${apt.id}" value="${apt.currentReadings.electricity}" placeholder="Текущие">
|
||
<span class="diff">+${calcConsumption(apt.lastReadings.electricity, apt.currentReadings.electricity)} кВт·ч</span>
|
||
</div>
|
||
</div>
|
||
<div class="reading-item">
|
||
<label>💧 Холодная вода (тг/м³: ${apt.meterRates.coldWater})</label>
|
||
<div class="reading-row">
|
||
<input type="number" id="water-${apt.id}" value="${apt.currentReadings.coldWater}" placeholder="Текущие">
|
||
<span class="diff">+${calcConsumption(apt.lastReadings.coldWater, apt.currentReadings.coldWater)} м³</span>
|
||
</div>
|
||
</div>
|
||
<div class="reading-item">
|
||
<label>🔥 Горячая вода (тг/м³: ${apt.meterRates.hotWater})</label>
|
||
<div class="reading-row">
|
||
<input type="number" id="hot-${apt.id}" value="${apt.currentReadings.hotWater}" placeholder="Текущие">
|
||
<span class="diff">+${calcConsumption(apt.lastReadings.hotWater, apt.currentReadings.hotWater)} м³</span>
|
||
</div>
|
||
</div>
|
||
<div class="reading-item">
|
||
<label>🔥 Газ (тг/м³: ${apt.meterRates.gas})</label>
|
||
<div class="reading-row">
|
||
<input type="number" id="gas-${apt.id}" value="${apt.currentReadings.gas}" placeholder="Текущие">
|
||
<span class="diff">+${calcConsumption(apt.lastReadings.gas, apt.currentReadings.gas)} м³</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="costs-section">
|
||
<h3>Расчёт стоимости</h3>
|
||
<div class="cost-row">
|
||
<span class="label">Электроэнергия</span>
|
||
<span class="value">${electricityCost} ₸</span>
|
||
</div>
|
||
<div class="cost-row">
|
||
<span class="label">Холодная вода</span>
|
||
<span class="value">${coldWaterCost} ₸</span>
|
||
</div>
|
||
<div class="cost-row">
|
||
<span class="label">Горячая вода</span>
|
||
<span class="value">${hotWaterCost} ₸</span>
|
||
</div>
|
||
<div class="cost-row">
|
||
<span class="label">Газоснабжение</span>
|
||
<span class="value">${gasCost} ₸</span>
|
||
</div>
|
||
<div class="cost-row">
|
||
<span class="label">Постоянные расходы</span>
|
||
<span class="value">${apt.fixedCosts} ₸</span>
|
||
</div>
|
||
<div class="cost-row total">
|
||
<span class="label">Итого к оплате</span>
|
||
<span class="value" style="color: ${isPaid ? '#059669' : '#dc2626'}">${totalCost} ₸</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="actions">
|
||
<button class="btn-primary" onclick="updateReadings(${apt.id})">Сохранить показания</button>
|
||
${!isPaid ? `<button class="btn-success" onclick="markPaid(${apt.id})">✓ Отметить оплату</button>` : ''}
|
||
<button class="btn-secondary" onclick="exportCsv()">📊 Выгрузить в Excel</button>
|
||
</div>
|
||
|
||
<div class="payment-info">
|
||
<h3>💳 Оплата до ${apt.paymentDay}-го числа</h3>
|
||
<p>Оплата производится на банковскую карту (Forte Bank). После оплаты нажмите "Отметить оплату".</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
summaryHTML = `
|
||
<div class="summary-card">
|
||
<h3>Всего квартир</h3>
|
||
<div class="value">${totalApartments}</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<h3>Оплачено</h3>
|
||
<div class="value success">${paidCount}</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<h3>Не оплачено</h3>
|
||
<div class="value warning">${totalApartments - paidCount}</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<h3>Просрочено</h3>
|
||
<div class="value" style="color: #dc2626">${overdueCount}</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<h3>Общая сумма к оплате</h3>
|
||
<div class="value" style="color: #dc2626">${totalUnpaid.toLocaleString()} ₸</div>
|
||
</div>
|
||
`;
|
||
|
||
summaryEl.innerHTML = summaryHTML;
|
||
aptsEl.innerHTML = aptsHTML;
|
||
}
|
||
|
||
async function updateReadings(apartmentId) {
|
||
const electricity = parseFloat(document.getElementById(`elec-${apartmentId}`).value) || 0;
|
||
const coldWater = parseFloat(document.getElementById(`water-${apartmentId}`).value) || 0;
|
||
const hotWater = parseFloat(document.getElementById(`hot-${apartmentId}`).value) || 0;
|
||
const gas = parseFloat(document.getElementById(`gas-${apartmentId}`).value) || 0;
|
||
|
||
await fetch("/api/update-readings", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
apartmentId,
|
||
readings: { electricity, coldWater, hotWater, gas }
|
||
})
|
||
});
|
||
|
||
showNotification("Показания сохранены!");
|
||
loadData();
|
||
}
|
||
|
||
async function markPaid(apartmentId) {
|
||
await fetch("/api/mark-paid", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ apartmentId })
|
||
});
|
||
|
||
showNotification("Оплата отмечена!");
|
||
loadData();
|
||
}
|
||
|
||
function showNotification(message, isError = false) {
|
||
const notif = document.getElementById("notification");
|
||
notif.textContent = message;
|
||
notif.className = "notification show" + (isError ? " error" : "");
|
||
setTimeout(() => notif.classList.remove("show"), 3000);
|
||
}
|
||
|
||
function exportCsv() {
|
||
const now = new Date();
|
||
const rows = [["Квартира", "Арендатор", "День оплаты", "Электроэнергия", "Хол. вода", "Гор. вода", "Газ", "Постоянные", "Итого", "Статус"]];
|
||
|
||
data.apartments.forEach(apt => {
|
||
const electricityCost = Math.round(
|
||
Math.max(0, apt.currentReadings.electricity - apt.lastReadings.electricity) * apt.meterRates.electricity
|
||
);
|
||
const coldWaterCost = Math.round(
|
||
Math.max(0, apt.currentReadings.coldWater - apt.lastReadings.coldWater) * apt.meterRates.coldWater
|
||
);
|
||
const hotWaterCost = Math.round(
|
||
Math.max(0, apt.currentReadings.hotWater - apt.lastReadings.hotWater) * apt.meterRates.hotWater
|
||
);
|
||
const gasCost = Math.round(
|
||
Math.max(0, apt.currentReadings.gas - apt.lastReadings.gas) * apt.meterRates.gas
|
||
);
|
||
const total = electricityCost + coldWaterCost + hotWaterCost + gasCost + apt.fixedCosts;
|
||
|
||
rows.push([
|
||
apt.name,
|
||
apt.tenant,
|
||
apt.paymentDay,
|
||
electricityCost,
|
||
coldWaterCost,
|
||
hotWaterCost,
|
||
gasCost,
|
||
apt.fixedCosts,
|
||
total,
|
||
apt.status
|
||
]);
|
||
});
|
||
|
||
const csv = "\uFEFF" + 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 = `komunalnye_${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}.csv`;
|
||
a.click();
|
||
}
|
||
|
||
loadData();
|
||
</script>
|
||
</body>
|
||
</html>
|