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 = `
+
+
+
+
+
+
+
+
+
+ ${st.label}
+
+
+ ${prio.label}
+
+
+
+
+
+
+ ${issue.tracking_code}
+
+
+ منطقه ${issue.district} شهرداری
+
+
+
+
+
+
+
+
+
+
+
+
+ ${issue.category}
+
+ ${issue.created_at}
+
+
+
+
+ ${issue.title}
+
+
+
+
+ ${issue.description}
+
+
+
+
+
+ ${issue.address}
+
+
+
+
+
+
+
+
+ ${issue.upvotes}
+ تایید شهروندی
+
+
+
+
+ مشاهده و پیگیری
+
+
+
+
+
+ `;
+
+ 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 `
+
+ `;
+ }).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} نظر ثبت شده
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ 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 @@
+
+
+
+
+
+ شهرنگار | سامانه جامع مدیریت و رسیدگی به مسائل شهری
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ سامانه برخط ۱۳۷
+ همراهی شما برای شهری زیباتر، ایمنتر و پاکتر • پاسخگویی ۲۴ ساعته
+
+
+ امروز شنبه
+ |
+ تلفن ارتباط مردمی: ۱۳۷
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ صدای شما، اقدام سریع شهرداری و بازرسان شهری
+
+
+
+ خرابی آسفالت، سد معبر، نقص روشنایی پارکها، انباشت پسماند یا مشکلات ترافیکی را در کمتر از ۱ دقیقه گزارش کنید و وضعیت رسیدگی را مرحله به مرحله رصد نمایید.
+
+
+
+
+
+
+
+
+
+
+ جستجو و پیگیری
+
+
+
+
+
+
+
+
+
کل موارد ثبتشده
+
+
+
+
+
--
+
گزارشهای شهروندی
+
+
+
+
+
--
+
نرخ تکمیل: --٪
+
+
+
+
+
در حال عملیات و اجرا
+
+
+
+
+
--
+
اکیپ مستقر در محل
+
+
+
+
+
تایید و همراهی شهروندان
+
+
+
+
+
--
+
رأی و حمایت مردمی
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ منطقه شهرداری:
+
+ تمام مناطق (۱ تا ۲۲)
+ منطقه ۱ (شمرانات/تجریش)
+ منطقه ۲ (سعادتآباد/شهرک غرب)
+ منطقه ۳ (ونک/قلهک/میرداماد)
+ منطقه ۴ (تهرانپارس/هروی)
+ منطقه ۵ (صادقیه/پونک/جنتآباد)
+ منطقه ۶ (یوسفآباد/امیرآباد/فاطمی)
+ منطقه ۷ (سهروردی/مطهری)
+ منطقه ۸ (نارمک/مجیدیه)
+ منطقه ۹ (آزادی/استادمعین)
+ منطقه ۱۰ (نواب/جیحون)
+ منطقه ۱۱ (جمهوری/منیریه)
+ منطقه ۱۲ (بازار/بهارستان)
+
+
+
+
+
+ وضعیت رسیدگی:
+
+ همه وضعیتها
+ در صف بررسی اولیه
+ بررسی کارشناسی
+ در حال اجرای عملیات
+ رفع و حل شده
+
+
+
+
+
+ سطح فوریت:
+
+ همه اولویتها
+ 🚨 فوری و خطرآفرین
+ اولویت بالا
+ متوسط
+ عادی
+
+
+
+
+
+
+ مرتبسازی:
+
+ جدیدترین گزارشها
+ بیشترین تایید شهروندی (محبوبترین)
+ بیشترین فوریت و خطر
+ قدیمیترین
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
در حال بارگذاری گزارشهای شهری...
+
+
+
+
+
+
+
موردی با این مشخصات یافت نشد
+
میتوانید فیلترها را تغییر دهید یا خودتان اولین گزارش را ثبت کنید.
+
+ ثبت گزارش جدید
+
+
+
+
+
+
+
+
+
+
+
+ نقشه زنده و پراکندگی مسائل شهری
+
+
برای مشاهده جزئیات، عکسها و وضعیت هر موضوع، روی نشانگرهای نقشه کلیک کنید.
+
+
+
+
+ در حال بررسی/اجرا
+ رفع شده
+ نیازمند اقدام فوری
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
سامانه پیگیری و استعلام گزارشهای شهری
+
+ با وارد کردن کد پیگیری (مانند SHR-78214 )، از آخرین وضعیت اقدامات شهرداری، اعزام اکیپ و تصاویر قبل و بعد مطلع شوید.
+
+
+
+
+
+
+ استعلام وضعیت
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ مرکز کنترل و نظارت شهرداری (شهردید)
+
+
میز خدمت کارشناسان و بازرسان شهری
+
رسیدگی به تیکتهای شهروندان، تغییر وضعیت، ثبت گزارش اکیپ اجرایی و صدور دستور کار
+
+
+
+
+
+ بروزرسانی دادهها
+
+
+
+
+
+
+
+
+
+
+ توزیع موضوعی مسائل گزارششده
+
+
+
+
+
+
+
+
+
+
+ وضعیت پروندهها
+
+
+
+
+
+
+
+
+
+
+
+
+ جدول مدیریت و صدور دستور کار پروندهها
+
+ در حال بارگذاری...
+
+
+
+
+
+
+ کد رهگیری
+ عنوان و موقعیت
+ دستهبندی
+ منطقه
+ اولویت
+ وضعیت جاری
+ تاریخ ثبت
+ عملیات بازرسی
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ بهروزرسانی وضعیت و پاسخ کارشناسی
+
+
+
+
+
+
+
+
+
+
+ تغییر وضعیت پرونده
+
+ در صف بررسی اولیه
+ بررسی کارشناسی و ارجاع به اکیپ
+ در حال اجرای عملیات میدانی
+ رفع و حل کامل مشکل
+ رد گزارش (خارج از اختیارات/اطلاعات ناقص)
+
+
+
+
+ عنوان مرحله در تایملاین
+
+
+
+
+ پاسخ رسمی و توضیحات اکیپ اجرایی
+
+
+
+
+ آدرس تصویر رفع مشکل (پس از انجام کار)
+
+
+
+
+ انصراف
+ ثبت تغییرات
+
+
+
+
+
+
+
+
+
+
+
+
+
+
سامانه هوشمند گزارش و رسیدگی به مسائل شهری «شهرنگار»
+
طراحی و پیادهسازی برای ارتقای کیفیت زندگی و خدمات شهری
+
+
+
+
+ گزارشها
+ نقشه زنده
+ استعلام کد رهگیری
+ پنل بازرسان
+
+
+
+
+
+
+
+
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