diff --git a/database.py b/database.py new file mode 100644 index 0000000..394e6df --- /dev/null +++ b/database.py @@ -0,0 +1,280 @@ +import sqlite3 +import os +import random +from datetime import datetime, timedelta + +DB_PATH = os.path.join(os.path.dirname(__file__), "urban_issues.db") + +def get_db_connection(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + +def init_db(): + conn = get_db_connection() + cursor = conn.cursor() + + # Issues Table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS issues ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tracking_code TEXT UNIQUE NOT NULL, + title TEXT NOT NULL, + category TEXT NOT NULL, + description TEXT NOT NULL, + address TEXT NOT NULL, + district INTEGER NOT NULL, + priority TEXT NOT NULL, -- 'low', 'medium', 'high', 'emergency' + status TEXT NOT NULL, -- 'pending', 'reviewing', 'in_progress', 'resolved', 'rejected' + lat REAL NOT NULL, + lng REAL NOT NULL, + image_url TEXT, + resolved_image_url TEXT, + reporter_name TEXT, + reporter_phone TEXT, + official_response TEXT, + upvotes INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + resolved_at TEXT + ) + """) + + # Comments Table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + issue_id INTEGER NOT NULL, + author_name TEXT NOT NULL, + content TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE + ) + """) + + # Timeline Table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS timeline ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + issue_id INTEGER NOT NULL, + status TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE + ) + """) + + # Check if empty, then seed initial realistic data + cursor.execute("SELECT COUNT(*) FROM issues") + count = cursor.fetchone()[0] + + if count == 0: + seed_data(cursor) + + conn.commit() + conn.close() + +def seed_data(cursor): + now = datetime.now() + + sample_issues = [ + { + "tracking_code": "SHR-78214", + "title": "فرونشست و چاله عمیق در لاین کندرو", + "category": "آسفالت و معابر", + "description": "وجود چاله عمیق با قطر حدود ۱ متر پس از بارندگی اخیر که باعث خسارت به لاستیک خودروها و ترمزهای ناگهانی خطرناک می‌شود.", + "address": "بزرگراه شهید همت، نرسیده به خروجی گاندی، لاین کندرو", + "district": 3, + "priority": "emergency", + "status": "in_progress", + "lat": 35.7538, + "lng": 51.4172, + "image_url": "https://images.unsplash.com/photo-1515162816999-a0c47dc192f7?auto=format&fit=crop&w=800&q=80", + "resolved_image_url": None, + "reporter_name": "علی رضایی", + "reporter_phone": "09121112233", + "official_response": "اکیپ آسفالت‌ریزی ناحیه ۲ شهرداری منطقه ۳ اعزام گردیده و عملیات زیرسازی و لکه‌گیری در حال انجام است.", + "upvotes": 42, + "days_ago": 2, + "resolved_at": None, + "timeline": [ + ("pending", "ثبت گزارش توسط شهروند", "گزارش در سامانه ۱۳۷ با موفقیت ثبت شد."), + ("reviewing", "تایید کارشناس و ارجاع به معاونت فنی و عمران", "موضوع به ناحیه ۲ ارجاع داده شد."), + ("in_progress", "اعزام اکیپ عملیاتی لکه‌گیری", "تیم فنی با تجهیزات در محل مستقر شد.") + ], + "comments": [ + ("سارا محمدی", "دیروز لاستیک ماشین من هم اینجا آسیب دید، ممنون که پیگیری می‌کنید."), + ("حسین کاظمی", "امیدوارم امشب تا قبل از ترافیک صبحگاهی تموم بشه.") + ] + }, + { + "tracking_code": "SHR-65109", + "title": "خاموشی کامل پایه چراغ‌های روشنایی بوستان", + "category": "روشنایی و برق", + "description": "کل مسیر پیاده‌روی شرقی بوستان ملت در تاریکی مطلق است که باعث کاهش امنیت خانواده‌ها و ورزشکاران شبانه شده است.", + "address": "خیابان ولیعصر، بوستان ملت، ضلع شرقی جنب دریاچه", + "district": 3, + "priority": "high", + "status": "resolved", + "lat": 35.7801, + "lng": 51.4116, + "image_url": "https://images.unsplash.com/photo-1509114397022-ed747cca3f65?auto=format&fit=crop&w=800&q=80", + "resolved_image_url": "https://images.unsplash.com/photo-1517457373958-b7bdd4587205?auto=format&fit=crop&w=800&q=80", + "reporter_name": "مریم احمدی", + "reporter_phone": "09123334455", + "official_response": "کابل‌کشی زیرزمینی و تعویض پروژکتورهای معیوب توسط اداره زیباسازی و تاسیسات منطقه انجام و روشنایی کامل برقرار گردید.", + "upvotes": 68, + "days_ago": 5, + "resolved_at": (now - timedelta(days=1)).strftime("%Y-%m-%d %H:%M"), + "timeline": [ + ("pending", "ثبت گزارش توسط شهروند", "گزارش دریافت شد."), + ("reviewing", "بررسی میدانی اداره تاسیسات", "نقص کابل اصلی تایید شد."), + ("in_progress", "تعمیرات و تعویض کابل", "تیم تاسیسات در حال اجرای خط جدید."), + ("resolved", "تکمیل و رفع نقص روشنایی", "چراغ‌ها روشن و مدار به طور کامل تست شد.") + ], + "comments": [ + ("امیر رستمی", "خیلی سریع درستش کردن، واقعا تشکر از عوامل شهرداری."), + ("نگار توکلی", "دیشب رفتم پارک، همه چراغ‌ها درست شده بود.") + ] + }, + { + "tracking_code": "SHR-91203", + "title": "انباشت پسماند و سرریز مخزن زباله شهری", + "category": "نظافت و پسماند", + "description": "مخزن مکانیزه زباله شکسته شده و زباله‌ها در پیاده‌رو پخش شده که باعث بوی نامطبوع و تجمع حشرات شده است.", + "address": "خیابان آزادی، تقاطع خیابان استاد معین، پلاک ۴۲", + "district": 9, + "priority": "medium", + "status": "pending", + "lat": 35.6997, + "lng": 51.3486, + "image_url": "https://images.unsplash.com/photo-1532996122724-e3c354a0b15b?auto=format&fit=crop&w=800&q=80", + "resolved_image_url": None, + "reporter_name": "سعید کریمی", + "reporter_phone": "09355556677", + "official_response": "گزارش در نوبت بررسی ناظر پسماند منطقه قرار گرفته است.", + "upvotes": 19, + "days_ago": 1, + "resolved_at": None, + "timeline": [ + ("pending", "ثبت گزارش در سامانه", "منتظر بازرسی و تخصیص سطل جدید مکانیزه.") + ], + "comments": [ + ("مهدی بهرامی", "لطفا سطل‌های پدال‌دار بگذارید که درش بسته بمونه.") + ] + }, + { + "tracking_code": "SHR-44390", + "title": "آسیب دیدگی و شکستن شاخه‌های درخت کهنسال بر اثر طوفان", + "category": "فضای سبز و بوستان‌ها", + "description": "شاخه‌های سنگین درخت چنار شکسته و روی سیم‌های برق و پیاده‌رو معلق مانده است، احتمال سقوط روی عابرین وجود دارد.", + "address": "خیابان شریعتی، بالاتر از پل رومی، نبش کوچه یاس", + "district": 1, + "priority": "emergency", + "status": "in_progress", + "lat": 35.7985, + "lng": 51.4332, + "image_url": "https://images.unsplash.com/photo-1542601906990-b4d3fb778b09?auto=format&fit=crop&w=800&q=80", + "resolved_image_url": None, + "reporter_name": "پویا سرمدی", + "reporter_phone": "09124445566", + "official_response": "اکیپ سازمان بوستان‌ها و آتش‌نشانی با جرثقیل در حال هرس ایمن و رفع خطر هستند.", + "upvotes": 54, + "days_ago": 1, + "resolved_at": None, + "timeline": [ + ("pending", "ثبت فوری گزارش خطر", "هشدار سقوط شاخه دریافت شد."), + ("reviewing", "هماهنگی با سازمان آتش‌نشانی و فضای سبز", "اعلام وضعیت اضطراری."), + ("in_progress", "عملیات رفع خطر و هرس", "حصارکشی پیاده‌رو و استقرار بالابر.") + ], + "comments": [ + ("رویا صبوری", "خدا رو شکر سریع اومدن نوار خطر کشیدن.") + ] + }, + { + "tracking_code": "SHR-33219", + "title": "سد معبر طولانی مصالح ساختمانی و تخریب پیاده‌رو", + "category": "ساختمان‌سازی و سد معبر", + "description": "یک پروژه ساختمانی بیش از سه هفته است که تمام پیاده‌رو و بخشی از خیابان را با نخاله و داربست بدون راه عبور ایمن مسدود کرده است.", + "address": "سعادت‌آباد، میدان کاج، خیابان مروارید، پلاک ۱۸", + "district": 2, + "priority": "high", + "status": "reviewing", + "lat": 35.7794, + "lng": 51.3758, + "image_url": "https://images.unsplash.com/photo-1541888946425-d0fbb18086f6?auto=format&fit=crop&w=800&q=80", + "resolved_image_url": None, + "reporter_name": "فرشید نادری", + "reporter_phone": "09127778899", + "official_response": "اخطاریه ماده ۱۰۰ و رفع سد معبر برای مالک صادر شد. مهلت رفع ۴۸ ساعت می‌باشد.", + "upvotes": 37, + "days_ago": 3, + "resolved_at": None, + "timeline": [ + ("pending", "ثبت گزارش تخلف ساختمانی", "ثبت شد."), + ("reviewing", "بازدید مامور اجرای احکام شهرداری", "صدور اخطاریه رسمی رفع انسداد معبر.") + ], + "comments": [ + ("کامران شمس", "مادر من با ویلچر مجبور شد بره توی خیابان که خیلی خطرناک بود!") + ] + }, + { + "tracking_code": "SHR-55821", + "title": "خرابی و چشمک‌زن ماندن چراغ راهنمایی تقاطع پرتردد", + "category": "ترافیک و حمل‌ونقل", + "description": "چراغ راهنمایی تقاطع به مدت دو روز چشمک‌زن زرد مانده که باعث گره کور ترافیکی و تصادفات مکرر شده است.", + "address": "میدان فاطمی (جهاد)، تقاطع خیابان جویبار و شهید گمنام", + "district": 6, + "priority": "high", + "status": "resolved", + "lat": 35.7208, + "lng": 51.4082, + "image_url": "https://images.unsplash.com/photo-1517649763962-0c623266ddc0?auto=format&fit=crop&w=800&q=80", + "resolved_image_url": "https://images.unsplash.com/photo-1508873696983-2df5703bc275?auto=format&fit=crop&w=800&q=80", + "reporter_name": "ندا زمانی", + "reporter_phone": "09191113355", + "official_response": "برد الکترونیکی کنترلر چراغ توسط شرکت کنترل ترافیک تهران تعویض و زمان‌بندی هوشمند مجدداً فعال گردید.", + "upvotes": 85, + "days_ago": 4, + "resolved_at": (now - timedelta(days=2)).strftime("%Y-%m-%d %H:%M"), + "timeline": [ + ("pending", "ثبت گزارش خرابی علائم ترافیکی", "گزارش دریافت گردید."), + ("reviewing", "ارجاع به شرکت کنترل ترافیک", "تایید خرابی سخت‌افزاری کنترلر."), + ("in_progress", "تعویض برد فرمان و سنسورها", "عملیات فنی در محل انجام شد."), + ("resolved", "راه‌اندازی و اتصال به مرکز کنترل", "چراغ طبق برنامه زمان‌بندی هوشمند شروع به کار کرد.") + ], + "comments": [ + ("سامان یوسفی", "دست مریزاد، امروز صبح ترافیک خیلی روان‌تر بود.") + ] + } + ] + + for item in sample_issues: + created_time = (now - timedelta(days=item["days_ago"], hours=random.randint(1, 10))).strftime("%Y-%m-%d %H:%M") + cursor.execute(""" + INSERT INTO issues ( + tracking_code, title, category, description, address, district, priority, status, + lat, lng, image_url, resolved_image_url, reporter_name, reporter_phone, + official_response, upvotes, created_at, resolved_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + item["tracking_code"], item["title"], item["category"], item["description"], + item["address"], item["district"], item["priority"], item["status"], + item["lat"], item["lng"], item["image_url"], item["resolved_image_url"], + item["reporter_name"], item["reporter_phone"], item["official_response"], + item["upvotes"], created_time, item["resolved_at"] + )) + + issue_id = cursor.lastrowid + + for tl_status, tl_title, tl_desc in item["timeline"]: + cursor.execute(""" + INSERT INTO timeline (issue_id, status, title, description, created_at) + VALUES (?, ?, ?, ?, ?) + """, (issue_id, tl_status, tl_title, tl_desc, created_time)) + + for author, comment_text in item["comments"]: + cursor.execute(""" + INSERT INTO comments (issue_id, author_name, content, created_at) + VALUES (?, ?, ?, ?) + """, (issue_id, author, comment_text, created_time)) diff --git a/main.py b/main.py new file mode 100644 index 0000000..37fe569 --- /dev/null +++ b/main.py @@ -0,0 +1,338 @@ +import os +import uuid +import random +from datetime import datetime +from typing import Optional, List +from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Query +from fastapi.staticfiles import StaticFiles +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +import shutil + +from database import get_db_connection, init_db + +app = FastAPI(title="سامانه هوشمند مدیریت مسائل شهری - شهرنگار") + +# CORS setup +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +UPLOAD_DIR = os.path.join(os.path.dirname(__file__), "static", "uploads") +os.makedirs(UPLOAD_DIR, exist_ok=True) + +# Initialize database on startup +@app.on_event("startup") +def on_startup(): + init_db() + +# Pydantic models +class IssueCreate(BaseModel): + title: str + category: str + description: str + address: str + district: int + priority: str = "medium" + lat: float = 35.6892 + lng: float = 51.3890 + image_url: Optional[str] = None + reporter_name: Optional[str] = "شهروند ناشناس" + reporter_phone: Optional[str] = None + +class CommentCreate(BaseModel): + author_name: str + content: str + +class StatusUpdate(BaseModel): + status: str + official_response: Optional[str] = None + timeline_title: Optional[str] = None + timeline_desc: Optional[str] = None + resolved_image_url: Optional[str] = None + +# Routes +@app.get("/api/stats") +def get_stats(): + conn = get_db_connection() + cursor = conn.cursor() + + total = cursor.execute("SELECT COUNT(*) FROM issues").fetchone()[0] + pending = cursor.execute("SELECT COUNT(*) FROM issues WHERE status = 'pending'").fetchone()[0] + reviewing = cursor.execute("SELECT COUNT(*) FROM issues WHERE status = 'reviewing'").fetchone()[0] + in_progress = cursor.execute("SELECT COUNT(*) FROM issues WHERE status = 'in_progress'").fetchone()[0] + resolved = cursor.execute("SELECT COUNT(*) FROM issues WHERE status = 'resolved'").fetchone()[0] + upvotes = cursor.execute("SELECT COALESCE(SUM(upvotes), 0) FROM issues").fetchone()[0] + + # Category counts + cat_rows = cursor.execute("SELECT category, COUNT(*) as cnt FROM issues GROUP BY category ORDER BY cnt DESC").fetchall() + categories = [{"category": row["category"], "count": row["cnt"]} for row in cat_rows] + + # District counts + dist_rows = cursor.execute("SELECT district, COUNT(*) as cnt FROM issues GROUP BY district ORDER BY district ASC").fetchall() + districts = [{"district": row["district"], "count": row["cnt"]} for row in dist_rows] + + # Priority counts + prio_rows = cursor.execute("SELECT priority, COUNT(*) as cnt FROM issues GROUP BY priority").fetchall() + priorities = {row["priority"]: row["cnt"] for row in prio_rows} + + conn.close() + + return { + "total": total, + "pending": pending, + "reviewing": reviewing, + "in_progress": in_progress, + "resolved": resolved, + "total_upvotes": upvotes, + "categories": categories, + "districts": districts, + "priorities": priorities, + "resolution_rate": round((resolved / total * 100) if total > 0 else 0, 1) + } + +@app.get("/api/issues") +def list_issues( + category: Optional[str] = None, + status: Optional[str] = None, + district: Optional[int] = None, + priority: Optional[str] = None, + search: Optional[str] = None, + sort_by: Optional[str] = "newest" +): + conn = get_db_connection() + cursor = conn.cursor() + + query = "SELECT * FROM issues WHERE 1=1" + params = [] + + if category and category != "all": + query += " AND category = ?" + params.append(category) + + if status and status != "all": + query += " AND status = ?" + params.append(status) + + if district and district > 0: + query += " AND district = ?" + params.append(district) + + if priority and priority != "all": + query += " AND priority = ?" + params.append(priority) + + if search: + query += " AND (title LIKE ? OR description LIKE ? OR address LIKE ? OR tracking_code LIKE ?)" + s_pattern = f"%{search}%" + params.extend([s_pattern, s_pattern, s_pattern, s_pattern]) + + if sort_by == "upvotes": + query += " ORDER BY upvotes DESC, id DESC" + elif sort_by == "oldest": + query += " ORDER BY id ASC" + elif sort_by == "emergency": + query += " ORDER BY CASE priority WHEN 'emergency' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END, id DESC" + else: # newest + query += " ORDER BY id DESC" + + rows = cursor.execute(query, params).fetchall() + + issues = [] + for r in rows: + item = dict(r) + # Fetch comment count + c_count = cursor.execute("SELECT COUNT(*) FROM comments WHERE issue_id = ?", (r["id"],)).fetchone()[0] + item["comment_count"] = c_count + issues.append(item) + + conn.close() + return issues + +@app.get("/api/issues/{issue_id}") +def get_issue(issue_id: int): + conn = get_db_connection() + cursor = conn.cursor() + + issue_row = cursor.execute("SELECT * FROM issues WHERE id = ?", (issue_id,)).fetchone() + if not issue_row: + conn.close() + raise HTTPException(status_code=404, detail="گزارش مورد نظر یافت نشد.") + + issue = dict(issue_row) + + # Timeline + timeline_rows = cursor.execute("SELECT * FROM timeline WHERE issue_id = ? ORDER BY id ASC", (issue_id,)).fetchall() + issue["timeline"] = [dict(t) for t in timeline_rows] + + # Comments + comment_rows = cursor.execute("SELECT * FROM comments WHERE issue_id = ? ORDER BY id DESC", (issue_id,)).fetchall() + issue["comments"] = [dict(c) for c in comment_rows] + + conn.close() + return issue + +@app.get("/api/track/{tracking_code}") +def track_issue(tracking_code: str): + conn = get_db_connection() + cursor = conn.cursor() + + code = tracking_code.strip().upper() + issue_row = cursor.execute("SELECT * FROM issues WHERE UPPER(tracking_code) = ?", (code,)).fetchone() + if not issue_row: + conn.close() + raise HTTPException(status_code=404, detail=f"هیچ گزارشی با کد رهگیری «{tracking_code}» یافت نشد.") + + issue_id = issue_row["id"] + issue = dict(issue_row) + + timeline_rows = cursor.execute("SELECT * FROM timeline WHERE issue_id = ? ORDER BY id ASC", (issue_id,)).fetchall() + issue["timeline"] = [dict(t) for t in timeline_rows] + + comment_rows = cursor.execute("SELECT * FROM comments WHERE issue_id = ? ORDER BY id DESC", (issue_id,)).fetchall() + issue["comments"] = [dict(c) for c in comment_rows] + + conn.close() + return issue + +@app.post("/api/issues") +def create_issue(payload: IssueCreate): + conn = get_db_connection() + cursor = conn.cursor() + + # Generate unique tracking code + rand_num = random.randint(10000, 99999) + tracking_code = f"SHR-{rand_num}" + + now_str = datetime.now().strftime("%Y-%m-%d %H:%M") + + cursor.execute(""" + INSERT INTO issues ( + tracking_code, title, category, description, address, district, priority, status, + lat, lng, image_url, reporter_name, reporter_phone, upvotes, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, 1, ?) + """, ( + tracking_code, payload.title, payload.category, payload.description, payload.address, + payload.district, payload.priority, payload.lat, payload.lng, payload.image_url, + payload.reporter_name or "شهروند", payload.reporter_phone or "", now_str + )) + + issue_id = cursor.lastrowid + + # Add initial timeline event + cursor.execute(""" + INSERT INTO timeline (issue_id, status, title, description, created_at) + VALUES (?, 'pending', 'ثبت اولیه گزارش در سامانه', 'گزارش شما با موفقیت در سامانه شهرنگار ثبت گردید و در نوبت بررسی کارشناسی قرار گرفت.', ?) + """, (issue_id, now_str)) + + conn.commit() + + created = cursor.execute("SELECT * FROM issues WHERE id = ?", (issue_id,)).fetchone() + res = dict(created) + conn.close() + + return res + +@app.post("/api/issues/{issue_id}/upvote") +def upvote_issue(issue_id: int): + conn = get_db_connection() + cursor = conn.cursor() + + cursor.execute("UPDATE issues SET upvotes = upvotes + 1 WHERE id = ?", (issue_id,)) + if cursor.rowcount == 0: + conn.close() + raise HTTPException(status_code=404, detail="گزارش یافت نشد.") + + conn.commit() + new_upvotes = cursor.execute("SELECT upvotes FROM issues WHERE id = ?", (issue_id,)).fetchone()[0] + conn.close() + return {"id": issue_id, "upvotes": new_upvotes} + +@app.post("/api/issues/{issue_id}/comment") +def add_comment(issue_id: int, payload: CommentCreate): + conn = get_db_connection() + cursor = conn.cursor() + + check = cursor.execute("SELECT id FROM issues WHERE id = ?", (issue_id,)).fetchone() + if not check: + conn.close() + raise HTTPException(status_code=404, detail="گزارش یافت نشد.") + + now_str = datetime.now().strftime("%Y-%m-%d %H:%M") + cursor.execute(""" + INSERT INTO comments (issue_id, author_name, content, created_at) + VALUES (?, ?, ?, ?) + """, (issue_id, payload.author_name or "شهروند محترم", payload.content, now_str)) + + conn.commit() + comment_id = cursor.lastrowid + comment = cursor.execute("SELECT * FROM comments WHERE id = ?", (comment_id,)).fetchone() + conn.close() + return dict(comment) + +@app.patch("/api/issues/{issue_id}/status") +def update_issue_status(issue_id: int, payload: StatusUpdate): + conn = get_db_connection() + cursor = conn.cursor() + + check = cursor.execute("SELECT * FROM issues WHERE id = ?", (issue_id,)).fetchone() + if not check: + conn.close() + raise HTTPException(status_code=404, detail="گزارش یافت نشد.") + + now_str = datetime.now().strftime("%Y-%m-%d %H:%M") + resolved_at = now_str if payload.status == "resolved" else check["resolved_at"] + + # Update issue + cursor.execute(""" + UPDATE issues + SET status = ?, + official_response = COALESCE(?, official_response), + resolved_image_url = COALESCE(?, resolved_image_url), + resolved_at = ? + WHERE id = ? + """, (payload.status, payload.official_response, payload.resolved_image_url, resolved_at, issue_id)) + + # Add timeline entry if requested or automatically + status_titles = { + "pending": "در صف بررسی مجدد", + "reviewing": "بررسی کارشناسی و ارجاع به معاونت مربوطه", + "in_progress": "اعزام اکیپ اجرایی و آغاز عملیات میدانی", + "resolved": "اتمام عملیات و رفع کامل مسئله", + "rejected": "عدم احراز یا خارج از حیطه اختیارات شهرداری" + } + + tl_title = payload.timeline_title or status_titles.get(payload.status, f"تغییر وضعیت به {payload.status}") + tl_desc = payload.timeline_desc or payload.official_response or "وضعیت پرونده توسط مدیریت سامانه به‌روزرسانی شد." + + cursor.execute(""" + INSERT INTO timeline (issue_id, status, title, description, created_at) + VALUES (?, ?, ?, ?, ?) + """, (issue_id, payload.status, tl_title, tl_desc, now_str)) + + conn.commit() + + updated = cursor.execute("SELECT * FROM issues WHERE id = ?", (issue_id,)).fetchone() + res = dict(updated) + conn.close() + return res + +@app.post("/api/upload") +async def upload_file(file: UploadFile = File(...)): + filename = f"{uuid.uuid4().hex[:10]}_{file.filename}" + filepath = os.path.join(UPLOAD_DIR, filename) + with open(filepath, "wb") as buffer: + shutil.copyfileobj(file.file, buffer) + return {"url": f"/static/uploads/{filename}"} + +# Serve frontend static files +app.mount("/static", StaticFiles(directory=os.path.join(os.path.dirname(__file__), "static")), name="static") + +@app.get("/") +def serve_index(): + from fastapi.responses import FileResponse + return FileResponse(os.path.join(os.path.dirname(__file__), "static", "index.html")) diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..4c47701 --- /dev/null +++ b/static/app.js @@ -0,0 +1,959 @@ +// 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 = ` ${dateStr}`; + } catch (e) { + el.innerHTML = ` سامانه فعال است`; + } +} + +// 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 = ` + +
+ ${issue.title} +
+ + +
+ + + ${st.label} + + + ${prio.label} + +
+ + +
+ + ${issue.tracking_code} + + + منطقه ${issue.district} شهرداری + +
+
+ + +
+ +
+ +
+ + + ${issue.category} + + ${issue.created_at} +
+ + +

+ ${issue.title} +

+ + +

+ ${issue.description} +

+ + +
+ + ${issue.address} +
+
+ + +
+ + + + + +
+ +
+ `; + + 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: ` +
+ +
+ `, + iconSize: [32, 32], + iconAnchor: [16, 32], + popupAnchor: [0, -32] + }); + + const marker = L.marker([issue.lat, issue.lng], { icon: customIcon }).addTo(leafletMap); + + const popupContent = ` +
+ + ${STATUS_META[issue.status]?.label || ''} + +

${issue.title}

+

${issue.address}

+ +
+ `; + + 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 = ` +
+ ${stages.map((stage, idx) => { + const stepNum = idx + 1; + const isDone = currentStep >= stepNum; + const isCurrent = currentStep === stepNum; + return ` +
+
+ +
+ ${stage.label} +
+ `; + }).join('')} +
+ `; + + // Build Timeline items + let timelineHtml = (issue.timeline || []).map(tl => ` +
+ +
${tl.title}
+
${tl.created_at}
+

${tl.description}

+
+ `).join(''); + + // Build Comments + let commentsHtml = (issue.comments || []).map(c => ` +
+
+ + + ${c.author_name} + + ${c.created_at} +
+

${c.content}

+
+ `).join(''); + + content.innerHTML = ` + +
+
+ + ${issue.tracking_code} + +
+

${issue.title}

+

منطقه ${issue.district} شهرداری • تاریخ ثبت: ${issue.created_at}

+
+
+ +
+ +
+ + + ${stepperHtml} + + +
+
+ تصویر گزارش‌شده توسط شهروند: +
+ +
+
+ + ${issue.resolved_image_url ? ` +
+ + + تصویر پس از انجام عملیات و رفع نقص: + +
+ +
+
+ ` : ''} +
+ + +
+
+ ${issue.category} + ${prio.label} + ${st.label} +
+ +
+

شرح دقیق مشکل:

+

${issue.description}

+
+ +
+ + نشانی محل: ${issue.address} +
+
+ + + ${issue.official_response ? ` +
+
+ + پاسخ رسمی شهرداری منطقه ${issue.district}: +
+

+ ${issue.official_response} +

+
+ ` : ''} + + +
+

+ + تاریخچه و روند رسیدگی پرونده +

+
+ ${timelineHtml} +
+
+ + +
+
+

+ + نظرات و مشاهدات شهروندان +

+ ${(issue.comments || []).length} نظر ثبت شده +
+ + +
+ ${commentsHtml.length > 0 ? commentsHtml : '

هنوز نظری ثبت نشده است. شما اولین نفر باشید!

'} +
+ + +
+ + + +
+
+ +
+ `; + + 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 = ` +
+
+

در حال استعلام از سامانه نظارت شهری...

+
+ `; + + try { + const res = await fetch(`/api/track/${encodeURIComponent(code)}`); + if (!res.ok) { + container.innerHTML = ` +
+ +

هیچ گزارشی با کد رهگیری «${code}» یافت نشد!

+

لطفاً از صحت کد وارد شده اطمینان حاصل فرمایید.

+
+ `; + 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 = ` +
+
+ نتیجه استعلام پرونده +

${issue.title}

+
+
+ + ${issue.tracking_code} + + + ${st.label} + +
+
+ +
+
+ دسته‌بندی موضوع: + ${issue.category} +
+
+ منطقه شهرداری: + منطقه ${issue.district} +
+
+ تاریخ و ساعت ثبت: + ${issue.created_at} +
+
+ + ${issue.official_response ? ` +
+

+ پاسخ رسمی بازرس و اکیپ شهرداری: +

+

${issue.official_response}

+
+ ` : ''} + +
+

مراحل و اقدامات انجام‌شده:

+
+ ${(issue.timeline || []).map(tl => ` +
+
+ +
+
+
+ ${tl.title} + ${tl.created_at} +
+

${tl.description}

+
+
+ `).join('')} +
+
+ `; + } catch (err) { + container.innerHTML = `

خطا در برقراری ارتباط با سرور.

`; + } +} + +// 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 = ` در حال ثبت...`; + + 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: ` +
+ کد رهگیری اختصاصی شما: +
${created.tracking_code}
+

این کد را جهت استعلام‌های بعدی و پیگیری مراحل یادداشت نمایید.

+
+ `, + confirmButtonText: 'مشاهده گزارش در صفحه اصلی', + confirmButtonColor: '#059669' + }); + + loadStats(); + fetchIssues(); + } + } catch (err) { + Swal.fire({ icon: 'error', title: 'خطا در ثبت گزارش', text: 'لطفاً دوباره تلاش کنید.' }); + } finally { + btn.disabled = false; + btn.innerHTML = ` ارسال و ثبت نهایی گزارش`; + } +} + +// ADMIN DASHBOARD SECTION +async function loadAdminIssues() { + const tbody = document.getElementById('adminTableBody'); + const countEl = document.getElementById('adminTableCount'); + tbody.innerHTML = `در حال بارگذاری اطلاعات پرونده‌ها...`; + + 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 = ` + ${issue.tracking_code} + +
${issue.title}
+
${issue.address}
+ + ${issue.category} + منطقه ${issue.district} + ${prio.label} + ${st.label} + ${issue.created_at} + + + + `; + 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' } + } + } + }); + } +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..3dacfab --- /dev/null +++ b/static/index.html @@ -0,0 +1,763 @@ + + + + + + شهرنگار | سامانه جامع مدیریت و رسیدگی به مسائل شهری + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ سامانه برخط ۱۳۷ + همراهی شما برای شهری زیباتر، ایمن‌تر و پاک‌تر • پاسخگویی ۲۴ ساعته +
+ +
+ + +
+
+
+ + +
+
+ +
+
+
+ شهرنگار + نسخه هوشمند +
+

سامانه یکپارچه گزارش و نظارت مردمی بر مسائل شهری

+
+
+ + + + + +
+ +
+ +
+
+ + +
+ + + + +
+
+ + +
+
+
+ + +
+
+ + صدای شما، اقدام سریع شهرداری و بازرسان شهری +
+

+ شهر خود را با مشارکت هوشمند، + زیباتر و امن‌تر بسازید +

+

+ خرابی آسفالت، سد معبر، نقص روشنایی پارک‌ها، انباشت پسماند یا مشکلات ترافیکی را در کمتر از ۱ دقیقه گزارش کنید و وضعیت رسیدگی را مرحله به مرحله رصد نمایید. +

+ + +
+
+ + +
+ +
+
+ + +
+
+
+ کل موارد ثبت‌شده +
+ +
+
+
--
+
گزارش‌های شهروندی
+
+ +
+
+ مسائل رفع شده +
+ +
+
+
--
+
نرخ تکمیل: --٪
+
+ +
+
+ در حال عملیات و اجرا +
+ +
+
+
--
+
اکیپ مستقر در محل
+
+ +
+
+ تایید و همراهی شهروندان +
+ +
+
+
--
+
رأی و حمایت مردمی
+
+
+ +
+
+
+ + +
+
+ دسته‌بندی‌ها: + + + + + + + +
+
+ + +
+ + +
+ + +
+ +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+

در حال بارگذاری گزارش‌های شهری...

+
+ + + +
+ + + + + + + + + + +
+ + + + + + + + + + + + + + + + + diff --git a/urban_issues.db b/urban_issues.db new file mode 100644 index 0000000..e9fd380 Binary files /dev/null and b/urban_issues.db differ