960 lines
39 KiB
JavaScript
960 lines
39 KiB
JavaScript
// Global State
|
|
let currentTab = 'feed';
|
|
let issuesData = [];
|
|
let statsData = null;
|
|
let leafletMap = null;
|
|
let mapMarkers = [];
|
|
let pickerMap = null;
|
|
let pickerMarker = null;
|
|
let categoryChartInstance = null;
|
|
let statusChartInstance = null;
|
|
|
|
// Category Config
|
|
const CATEGORY_META = {
|
|
'آسفالت و معابر': { icon: 'fa-road', color: 'text-amber-500', bg: 'bg-amber-50', badge: 'bg-amber-100 text-amber-800' },
|
|
'روشنایی و برق': { icon: 'fa-lightbulb', color: 'text-yellow-500', bg: 'bg-yellow-50', badge: 'bg-yellow-100 text-yellow-800' },
|
|
'نظافت و پسماند': { icon: 'fa-trash-can', color: 'text-red-500', bg: 'bg-red-50', badge: 'bg-red-100 text-red-800' },
|
|
'فضای سبز و بوستانها': { icon: 'fa-tree', color: 'text-emerald-500', bg: 'bg-emerald-50', badge: 'bg-emerald-100 text-emerald-800' },
|
|
'ترافیک و حملونقل': { icon: 'fa-traffic-light', color: 'text-blue-500', bg: 'bg-blue-50', badge: 'bg-blue-100 text-blue-800' },
|
|
'ساختمانسازی و سد معبر': { icon: 'fa-person-digging', color: 'text-orange-500', bg: 'bg-orange-50', badge: 'bg-orange-100 text-orange-800' },
|
|
};
|
|
|
|
const STATUS_META = {
|
|
'pending': { label: 'در انتظار بررسی', color: 'bg-slate-100 text-slate-700 border-slate-300', dot: 'bg-slate-400', step: 1 },
|
|
'reviewing': { label: 'بررسی کارشناسی', color: 'bg-blue-100 text-blue-800 border-blue-200', dot: 'bg-blue-500', step: 2 },
|
|
'in_progress': { label: 'در حال انجام عملیات', color: 'bg-amber-100 text-amber-800 border-amber-200', dot: 'bg-amber-500', step: 3 },
|
|
'resolved': { label: 'رفع و حل شده', color: 'bg-emerald-100 text-emerald-800 border-emerald-200', dot: 'bg-emerald-500', step: 4 },
|
|
'rejected': { label: 'رد شده / خارج از اختیارات', color: 'bg-rose-100 text-rose-800 border-rose-200', dot: 'bg-rose-500', step: 0 }
|
|
};
|
|
|
|
const PRIORITY_META = {
|
|
'emergency': { label: '🚨 فوری و خطرآفرین', badge: 'bg-red-600 text-white animate-pulse' },
|
|
'high': { label: 'اولویت بالا', badge: 'bg-orange-100 text-orange-800' },
|
|
'medium': { label: 'متوسط', badge: 'bg-yellow-100 text-yellow-800' },
|
|
'low': { label: 'عادی', badge: 'bg-slate-100 text-slate-700' }
|
|
};
|
|
|
|
// Initialize Application
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
setupCategoryRadios();
|
|
loadStats();
|
|
fetchIssues();
|
|
updateClock();
|
|
setInterval(updateClock, 60000);
|
|
});
|
|
|
|
function updateClock() {
|
|
const el = document.getElementById('currentDateTime');
|
|
if (!el) return;
|
|
const now = new Date();
|
|
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' };
|
|
try {
|
|
const dateStr = new Intl.DateTimeFormat('fa-IR', options).format(now);
|
|
el.innerHTML = `<i class="fa-regular fa-clock ml-1"></i> ${dateStr}`;
|
|
} catch (e) {
|
|
el.innerHTML = `<i class="fa-regular fa-clock ml-1"></i> سامانه فعال است`;
|
|
}
|
|
}
|
|
|
|
// Switch Active Tab
|
|
function switchTab(tabId) {
|
|
currentTab = tabId;
|
|
|
|
// Hide all views
|
|
['feed', 'map', 'track', 'admin'].forEach(tab => {
|
|
const view = document.getElementById(`view-${tab}`);
|
|
const navBtn = document.getElementById(`nav-${tab}`);
|
|
if (view) view.classList.add('hidden');
|
|
if (navBtn) {
|
|
navBtn.classList.remove('bg-white', 'text-emerald-700', 'shadow-sm');
|
|
navBtn.classList.add('text-slate-600');
|
|
}
|
|
});
|
|
|
|
// Show selected view
|
|
const activeView = document.getElementById(`view-${tabId}`);
|
|
const activeNav = document.getElementById(`nav-${tabId}`);
|
|
if (activeView) activeView.classList.remove('hidden');
|
|
if (activeNav) {
|
|
activeNav.classList.add('bg-white', 'text-emerald-700', 'shadow-sm');
|
|
activeNav.classList.remove('text-slate-600');
|
|
}
|
|
|
|
// Tab specific actions
|
|
if (tabId === 'map') {
|
|
setTimeout(initLeafletMap, 100);
|
|
} else if (tabId === 'admin') {
|
|
loadStats();
|
|
loadAdminIssues();
|
|
}
|
|
}
|
|
|
|
// Fetch Stats from API
|
|
async function loadStats() {
|
|
try {
|
|
const res = await fetch('/api/stats');
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
statsData = data;
|
|
|
|
document.getElementById('stat-total').textContent = data.total.toLocaleString('fa-IR');
|
|
document.getElementById('stat-resolved').textContent = data.resolved.toLocaleString('fa-IR');
|
|
document.getElementById('stat-in-progress').textContent = (data.in_progress + data.reviewing).toLocaleString('fa-IR');
|
|
document.getElementById('stat-upvotes').textContent = data.total_upvotes.toLocaleString('fa-IR');
|
|
document.getElementById('stat-rate').textContent = `نرخ تکمیل: ${data.resolution_rate}٪`;
|
|
|
|
if (currentTab === 'admin') {
|
|
renderAdminCharts(data);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error loading stats:', err);
|
|
}
|
|
}
|
|
|
|
// Fetch Issues with Filters
|
|
async function fetchIssues() {
|
|
const loadingEl = document.getElementById('feedLoading');
|
|
const emptyEl = document.getElementById('feedEmpty');
|
|
const container = document.getElementById('issuesContainer');
|
|
|
|
loadingEl.classList.remove('hidden');
|
|
emptyEl.classList.add('hidden');
|
|
container.innerHTML = '';
|
|
|
|
const activePill = document.querySelector('.cat-pill.active');
|
|
const selectedCat = activePill ? activePill.getAttribute('data-cat') || 'all' : 'all';
|
|
const district = document.getElementById('filterDistrict').value;
|
|
const status = document.getElementById('filterStatus').value;
|
|
const priority = document.getElementById('filterPriority').value;
|
|
const sortBy = document.getElementById('sortBy').value;
|
|
|
|
let url = `/api/issues?category=${encodeURIComponent(selectedCat)}&status=${status}&district=${district}&priority=${priority}&sort_by=${sortBy}`;
|
|
|
|
try {
|
|
const res = await fetch(url);
|
|
const data = await res.json();
|
|
issuesData = data;
|
|
loadingEl.classList.add('hidden');
|
|
|
|
if (data.length === 0) {
|
|
emptyEl.classList.remove('hidden');
|
|
return;
|
|
}
|
|
|
|
renderIssueCards(data);
|
|
if (leafletMap) {
|
|
updateMapMarkers(data);
|
|
}
|
|
} catch (err) {
|
|
loadingEl.classList.add('hidden');
|
|
console.error('Error fetching issues:', err);
|
|
}
|
|
}
|
|
|
|
// Render Issue Cards
|
|
function renderIssueCards(issues) {
|
|
const container = document.getElementById('issuesContainer');
|
|
container.innerHTML = '';
|
|
|
|
issues.forEach(issue => {
|
|
const cat = CATEGORY_META[issue.category] || { icon: 'fa-circle-info', color: 'text-emerald-500', bg: 'bg-emerald-50', badge: 'bg-emerald-100 text-emerald-800' };
|
|
const st = STATUS_META[issue.status] || STATUS_META['pending'];
|
|
const prio = PRIORITY_META[issue.priority] || PRIORITY_META['medium'];
|
|
const imgUrl = issue.image_url || 'https://images.unsplash.com/photo-1515162816999-a0c47dc192f7?auto=format&fit=crop&w=800&q=80';
|
|
|
|
const card = document.createElement('div');
|
|
card.className = "bg-white rounded-3xl border border-slate-200 overflow-hidden shadow-sm hover:shadow-xl transition-all duration-300 flex flex-col group";
|
|
|
|
card.innerHTML = `
|
|
<!-- Card Image & Badges -->
|
|
<div class="relative h-48 overflow-hidden bg-slate-100">
|
|
<img src="${imgUrl}" alt="${issue.title}" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500">
|
|
<div class="absolute inset-0 bg-gradient-to-t from-slate-950/70 via-transparent to-black/30"></div>
|
|
|
|
<!-- Top Badges -->
|
|
<div class="absolute top-3 right-3 left-3 flex items-center justify-between">
|
|
<span class="px-2.5 py-1 rounded-full text-[11px] font-bold ${st.color} border shadow-sm flex items-center gap-1.5">
|
|
<span class="w-2 h-2 rounded-full ${st.dot}"></span>
|
|
${st.label}
|
|
</span>
|
|
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold ${prio.badge} shadow-sm">
|
|
${prio.label}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Bottom Overlay Info -->
|
|
<div class="absolute bottom-3 right-3 left-3 flex items-center justify-between text-white text-xs">
|
|
<span class="font-mono bg-black/40 backdrop-blur-md px-2 py-0.5 rounded-md font-bold text-emerald-300">
|
|
<i class="fa-solid fa-hashtag text-[10px]"></i> ${issue.tracking_code}
|
|
</span>
|
|
<span class="bg-black/40 backdrop-blur-md px-2 py-0.5 rounded-md text-[11px]">
|
|
منطقه ${issue.district} شهرداری
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Card Content -->
|
|
<div class="p-5 flex-1 flex flex-col justify-between space-y-4">
|
|
|
|
<div class="space-y-2">
|
|
<!-- Category & Date -->
|
|
<div class="flex items-center justify-between text-xs text-slate-400">
|
|
<span class="flex items-center gap-1.5 font-bold ${cat.color}">
|
|
<i class="fa-solid ${cat.icon}"></i>
|
|
${issue.category}
|
|
</span>
|
|
<span><i class="fa-regular fa-clock ml-1"></i>${issue.created_at}</span>
|
|
</div>
|
|
|
|
<!-- Title -->
|
|
<h3 onclick="openIssueDetail(${issue.id})" class="text-base font-bold text-slate-900 line-clamp-1 hover:text-emerald-600 cursor-pointer transition-colors">
|
|
${issue.title}
|
|
</h3>
|
|
|
|
<!-- Description -->
|
|
<p class="text-xs text-slate-600 line-clamp-2 leading-relaxed">
|
|
${issue.description}
|
|
</p>
|
|
|
|
<!-- Address -->
|
|
<div class="flex items-start gap-1.5 text-xs text-slate-500 pt-1">
|
|
<i class="fa-solid fa-location-dot text-rose-500 mt-0.5 text-xs"></i>
|
|
<span class="line-clamp-1">${issue.address}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Card Footer (Actions) -->
|
|
<div class="pt-3 border-t border-slate-100 flex items-center justify-between">
|
|
<!-- Upvote Button -->
|
|
<button onclick="handleUpvote(event, ${issue.id}, this)" class="upvote-btn flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-slate-200 hover:border-emerald-500 hover:bg-emerald-50 text-slate-700 hover:text-emerald-700 text-xs font-bold transition-all">
|
|
<i class="fa-regular fa-thumbs-up"></i>
|
|
<span class="count">${issue.upvotes}</span>
|
|
<span class="text-[10px] text-slate-400">تایید شهروندی</span>
|
|
</button>
|
|
|
|
<!-- Detail Button -->
|
|
<button onclick="openIssueDetail(${issue.id})" class="bg-slate-100 hover:bg-emerald-600 hover:text-white text-slate-700 px-3.5 py-1.5 rounded-xl text-xs font-bold transition-all flex items-center gap-1">
|
|
<span>مشاهده و پیگیری</span>
|
|
<i class="fa-solid fa-chevron-left text-[10px]"></i>
|
|
</button>
|
|
</div>
|
|
|
|
</div>
|
|
`;
|
|
|
|
container.appendChild(card);
|
|
});
|
|
}
|
|
|
|
// Category filter button click
|
|
function filterByCategory(catName) {
|
|
document.querySelectorAll('.cat-pill').forEach(pill => {
|
|
pill.classList.remove('active', 'bg-slate-900', 'text-white');
|
|
pill.classList.add('bg-slate-100', 'text-slate-600');
|
|
});
|
|
|
|
const target = event ? event.currentTarget : document.querySelector('.cat-pill');
|
|
if (target) {
|
|
target.classList.add('active', 'bg-slate-900', 'text-white');
|
|
target.classList.remove('bg-slate-100', 'text-slate-600');
|
|
target.setAttribute('data-cat', catName);
|
|
}
|
|
|
|
fetchIssues();
|
|
}
|
|
|
|
function applyFilters() {
|
|
fetchIssues();
|
|
}
|
|
|
|
// Upvote Handler
|
|
async function handleUpvote(e, issueId, btn) {
|
|
e.stopPropagation();
|
|
try {
|
|
const res = await fetch(`/api/issues/${issueId}/upvote`, { method: 'POST' });
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
const countEl = btn.querySelector('.count');
|
|
if (countEl) countEl.textContent = data.upvotes;
|
|
btn.classList.add('bg-emerald-100', 'text-emerald-800', 'border-emerald-400');
|
|
btn.querySelector('i').classList.replace('fa-regular', 'fa-solid');
|
|
|
|
Swal.fire({
|
|
toast: true,
|
|
position: 'top-end',
|
|
icon: 'success',
|
|
title: 'تایید شما با موفقیت ثبت شد',
|
|
showConfirmButton: false,
|
|
timer: 1800
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error('Error upvoting:', err);
|
|
}
|
|
}
|
|
|
|
// Quick Search / Track from Hero
|
|
function handleQuickSearch() {
|
|
const val = document.getElementById('quickTrackInput').value.trim();
|
|
if (!val) return;
|
|
|
|
if (val.toUpperCase().startsWith('SHR-')) {
|
|
// Switch to track tab and search
|
|
switchTab('track');
|
|
document.getElementById('trackCodeInput').value = val.toUpperCase();
|
|
fetchTrackingDetail();
|
|
} else {
|
|
// Search keyword in feed
|
|
switchTab('feed');
|
|
searchIssues(val);
|
|
}
|
|
}
|
|
|
|
async function searchIssues(keyword) {
|
|
const loadingEl = document.getElementById('feedLoading');
|
|
const container = document.getElementById('issuesContainer');
|
|
loadingEl.classList.remove('hidden');
|
|
container.innerHTML = '';
|
|
|
|
try {
|
|
const res = await fetch(`/api/issues?search=${encodeURIComponent(keyword)}`);
|
|
const data = await res.json();
|
|
loadingEl.classList.add('hidden');
|
|
renderIssueCards(data);
|
|
} catch (err) {
|
|
loadingEl.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
// Leaflet Map Initialization
|
|
function initLeafletMap() {
|
|
if (leafletMap) {
|
|
leafletMap.invalidateSize();
|
|
return;
|
|
}
|
|
|
|
// Tehran Center Coordinates
|
|
leafletMap = L.map('leafletMap').setView([35.7538, 51.4172], 12);
|
|
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 19,
|
|
attribution: '© OpenStreetMap شهرنگار'
|
|
}).addTo(leafletMap);
|
|
|
|
if (issuesData.length > 0) {
|
|
updateMapMarkers(issuesData);
|
|
}
|
|
}
|
|
|
|
function updateMapMarkers(issues) {
|
|
if (!leafletMap) return;
|
|
|
|
// Clear existing
|
|
mapMarkers.forEach(m => leafletMap.removeLayer(m));
|
|
mapMarkers = [];
|
|
|
|
issues.forEach(issue => {
|
|
const isResolved = issue.status === 'resolved';
|
|
const isEmergency = issue.priority === 'emergency';
|
|
const pinColor = isResolved ? '#10b981' : (isEmergency ? '#ef4444' : '#f59e0b');
|
|
|
|
const customIcon = L.divIcon({
|
|
className: 'custom-map-pin',
|
|
html: `
|
|
<div style="background-color: ${pinColor}; width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; border: 3px solid white; box-shadow: 0 4px 10px rgba(0,0,0,0.3); font-size: 13px;">
|
|
<i class="fa-solid ${CATEGORY_META[issue.category]?.icon || 'fa-map-pin'}"></i>
|
|
</div>
|
|
`,
|
|
iconSize: [32, 32],
|
|
iconAnchor: [16, 32],
|
|
popupAnchor: [0, -32]
|
|
});
|
|
|
|
const marker = L.marker([issue.lat, issue.lng], { icon: customIcon }).addTo(leafletMap);
|
|
|
|
const popupContent = `
|
|
<div class="p-2 space-y-2 min-w-[200px]">
|
|
<span class="text-[10px] font-bold px-2 py-0.5 rounded-full ${STATUS_META[issue.status]?.color || 'bg-slate-100'}">
|
|
${STATUS_META[issue.status]?.label || ''}
|
|
</span>
|
|
<h4 class="font-bold text-sm text-slate-900 mt-1">${issue.title}</h4>
|
|
<p class="text-xs text-slate-500 line-clamp-2">${issue.address}</p>
|
|
<button onclick="openIssueDetail(${issue.id})" class="w-full mt-2 bg-emerald-600 text-white py-1.5 rounded-lg text-xs font-bold hover:bg-emerald-700">
|
|
مشاهده پرونده کامل
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
marker.bindPopup(popupContent);
|
|
mapMarkers.push(marker);
|
|
});
|
|
}
|
|
|
|
// Issue Detail Modal
|
|
async function openIssueDetail(issueId) {
|
|
try {
|
|
const res = await fetch(`/api/issues/${issueId}`);
|
|
if (!res.ok) return;
|
|
const issue = await res.json();
|
|
|
|
const st = STATUS_META[issue.status] || STATUS_META['pending'];
|
|
const prio = PRIORITY_META[issue.priority] || PRIORITY_META['medium'];
|
|
const cat = CATEGORY_META[issue.category] || { icon: 'fa-city', color: 'text-emerald-600', badge: 'bg-emerald-100 text-emerald-800' };
|
|
|
|
const modal = document.getElementById('detailModal');
|
|
const content = document.getElementById('detailModalContent');
|
|
|
|
// Build Stepper Stages
|
|
const stages = [
|
|
{ key: 'pending', label: 'ثبت در سامانه', icon: 'fa-file-lines' },
|
|
{ key: 'reviewing', label: 'بررسی کارشناسی', icon: 'fa-clipboard-check' },
|
|
{ key: 'in_progress', label: 'اعزام اکیپ و اجرا', icon: 'fa-screwdriver-wrench' },
|
|
{ key: 'resolved', label: 'رفع کامل مشکل', icon: 'fa-circle-check' }
|
|
];
|
|
|
|
const currentStep = st.step || 1;
|
|
|
|
let stepperHtml = `
|
|
<div class="grid grid-cols-4 gap-2 text-center my-6 relative">
|
|
${stages.map((stage, idx) => {
|
|
const stepNum = idx + 1;
|
|
const isDone = currentStep >= stepNum;
|
|
const isCurrent = currentStep === stepNum;
|
|
return `
|
|
<div class="flex flex-col items-center gap-1.5 z-10">
|
|
<div class="w-10 h-10 rounded-2xl flex items-center justify-center text-sm font-bold transition-all ${isDone ? 'bg-emerald-600 text-white shadow-lg shadow-emerald-600/30' : 'bg-slate-200 text-slate-400'} ${isCurrent ? 'ring-4 ring-emerald-100 animate-bounce' : ''}">
|
|
<i class="fa-solid ${stage.icon}"></i>
|
|
</div>
|
|
<span class="text-[11px] font-bold ${isDone ? 'text-emerald-700' : 'text-slate-400'}">${stage.label}</span>
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
`;
|
|
|
|
// Build Timeline items
|
|
let timelineHtml = (issue.timeline || []).map(tl => `
|
|
<div class="relative pr-6 pb-5 border-r-2 border-emerald-500 last:border-transparent">
|
|
<span class="absolute -right-2 top-0 w-4 h-4 rounded-full bg-emerald-600 border-2 border-white"></span>
|
|
<div class="text-xs font-bold text-slate-800">${tl.title}</div>
|
|
<div class="text-[11px] text-slate-400 mb-1"><i class="fa-regular fa-clock ml-1"></i>${tl.created_at}</div>
|
|
<p class="text-xs text-slate-600 leading-relaxed bg-slate-50 p-2.5 rounded-xl border border-slate-200/60">${tl.description}</p>
|
|
</div>
|
|
`).join('');
|
|
|
|
// Build Comments
|
|
let commentsHtml = (issue.comments || []).map(c => `
|
|
<div class="bg-slate-50 p-3 rounded-2xl border border-slate-200 space-y-1">
|
|
<div class="flex items-center justify-between text-xs">
|
|
<span class="font-bold text-slate-800 flex items-center gap-1.5">
|
|
<i class="fa-solid fa-user text-slate-400 text-[10px]"></i>
|
|
${c.author_name}
|
|
</span>
|
|
<span class="text-[10px] text-slate-400">${c.created_at}</span>
|
|
</div>
|
|
<p class="text-xs text-slate-600">${c.content}</p>
|
|
</div>
|
|
`).join('');
|
|
|
|
content.innerHTML = `
|
|
<!-- Modal Header -->
|
|
<div class="bg-gradient-to-r from-slate-900 to-slate-800 text-white p-6 sticky top-0 z-20 flex items-center justify-between">
|
|
<div class="flex items-center gap-3">
|
|
<span class="font-mono bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 px-3 py-1 rounded-xl text-sm font-bold">
|
|
${issue.tracking_code}
|
|
</span>
|
|
<div>
|
|
<h3 class="text-base sm:text-lg font-bold">${issue.title}</h3>
|
|
<p class="text-xs text-slate-400">منطقه ${issue.district} شهرداری • تاریخ ثبت: ${issue.created_at}</p>
|
|
</div>
|
|
</div>
|
|
<button onclick="closeDetailModal()" class="w-8 h-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center">
|
|
<i class="fa-solid fa-xmark"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="p-6 space-y-6">
|
|
|
|
<!-- Status Stepper Bar -->
|
|
${stepperHtml}
|
|
|
|
<!-- Before / After Images (if resolved) -->
|
|
<div class="grid grid-cols-1 ${issue.resolved_image_url ? 'sm:grid-cols-2' : ''} gap-4">
|
|
<div>
|
|
<span class="text-xs font-bold text-slate-500 block mb-1.5">تصویر گزارششده توسط شهروند:</span>
|
|
<div class="h-56 rounded-2xl overflow-hidden bg-slate-100 border border-slate-200">
|
|
<img src="${issue.image_url || 'https://images.unsplash.com/photo-1515162816999-a0c47dc192f7?auto=format&fit=crop&w=800&q=80'}" class="w-full h-full object-cover">
|
|
</div>
|
|
</div>
|
|
|
|
${issue.resolved_image_url ? `
|
|
<div>
|
|
<span class="text-xs font-bold text-emerald-600 block mb-1.5 flex items-center gap-1">
|
|
<i class="fa-solid fa-circle-check"></i>
|
|
تصویر پس از انجام عملیات و رفع نقص:
|
|
</span>
|
|
<div class="h-56 rounded-2xl overflow-hidden bg-slate-100 border-2 border-emerald-400">
|
|
<img src="${issue.resolved_image_url}" class="w-full h-full object-cover">
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
|
|
<!-- Issue Information Details -->
|
|
<div class="bg-slate-50 p-5 rounded-2xl border border-slate-200 space-y-3">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<span class="px-2.5 py-1 rounded-lg text-xs font-bold ${cat.badge}"><i class="fa-solid ${cat.icon} ml-1"></i>${issue.category}</span>
|
|
<span class="px-2.5 py-1 rounded-lg text-xs font-bold ${prio.badge}">${prio.label}</span>
|
|
<span class="px-2.5 py-1 rounded-lg text-xs font-bold ${st.color} border">${st.label}</span>
|
|
</div>
|
|
|
|
<div>
|
|
<h4 class="text-xs font-bold text-slate-700 mb-1">شرح دقیق مشکل:</h4>
|
|
<p class="text-xs sm:text-sm text-slate-700 leading-relaxed">${issue.description}</p>
|
|
</div>
|
|
|
|
<div class="pt-2 border-t border-slate-200/80 flex items-start gap-2 text-xs text-slate-600">
|
|
<i class="fa-solid fa-location-dot text-rose-500 mt-0.5"></i>
|
|
<span><strong>نشانی محل:</strong> ${issue.address}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Official Municipal Response -->
|
|
${issue.official_response ? `
|
|
<div class="bg-emerald-50 border border-emerald-200 p-5 rounded-2xl space-y-2">
|
|
<div class="flex items-center gap-2 text-emerald-800 text-xs font-bold">
|
|
<i class="fa-solid fa-building-columns text-base"></i>
|
|
<span>پاسخ رسمی شهرداری منطقه ${issue.district}:</span>
|
|
</div>
|
|
<p class="text-xs sm:text-sm text-emerald-900 leading-relaxed font-medium">
|
|
${issue.official_response}
|
|
</p>
|
|
</div>
|
|
` : ''}
|
|
|
|
<!-- Timeline Log -->
|
|
<div class="space-y-3">
|
|
<h4 class="text-sm font-bold text-slate-900 flex items-center gap-2">
|
|
<i class="fa-solid fa-list-timeline text-emerald-600"></i>
|
|
<span>تاریخچه و روند رسیدگی پرونده</span>
|
|
</h4>
|
|
<div class="pr-2 pt-2">
|
|
${timelineHtml}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Citizen Comments & Support -->
|
|
<div class="space-y-4 pt-4 border-t border-slate-200">
|
|
<div class="flex items-center justify-between">
|
|
<h4 class="text-sm font-bold text-slate-900 flex items-center gap-2">
|
|
<i class="fa-regular fa-comments text-emerald-600"></i>
|
|
<span>نظرات و مشاهدات شهروندان</span>
|
|
</h4>
|
|
<span class="text-xs text-slate-500">${(issue.comments || []).length} نظر ثبت شده</span>
|
|
</div>
|
|
|
|
<!-- Comments List -->
|
|
<div class="space-y-2 max-h-48 overflow-y-auto custom-scrollbar">
|
|
${commentsHtml.length > 0 ? commentsHtml : '<p class="text-xs text-slate-400 text-center py-4">هنوز نظری ثبت نشده است. شما اولین نفر باشید!</p>'}
|
|
</div>
|
|
|
|
<!-- Add Comment Form -->
|
|
<form onsubmit="submitComment(event, ${issue.id})" class="flex gap-2 pt-2">
|
|
<input type="text" id="commentAuthor" placeholder="نام شما" class="w-1/3 px-3 py-2 rounded-xl border border-slate-300 text-xs outline-none focus:border-emerald-500">
|
|
<input type="text" id="commentText" required placeholder="نظر یا مشاهده تکمیلی خود را بنویسید..." class="flex-1 px-3 py-2 rounded-xl border border-slate-300 text-xs outline-none focus:border-emerald-500">
|
|
<button type="submit" class="bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 rounded-xl text-xs font-bold transition-all">
|
|
ارسال
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
</div>
|
|
`;
|
|
|
|
modal.classList.remove('hidden');
|
|
} catch (err) {
|
|
console.error('Error viewing issue detail:', err);
|
|
}
|
|
}
|
|
|
|
function closeDetailModal() {
|
|
document.getElementById('detailModal').classList.add('hidden');
|
|
}
|
|
|
|
// Add Comment Handler
|
|
async function submitComment(e, issueId) {
|
|
e.preventDefault();
|
|
const author = document.getElementById('commentAuthor').value.trim() || 'شهروند';
|
|
const content = document.getElementById('commentText').value.trim();
|
|
if (!content) return;
|
|
|
|
try {
|
|
const res = await fetch(`/api/issues/${issueId}/comment`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ author_name: author, content: content })
|
|
});
|
|
|
|
if (res.ok) {
|
|
document.getElementById('commentText').value = '';
|
|
openIssueDetail(issueId); // Refresh modal
|
|
}
|
|
} catch (err) {
|
|
console.error('Error submitting comment:', err);
|
|
}
|
|
}
|
|
|
|
// Tracking Portal Search
|
|
async function fetchTrackingDetail() {
|
|
const code = document.getElementById('trackCodeInput').value.trim().toUpperCase();
|
|
if (!code) {
|
|
Swal.fire({ icon: 'warning', title: 'لطفاً کد رهگیری را وارد نمایید', confirmButtonText: 'متوجه شدم' });
|
|
return;
|
|
}
|
|
|
|
const container = document.getElementById('trackResultContainer');
|
|
container.classList.remove('hidden');
|
|
container.innerHTML = `
|
|
<div class="py-12 text-center">
|
|
<div class="inline-block animate-spin text-3xl text-emerald-600 mb-2"><i class="fa-solid fa-circle-notch"></i></div>
|
|
<p class="text-xs text-slate-500">در حال استعلام از سامانه نظارت شهری...</p>
|
|
</div>
|
|
`;
|
|
|
|
try {
|
|
const res = await fetch(`/api/track/${encodeURIComponent(code)}`);
|
|
if (!res.ok) {
|
|
container.innerHTML = `
|
|
<div class="py-8 text-center text-rose-600 space-y-2">
|
|
<i class="fa-solid fa-triangle-exclamation text-3xl"></i>
|
|
<h4 class="font-bold text-sm">هیچ گزارشی با کد رهگیری «${code}» یافت نشد!</h4>
|
|
<p class="text-xs text-slate-500">لطفاً از صحت کد وارد شده اطمینان حاصل فرمایید.</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const issue = await res.json();
|
|
const st = STATUS_META[issue.status] || STATUS_META['pending'];
|
|
const cat = CATEGORY_META[issue.category] || { icon: 'fa-city', color: 'text-emerald-600', badge: 'bg-emerald-100 text-emerald-800' };
|
|
|
|
container.innerHTML = `
|
|
<div class="flex flex-wrap items-center justify-between gap-4 pb-4 border-b border-slate-100">
|
|
<div>
|
|
<span class="text-xs font-bold text-slate-400 block mb-1">نتیجه استعلام پرونده</span>
|
|
<h3 class="text-xl font-black text-slate-900">${issue.title}</h3>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="font-mono text-sm font-bold bg-slate-900 text-emerald-400 px-3 py-1.5 rounded-xl">
|
|
${issue.tracking_code}
|
|
</span>
|
|
<span class="text-xs font-bold px-3 py-1.5 rounded-xl ${st.color} border">
|
|
${st.label}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
|
|
<div class="bg-slate-50 p-4 rounded-2xl border border-slate-200">
|
|
<span class="text-slate-400 block mb-1">دستهبندی موضوع:</span>
|
|
<span class="font-bold text-slate-800 flex items-center gap-1.5"><i class="fa-solid ${cat.icon} ${cat.color}"></i> ${issue.category}</span>
|
|
</div>
|
|
<div class="bg-slate-50 p-4 rounded-2xl border border-slate-200">
|
|
<span class="text-slate-400 block mb-1">منطقه شهرداری:</span>
|
|
<span class="font-bold text-slate-800">منطقه ${issue.district}</span>
|
|
</div>
|
|
<div class="bg-slate-50 p-4 rounded-2xl border border-slate-200">
|
|
<span class="text-slate-400 block mb-1">تاریخ و ساعت ثبت:</span>
|
|
<span class="font-bold text-slate-800">${issue.created_at}</span>
|
|
</div>
|
|
</div>
|
|
|
|
${issue.official_response ? `
|
|
<div class="bg-emerald-50 border-2 border-emerald-300 p-5 rounded-2xl space-y-1.5">
|
|
<h4 class="text-xs font-bold text-emerald-800 flex items-center gap-1.5">
|
|
<i class="fa-solid fa-building-flag"></i> پاسخ رسمی بازرس و اکیپ شهرداری:
|
|
</h4>
|
|
<p class="text-xs sm:text-sm text-emerald-950 leading-relaxed font-medium">${issue.official_response}</p>
|
|
</div>
|
|
` : ''}
|
|
|
|
<div class="space-y-4">
|
|
<h4 class="text-sm font-bold text-slate-900">مراحل و اقدامات انجامشده:</h4>
|
|
<div class="space-y-3">
|
|
${(issue.timeline || []).map(tl => `
|
|
<div class="flex items-start gap-3 bg-slate-50 p-3.5 rounded-2xl border border-slate-200">
|
|
<div class="w-8 h-8 rounded-xl bg-emerald-600 text-white flex items-center justify-center text-xs shrink-0 mt-0.5">
|
|
<i class="fa-solid fa-check"></i>
|
|
</div>
|
|
<div class="space-y-1">
|
|
<div class="flex items-center gap-2">
|
|
<span class="font-bold text-xs text-slate-900">${tl.title}</span>
|
|
<span class="text-[10px] text-slate-400">${tl.created_at}</span>
|
|
</div>
|
|
<p class="text-xs text-slate-600">${tl.description}</p>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
} catch (err) {
|
|
container.innerHTML = `<p class="text-xs text-rose-500">خطا در برقراری ارتباط با سرور.</p>`;
|
|
}
|
|
}
|
|
|
|
// Submit New Report Modal & Map Picker
|
|
function openNewReportModal() {
|
|
document.getElementById('newReportModal').classList.remove('hidden');
|
|
setTimeout(initPickerMap, 200);
|
|
}
|
|
|
|
function closeNewReportModal() {
|
|
document.getElementById('newReportModal').classList.add('hidden');
|
|
}
|
|
|
|
function setupCategoryRadios() {
|
|
const radios = document.querySelectorAll('.category-radio input[type="radio"]');
|
|
radios.forEach(radio => {
|
|
radio.addEventListener('change', () => {
|
|
document.querySelectorAll('.category-radio').forEach(r => {
|
|
r.classList.remove('border-emerald-500', 'bg-emerald-50/50');
|
|
});
|
|
if (radio.checked) {
|
|
radio.closest('.category-radio').classList.add('border-emerald-500', 'bg-emerald-50/50');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function selectSampleImage(el, url) {
|
|
document.querySelectorAll('.sample-img-card').forEach(c => c.classList.remove('border-emerald-500', 'ring-2', 'ring-emerald-200'));
|
|
el.classList.add('border-emerald-500', 'ring-2', 'ring-emerald-200');
|
|
document.getElementById('reportImageUrl').value = url;
|
|
}
|
|
|
|
function initPickerMap() {
|
|
if (pickerMap) {
|
|
pickerMap.invalidateSize();
|
|
return;
|
|
}
|
|
|
|
pickerMap = L.map('pickerMap').setView([35.7538, 51.4172], 13);
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 19
|
|
}).addTo(pickerMap);
|
|
|
|
pickerMarker = L.marker([35.7538, 51.4172], { draggable: true }).addTo(pickerMap);
|
|
|
|
pickerMarker.on('dragend', function (e) {
|
|
const pos = e.target.getLatLng();
|
|
document.getElementById('reportLat').value = pos.lat.toFixed(6);
|
|
document.getElementById('reportLng').value = pos.lng.toFixed(6);
|
|
});
|
|
|
|
pickerMap.on('click', function (e) {
|
|
pickerMarker.setLatLng(e.latlng);
|
|
document.getElementById('reportLat').value = e.latlng.lat.toFixed(6);
|
|
document.getElementById('reportLng').value = e.latlng.lng.toFixed(6);
|
|
});
|
|
}
|
|
|
|
// Submit Form Handler
|
|
async function submitNewReport(e) {
|
|
e.preventDefault();
|
|
const btn = document.getElementById('submitReportBtn');
|
|
btn.disabled = true;
|
|
btn.innerHTML = `<i class="fa-solid fa-circle-notch animate-spin"></i> در حال ثبت...`;
|
|
|
|
const category = document.querySelector('input[name="category"]:checked')?.value || 'آسفالت و معابر';
|
|
const title = document.getElementById('reportTitle').value.trim();
|
|
const district = parseInt(document.getElementById('reportDistrict').value);
|
|
const priority = document.getElementById('reportPriority').value;
|
|
const address = document.getElementById('reportAddress').value.trim();
|
|
const description = document.getElementById('reportDescription').value.trim();
|
|
const imageUrl = document.getElementById('reportImageUrl').value.trim() || 'https://images.unsplash.com/photo-1515162816999-a0c47dc192f7?auto=format&fit=crop&w=800&q=80';
|
|
const reporterName = document.getElementById('reporterName').value.trim() || 'شهروند مسئول';
|
|
const reporterPhone = document.getElementById('reporterPhone').value.trim();
|
|
const lat = parseFloat(document.getElementById('reportLat').value) || 35.7538;
|
|
const lng = parseFloat(document.getElementById('reportLng').value) || 51.4172;
|
|
|
|
const payload = {
|
|
title, category, district, priority, address, description,
|
|
image_url: imageUrl, reporter_name: reporterName, reporter_phone: reporterPhone,
|
|
lat, lng
|
|
};
|
|
|
|
try {
|
|
const res = await fetch('/api/issues', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (res.ok) {
|
|
const created = await res.json();
|
|
closeNewReportModal();
|
|
document.getElementById('newReportForm').reset();
|
|
|
|
// Success Alert with Tracking Card
|
|
Swal.fire({
|
|
icon: 'success',
|
|
title: 'گزارش شما با موفقیت ثبت شد!',
|
|
html: `
|
|
<div class="p-4 bg-slate-50 rounded-2xl border border-slate-200 text-center space-y-2 mt-3">
|
|
<span class="text-xs text-slate-500">کد رهگیری اختصاصی شما:</span>
|
|
<div class="font-mono text-xl font-black text-emerald-600 bg-white py-2 px-4 rounded-xl border-2 border-dashed border-emerald-400">${created.tracking_code}</div>
|
|
<p class="text-xs text-slate-500 leading-relaxed">این کد را جهت استعلامهای بعدی و پیگیری مراحل یادداشت نمایید.</p>
|
|
</div>
|
|
`,
|
|
confirmButtonText: 'مشاهده گزارش در صفحه اصلی',
|
|
confirmButtonColor: '#059669'
|
|
});
|
|
|
|
loadStats();
|
|
fetchIssues();
|
|
}
|
|
} catch (err) {
|
|
Swal.fire({ icon: 'error', title: 'خطا در ثبت گزارش', text: 'لطفاً دوباره تلاش کنید.' });
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = `<i class="fa-solid fa-paper-plane"></i> <span>ارسال و ثبت نهایی گزارش</span>`;
|
|
}
|
|
}
|
|
|
|
// ADMIN DASHBOARD SECTION
|
|
async function loadAdminIssues() {
|
|
const tbody = document.getElementById('adminTableBody');
|
|
const countEl = document.getElementById('adminTableCount');
|
|
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-6 text-slate-400">در حال بارگذاری اطلاعات پروندهها...</td></tr>`;
|
|
|
|
try {
|
|
const res = await fetch('/api/issues?sort_by=newest');
|
|
const data = await res.json();
|
|
countEl.textContent = `${data.length} پرونده ثبت شده`;
|
|
|
|
tbody.innerHTML = '';
|
|
data.forEach(issue => {
|
|
const st = STATUS_META[issue.status] || STATUS_META['pending'];
|
|
const prio = PRIORITY_META[issue.priority] || PRIORITY_META['medium'];
|
|
|
|
const tr = document.createElement('tr');
|
|
tr.className = "hover:bg-slate-50 transition-colors";
|
|
tr.innerHTML = `
|
|
<td class="py-3 px-4 font-mono font-bold text-emerald-700">${issue.tracking_code}</td>
|
|
<td class="py-3 px-4">
|
|
<div class="font-bold text-slate-900">${issue.title}</div>
|
|
<div class="text-[11px] text-slate-400 truncate max-w-xs">${issue.address}</div>
|
|
</td>
|
|
<td class="py-3 px-4 font-semibold">${issue.category}</td>
|
|
<td class="py-3 px-4">منطقه ${issue.district}</td>
|
|
<td class="py-3 px-4"><span class="px-2 py-0.5 rounded-full text-[10px] font-bold ${prio.badge}">${prio.label}</span></td>
|
|
<td class="py-3 px-4"><span class="px-2 py-0.5 rounded-full text-[10px] font-bold ${st.color} border">${st.label}</span></td>
|
|
<td class="py-3 px-4 text-slate-400">${issue.created_at}</td>
|
|
<td class="py-3 px-4 text-center">
|
|
<button onclick="openAdminActionModal(${issue.id}, '${issue.status}')" class="bg-slate-900 hover:bg-emerald-600 text-white px-3 py-1.5 rounded-lg text-xs font-bold transition-colors">
|
|
دستور کار / تغییر وضعیت
|
|
</button>
|
|
</td>
|
|
`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
} catch (err) {
|
|
console.error('Error loading admin issues:', err);
|
|
}
|
|
}
|
|
|
|
function openAdminActionModal(issueId, currentStatus) {
|
|
document.getElementById('adminTargetIssueId').value = issueId;
|
|
document.getElementById('adminNewStatus').value = currentStatus;
|
|
document.getElementById('adminActionModal').classList.remove('hidden');
|
|
}
|
|
|
|
function closeAdminActionModal() {
|
|
document.getElementById('adminActionModal').classList.add('hidden');
|
|
}
|
|
|
|
async function submitAdminAction(e) {
|
|
e.preventDefault();
|
|
const issueId = document.getElementById('adminTargetIssueId').value;
|
|
const status = document.getElementById('adminNewStatus').value;
|
|
const timelineTitle = document.getElementById('adminTimelineTitle').value.trim();
|
|
const officialResponse = document.getElementById('adminOfficialResponse').value.trim();
|
|
const resolvedImageUrl = document.getElementById('adminResolvedImage').value.trim();
|
|
|
|
try {
|
|
const res = await fetch(`/api/issues/${issueId}/status`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
status,
|
|
timeline_title: timelineTitle || undefined,
|
|
official_response: officialResponse || undefined,
|
|
resolved_image_url: resolvedImageUrl || undefined
|
|
})
|
|
});
|
|
|
|
if (res.ok) {
|
|
closeAdminActionModal();
|
|
document.getElementById('adminActionForm').reset();
|
|
Swal.fire({ icon: 'success', title: 'وضعیت پرونده با موفقیت بهروزرسانی شد', timer: 1500 });
|
|
loadAdminIssues();
|
|
loadStats();
|
|
}
|
|
} catch (err) {
|
|
console.error('Error submitting admin action:', err);
|
|
}
|
|
}
|
|
|
|
// Render Chart.js
|
|
function renderAdminCharts(stats) {
|
|
// 1. Category Bar Chart
|
|
const catCtx = document.getElementById('categoryChart')?.getContext('2d');
|
|
if (catCtx) {
|
|
if (categoryChartInstance) categoryChartInstance.destroy();
|
|
categoryChartInstance = new Chart(catCtx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: stats.categories.map(c => c.category),
|
|
datasets: [{
|
|
label: 'تعداد گزارشها',
|
|
data: stats.categories.map(c => c.count),
|
|
backgroundColor: '#059669',
|
|
borderRadius: 8
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
y: { beginAtZero: true, ticks: { precision: 0 } }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 2. Status Doughnut Chart
|
|
const statusCtx = document.getElementById('statusChart')?.getContext('2d');
|
|
if (statusCtx) {
|
|
if (statusChartInstance) statusChartInstance.destroy();
|
|
statusChartInstance = new Chart(statusCtx, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: ['در انتظار بررسی', 'بررسی کارشناسی', 'در حال اجرا', 'حل شده'],
|
|
datasets: [{
|
|
data: [stats.pending, stats.reviewing, stats.in_progress, stats.resolved],
|
|
backgroundColor: ['#94a3b8', '#3b82f6', '#f59e0b', '#10b981']
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { position: 'bottom' }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|