Initial commit: Real Estate Registry app
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
# Required
|
||||||
|
SECRET_KEY=generate-a-long-random-string-here
|
||||||
|
ENVIRONMENT=production
|
||||||
|
|
||||||
|
# AI provider keys (at least one required for OCR extraction)
|
||||||
|
GEMINI_API_KEY=
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
|
||||||
|
# Optional overrides
|
||||||
|
# DB_PATH=/path/to/realestate.db
|
||||||
|
# UPLOAD_DIR=/path/to/uploads
|
||||||
|
# MAX_CONCURRENT=3
|
||||||
|
# DEFAULT_PROVIDER=gemini
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
.env
|
||||||
|
data/*.db
|
||||||
|
data/*.db-wal
|
||||||
|
data/*.db-shm
|
||||||
|
uploads/
|
||||||
|
backups/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
.claude/
|
||||||
|
.vscode/
|
||||||
|
*.jpeg
|
||||||
|
*.jpg
|
||||||
|
*.png
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).parent
|
||||||
|
|
||||||
|
# Load environment variables from .env file and override any existing terminal variables
|
||||||
|
load_dotenv(BASE_DIR / ".env", override=True)
|
||||||
|
|
||||||
|
# API Keys — set whichever provider(s) you want to use
|
||||||
|
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
|
||||||
|
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "").strip().strip('"\'')
|
||||||
|
|
||||||
|
# Default provider: "claude", "gemini", or "easyocr"
|
||||||
|
DEFAULT_PROVIDER = os.environ.get("DEFAULT_PROVIDER", "gemini")
|
||||||
|
|
||||||
|
# Secret key for session signing — MUST be set in .env for production
|
||||||
|
SECRET_KEY = os.environ.get("SECRET_KEY", "")
|
||||||
|
|
||||||
|
DATABASE_PATH = os.environ.get("DB_PATH", str(BASE_DIR / "data" / "realestate.db"))
|
||||||
|
UPLOAD_DIR = os.environ.get("UPLOAD_DIR", str(BASE_DIR / "uploads"))
|
||||||
|
MAX_CONCURRENT_EXTRACTIONS = int(os.environ.get("MAX_CONCURRENT", "3"))
|
||||||
|
CLAUDE_MODEL = "claude-sonnet-4-6"
|
||||||
|
GEMINI_MODEL = "gemini-2.5-flash"
|
||||||
|
|
||||||
|
# Production mode — set to "production" on VPS
|
||||||
|
ENVIRONMENT = os.environ.get("ENVIRONMENT", "development")
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""One-time script to create the initial admin user."""
|
||||||
|
import sys
|
||||||
|
import getpass
|
||||||
|
from database.schema import create_tables
|
||||||
|
from services.auth_service import create_user, get_user_by_username
|
||||||
|
|
||||||
|
def main():
|
||||||
|
create_tables()
|
||||||
|
|
||||||
|
username = input("Enter admin username: ").strip()
|
||||||
|
if not username:
|
||||||
|
print("Username cannot be empty.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if get_user_by_username(username):
|
||||||
|
print(f"User '{username}' already exists.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
password = getpass.getpass("Enter password: ")
|
||||||
|
confirm = getpass.getpass("Confirm password: ")
|
||||||
|
|
||||||
|
if password != confirm:
|
||||||
|
print("Passwords do not match.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if len(password) < 8:
|
||||||
|
print("Password must be at least 8 characters.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
create_user(username, password)
|
||||||
|
print(f"Admin user '{username}' created successfully.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import sqlite3
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from config import DATABASE_PATH
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def get_connection() -> sqlite3.Connection:
|
||||||
|
Path(DATABASE_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(DATABASE_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_db():
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
from database.connection import get_db
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate(conn):
|
||||||
|
"""Add columns that may not exist in older databases."""
|
||||||
|
cursor = conn.execute("PRAGMA table_info(documents)")
|
||||||
|
existing = {row[1] for row in cursor.fetchall()}
|
||||||
|
migrations = [
|
||||||
|
("provider", "TEXT"),
|
||||||
|
("pdf_group_id", "TEXT"),
|
||||||
|
("page_number", "INTEGER"),
|
||||||
|
("page_info", "TEXT"),
|
||||||
|
("search_scope", "TEXT"),
|
||||||
|
("request_purpose", "TEXT"),
|
||||||
|
("data_valid_until", "TEXT"),
|
||||||
|
("registry_office", "TEXT"),
|
||||||
|
("owns_properties", "BOOLEAN"),
|
||||||
|
("declared_property_count", "INTEGER"),
|
||||||
|
]
|
||||||
|
for col, col_type in migrations:
|
||||||
|
if col not in existing:
|
||||||
|
conn.execute(f"ALTER TABLE documents ADD COLUMN {col} {col_type}")
|
||||||
|
|
||||||
|
cursor = conn.execute("PRAGMA table_info(properties)")
|
||||||
|
existing_prop = {row[1] for row in cursor.fetchall()}
|
||||||
|
migrations_prop = [
|
||||||
|
("party_name", "TEXT"),
|
||||||
|
("qaza", "TEXT"),
|
||||||
|
]
|
||||||
|
for col, col_type in migrations_prop:
|
||||||
|
if col not in existing_prop:
|
||||||
|
conn.execute(f"ALTER TABLE properties ADD COLUMN {col} {col_type}")
|
||||||
|
|
||||||
|
def create_tables():
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS persons (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
first_name TEXT NOT NULL,
|
||||||
|
father_name TEXT,
|
||||||
|
mother_name TEXT,
|
||||||
|
family_name TEXT,
|
||||||
|
family_origin TEXT,
|
||||||
|
nationality TEXT,
|
||||||
|
birth_date TEXT,
|
||||||
|
registry_number TEXT UNIQUE,
|
||||||
|
registry_place TEXT,
|
||||||
|
first_name_norm TEXT,
|
||||||
|
family_name_norm TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS documents (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
person_id INTEGER REFERENCES persons(id),
|
||||||
|
image_path TEXT NOT NULL,
|
||||||
|
request_number TEXT,
|
||||||
|
request_date TEXT,
|
||||||
|
applicant_name_raw TEXT,
|
||||||
|
request_purpose TEXT,
|
||||||
|
data_valid_until TEXT,
|
||||||
|
registry_office TEXT,
|
||||||
|
owns_properties BOOLEAN,
|
||||||
|
declared_property_count INTEGER,
|
||||||
|
provider TEXT,
|
||||||
|
pdf_group_id TEXT,
|
||||||
|
page_number INTEGER,
|
||||||
|
page_info TEXT,
|
||||||
|
search_scope TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
raw_extraction_json TEXT,
|
||||||
|
extraction_error TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS properties (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
document_id INTEGER NOT NULL REFERENCES documents(id),
|
||||||
|
person_id INTEGER REFERENCES persons(id),
|
||||||
|
row_order INTEGER DEFAULT 0,
|
||||||
|
area_name TEXT,
|
||||||
|
party_name TEXT,
|
||||||
|
property_number TEXT,
|
||||||
|
section TEXT,
|
||||||
|
block TEXT,
|
||||||
|
real_estate_district TEXT,
|
||||||
|
space TEXT,
|
||||||
|
qaza TEXT,
|
||||||
|
num_shares TEXT,
|
||||||
|
ownership_type TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_persons_first_name_norm ON persons(first_name_norm);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_persons_family_name_norm ON persons(family_name_norm);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_persons_registry_number ON persons(registry_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_documents_person_id ON documents(person_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_documents_status ON documents(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_properties_person_id ON properties(person_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_properties_property_number ON properties(property_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_properties_district ON properties(real_estate_district);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_properties_block ON properties(block);
|
||||||
|
""")
|
||||||
|
_migrate(conn)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from config import UPLOAD_DIR, ENVIRONMENT
|
||||||
|
from database.schema import create_tables
|
||||||
|
from routers import documents, review, search, upload, auth
|
||||||
|
|
||||||
|
# Disable docs/openapi in production
|
||||||
|
if ENVIRONMENT == "production":
|
||||||
|
app = FastAPI(title="Lebanese Real Estate Registry", docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
|
else:
|
||||||
|
app = FastAPI(title="Lebanese Real Estate Registry")
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def check_authentication(request: Request, call_next):
|
||||||
|
path = request.url.path
|
||||||
|
allowed_paths = ["/auth/login", "/static"]
|
||||||
|
is_allowed = any(path.startswith(p) for p in allowed_paths)
|
||||||
|
|
||||||
|
if not is_allowed:
|
||||||
|
session_id = request.cookies.get("session_id")
|
||||||
|
if not session_id or session_id not in auth.sessions:
|
||||||
|
return RedirectResponse(url="/auth/login", status_code=303)
|
||||||
|
|
||||||
|
response = await call_next(request)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup():
|
||||||
|
Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
|
||||||
|
Path("data").mkdir(exist_ok=True)
|
||||||
|
create_tables()
|
||||||
|
|
||||||
|
# Do NOT create default admin user automatically in production!
|
||||||
|
# A script should be used for initial setup.
|
||||||
|
|
||||||
|
|
||||||
|
app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads")
|
||||||
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|
||||||
|
app.include_router(upload.router)
|
||||||
|
app.include_router(review.router)
|
||||||
|
app.include_router(search.router)
|
||||||
|
app.include_router(documents.router)
|
||||||
|
app.include_router(auth.router, prefix="/auth")
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
fastapi>=0.115.0
|
||||||
|
uvicorn[standard]>=0.30.0
|
||||||
|
anthropic>=0.49.0
|
||||||
|
google-genai>=1.70.0
|
||||||
|
easyocr>=1.7.0
|
||||||
|
pymupdf>=1.24.0
|
||||||
|
jinja2>=3.1.4
|
||||||
|
python-multipart>=0.0.9
|
||||||
|
aiofiles>=23.0.0
|
||||||
|
pillow>=10.0.0
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Request, Form, HTTPException, status
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from services.auth_service import verify_password, get_user_by_username, create_user, delete_user, get_all_users
|
||||||
|
from services.backup_service import create_backup
|
||||||
|
import secrets
|
||||||
|
import os
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
# Simple memory store for sessions for this requirement. (In prod we use Redis/Cookie etc., but cookie + session dict is quickest without external deps like itsdangerous if not in requirements)
|
||||||
|
sessions = {}
|
||||||
|
|
||||||
|
def get_current_user_from_request(request: Request):
|
||||||
|
session_id = request.cookies.get("session_id")
|
||||||
|
if session_id and session_id in sessions:
|
||||||
|
return sessions[session_id]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_current_user(request: Request):
|
||||||
|
user = get_current_user_from_request(request)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_303_SEE_OTHER,
|
||||||
|
headers={"Location": "/auth/login"},
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
@router.get("/login", response_class=HTMLResponse)
|
||||||
|
async def login_get(request: Request):
|
||||||
|
return templates.TemplateResponse(request=request, name="login.html", context={"error": None})
|
||||||
|
|
||||||
|
@router.post("/login", response_class=HTMLResponse)
|
||||||
|
async def login_post(request: Request, username: str = Form(...), password: str = Form(...)):
|
||||||
|
user = get_user_by_username(username)
|
||||||
|
if not user or not verify_password(user["password_hash"], password):
|
||||||
|
return templates.TemplateResponse(request=request, name="login.html", context={"error": "Invalid username or password"})
|
||||||
|
|
||||||
|
|
||||||
|
session_id = secrets.token_urlsafe(32)
|
||||||
|
sessions[session_id] = dict(user)
|
||||||
|
|
||||||
|
from config import ENVIRONMENT
|
||||||
|
response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
response.set_cookie(
|
||||||
|
key="session_id",
|
||||||
|
value=session_id,
|
||||||
|
httponly=True,
|
||||||
|
secure=ENVIRONMENT == "production",
|
||||||
|
samesite="lax",
|
||||||
|
max_age=86400, # 24 hours
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
@router.get("/logout")
|
||||||
|
async def logout(request: Request):
|
||||||
|
response = RedirectResponse(url="/auth/login")
|
||||||
|
session_id = request.cookies.get("session_id")
|
||||||
|
if session_id in sessions:
|
||||||
|
del sessions[session_id]
|
||||||
|
response.delete_cookie("session_id")
|
||||||
|
return response
|
||||||
|
|
||||||
|
@router.get("/users", response_class=HTMLResponse)
|
||||||
|
async def users_list(request: Request, _=Depends(get_current_user)):
|
||||||
|
users = get_all_users()
|
||||||
|
return templates.TemplateResponse(request=request, name="users.html", context={"users": users})
|
||||||
|
|
||||||
|
@router.post("/users/create")
|
||||||
|
async def add_user(username: str = Form(...), password: str = Form(...), _=Depends(get_current_user)):
|
||||||
|
try:
|
||||||
|
create_user(username, password)
|
||||||
|
except Exception:
|
||||||
|
pass # Probably duplicate
|
||||||
|
return RedirectResponse(url="/auth/users", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
@router.post("/users/delete/{user_id}")
|
||||||
|
async def remove_user(user_id: int, _=Depends(get_current_user)):
|
||||||
|
delete_user(user_id)
|
||||||
|
return RedirectResponse(url="/auth/users", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
@router.get("/backup")
|
||||||
|
async def backup_db(request: Request, _=Depends(get_current_user)):
|
||||||
|
try:
|
||||||
|
backup_path = create_backup()
|
||||||
|
return FileResponse(backup_path, media_type="application/octet-stream", filename=os.path.basename(backup_path))
|
||||||
|
except Exception as e:
|
||||||
|
msg = f"Backup failed: {str(e)}"
|
||||||
|
return templates.TemplateResponse(request=request, name="users.html", context={
|
||||||
|
"users": get_all_users(),
|
||||||
|
"backup_msg": msg
|
||||||
|
})
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from config import UPLOAD_DIR
|
||||||
|
from database.connection import get_db
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/documents")
|
||||||
|
async def document_queue(request: Request, status: str = "", uploaded: int = 0):
|
||||||
|
with get_db() as conn:
|
||||||
|
if status:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT d.*, p.first_name, p.family_name
|
||||||
|
FROM documents d
|
||||||
|
LEFT JOIN persons p ON p.id = d.person_id
|
||||||
|
WHERE d.status=?
|
||||||
|
ORDER BY d.id DESC""",
|
||||||
|
(status,),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT d.*, p.first_name, p.family_name
|
||||||
|
FROM documents d
|
||||||
|
LEFT JOIN persons p ON p.id = d.person_id
|
||||||
|
ORDER BY d.id DESC"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
stats = conn.execute(
|
||||||
|
"""SELECT
|
||||||
|
COUNT(*) AS total,
|
||||||
|
SUM(CASE WHEN status='confirmed' THEN 1 ELSE 0 END) AS confirmed,
|
||||||
|
SUM(CASE WHEN status='extracted' THEN 1 ELSE 0 END) AS pending_review,
|
||||||
|
SUM(CASE WHEN status='pending' THEN 1 ELSE 0 END) AS processing,
|
||||||
|
SUM(CASE WHEN status='error' THEN 1 ELSE 0 END) AS errors
|
||||||
|
FROM documents"""
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"documents.html",
|
||||||
|
{
|
||||||
|
"documents": [dict(r) for r in rows],
|
||||||
|
"stats": dict(stats) if stats else {},
|
||||||
|
"current_status": status,
|
||||||
|
"uploaded": uploaded,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/documents/{doc_id}")
|
||||||
|
async def delete_document(doc_id: int):
|
||||||
|
"""Delete a document and its properties. Also removes the image file from disk."""
|
||||||
|
with get_db() as conn:
|
||||||
|
doc = conn.execute(
|
||||||
|
"SELECT image_path, person_id FROM documents WHERE id=?", (doc_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not doc:
|
||||||
|
return JSONResponse({"error": "not found"}, status_code=404)
|
||||||
|
|
||||||
|
# Delete properties for this document
|
||||||
|
conn.execute("DELETE FROM properties WHERE document_id=?", (doc_id,))
|
||||||
|
|
||||||
|
# Delete the document record
|
||||||
|
conn.execute("DELETE FROM documents WHERE id=?", (doc_id,))
|
||||||
|
|
||||||
|
# If the person has no remaining documents, remove them too
|
||||||
|
if doc["person_id"]:
|
||||||
|
remaining = conn.execute(
|
||||||
|
"SELECT COUNT(*) AS n FROM documents WHERE person_id=?",
|
||||||
|
(doc["person_id"],),
|
||||||
|
).fetchone()["n"]
|
||||||
|
if remaining == 0:
|
||||||
|
conn.execute("DELETE FROM properties WHERE person_id=?", (doc["person_id"],))
|
||||||
|
conn.execute("DELETE FROM persons WHERE id=?", (doc["person_id"],))
|
||||||
|
|
||||||
|
# Remove the image file from disk
|
||||||
|
image_file = Path(UPLOAD_DIR) / doc["image_path"]
|
||||||
|
try:
|
||||||
|
image_file.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return JSONResponse({"ok": True})
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from config import UPLOAD_DIR
|
||||||
|
from database.connection import get_db
|
||||||
|
from services.extractor import extract_document
|
||||||
|
from services.search_service import normalize_arabic
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_document(doc_id: int) -> dict | None:
|
||||||
|
with get_db() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM documents WHERE id=?", (doc_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
doc = dict(row)
|
||||||
|
props = conn.execute(
|
||||||
|
"SELECT * FROM properties WHERE document_id=? ORDER BY row_order",
|
||||||
|
(doc_id,),
|
||||||
|
).fetchall()
|
||||||
|
doc["properties"] = [dict(p) for p in props]
|
||||||
|
|
||||||
|
if doc["person_id"]:
|
||||||
|
person = conn.execute(
|
||||||
|
"SELECT * FROM persons WHERE id=?", (doc["person_id"],)
|
||||||
|
).fetchone()
|
||||||
|
doc["person"] = dict(person) if person else {}
|
||||||
|
elif doc["raw_extraction_json"]:
|
||||||
|
try:
|
||||||
|
extracted = json.loads(doc["raw_extraction_json"])
|
||||||
|
doc["person"] = extracted.get("person", {})
|
||||||
|
except Exception:
|
||||||
|
doc["person"] = {}
|
||||||
|
else:
|
||||||
|
doc["person"] = {}
|
||||||
|
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/review/next")
|
||||||
|
async def review_next():
|
||||||
|
with get_db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM documents WHERE status='extracted' ORDER BY id LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
return RedirectResponse(f"/review/{row['id']}", status_code=302)
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
pending = conn.execute(
|
||||||
|
"SELECT COUNT(*) AS n FROM documents WHERE status='pending'"
|
||||||
|
).fetchone()["n"]
|
||||||
|
|
||||||
|
if pending:
|
||||||
|
return HTMLResponse(
|
||||||
|
"<html><body style='font-family:sans-serif;text-align:center;padding:3rem'>"
|
||||||
|
"<h2>جارٍ معالجة الوثائق...</h2>"
|
||||||
|
"<p>أعد التحميل بعد قليل أو اذهب إلى <a href='/documents'>قائمة الوثائق</a>.</p>"
|
||||||
|
"</body></html>"
|
||||||
|
)
|
||||||
|
|
||||||
|
return HTMLResponse(
|
||||||
|
"<html><body style='font-family:sans-serif;text-align:center;padding:3rem'>"
|
||||||
|
"<h2>تمت مراجعة جميع الوثائق!</h2>"
|
||||||
|
"<p><a href='/search'>البحث في قاعدة البيانات</a> | <a href='/'>رفع المزيد</a></p>"
|
||||||
|
"</body></html>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/review/{doc_id}")
|
||||||
|
async def review_document(request: Request, doc_id: int, wait: int = 0):
|
||||||
|
doc = _get_document(doc_id)
|
||||||
|
if not doc:
|
||||||
|
return HTMLResponse("Document not found", status_code=404)
|
||||||
|
|
||||||
|
if doc["status"] == "pending" and wait:
|
||||||
|
return HTMLResponse(
|
||||||
|
f"""<html><head><meta http-equiv="refresh" content="3;url=/review/{doc_id}?wait=1">
|
||||||
|
<title>Processing...</title></head>
|
||||||
|
<body style='font-family:sans-serif;text-align:center;padding:3rem'>
|
||||||
|
<h2>جارٍ قراءة الوثيقة...</h2>
|
||||||
|
<p>ستتحدث هذه الصفحة تلقائياً.</p>
|
||||||
|
<p><a href="/review/{doc_id}">تحديث الآن</a></p>
|
||||||
|
</body></html>"""
|
||||||
|
)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "review.html", {"doc": doc, "upload_dir": "/uploads"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/check-duplicate")
|
||||||
|
async def check_duplicate(
|
||||||
|
first_name: str = "",
|
||||||
|
father_name: str = "",
|
||||||
|
family_name: str = "",
|
||||||
|
):
|
||||||
|
"""Check if a person with similar name already exists. Called via AJAX from review page."""
|
||||||
|
if not first_name:
|
||||||
|
return JSONResponse({"matches": []})
|
||||||
|
|
||||||
|
first_norm = normalize_arabic(first_name.strip())
|
||||||
|
family_norm = normalize_arabic(family_name.strip()) if family_name else ""
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
if family_norm:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT p.*, COUNT(DISTINCT pr.id) AS property_count
|
||||||
|
FROM persons p
|
||||||
|
LEFT JOIN properties pr ON pr.person_id = p.id
|
||||||
|
WHERE p.first_name_norm = ? AND p.family_name_norm = ?
|
||||||
|
GROUP BY p.id""",
|
||||||
|
(first_norm, family_norm),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT p.*, COUNT(DISTINCT pr.id) AS property_count
|
||||||
|
FROM persons p
|
||||||
|
LEFT JOIN properties pr ON pr.person_id = p.id
|
||||||
|
WHERE p.first_name_norm = ?
|
||||||
|
GROUP BY p.id""",
|
||||||
|
(first_norm,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# Further filter by father_name if provided
|
||||||
|
matches = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
if father_name and d.get("father_name"):
|
||||||
|
if normalize_arabic(father_name.strip()) != normalize_arabic(d["father_name"]):
|
||||||
|
continue
|
||||||
|
matches.append({
|
||||||
|
"id": d["id"],
|
||||||
|
"first_name": d["first_name"],
|
||||||
|
"father_name": d.get("father_name"),
|
||||||
|
"family_name": d.get("family_name"),
|
||||||
|
"family_origin": d.get("family_origin"),
|
||||||
|
"property_count": d["property_count"],
|
||||||
|
})
|
||||||
|
|
||||||
|
return JSONResponse({"matches": matches})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/confirm/{doc_id}")
|
||||||
|
async def confirm_document(doc_id: int, request: Request):
|
||||||
|
body = await request.json()
|
||||||
|
|
||||||
|
person_data = body.get("person", {})
|
||||||
|
properties_data = body.get("properties", [])
|
||||||
|
merge_person_id = body.get("merge_person_id") # If user chose to merge
|
||||||
|
|
||||||
|
first_name = (person_data.get("first_name") or "").strip()
|
||||||
|
registry_number = (person_data.get("registry_number") or "").strip() or None
|
||||||
|
|
||||||
|
# We should update document fields as well since user might have edited them
|
||||||
|
request_number = (body.get("request_number") or "").strip() or None
|
||||||
|
request_date = (body.get("request_date") or "").strip() or None
|
||||||
|
page_info = (body.get("page_info") or "").strip() or None
|
||||||
|
search_scope = (body.get("search_scope") or "").strip() or None
|
||||||
|
request_purpose = (body.get("request_purpose") or "").strip() or None
|
||||||
|
data_valid_until = (body.get("data_valid_until") or "").strip() or None
|
||||||
|
registry_office = (body.get("registry_office") or "").strip() or None
|
||||||
|
owns_properties = body.get("owns_properties")
|
||||||
|
if owns_properties is not None:
|
||||||
|
owns_properties = bool(owns_properties)
|
||||||
|
declared_property_count = body.get("declared_property_count")
|
||||||
|
if declared_property_count is not None:
|
||||||
|
try:
|
||||||
|
declared_property_count = int(declared_property_count)
|
||||||
|
except ValueError:
|
||||||
|
declared_property_count = None
|
||||||
|
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
person_id = None
|
||||||
|
|
||||||
|
# Option 1: User explicitly chose to merge with an existing person
|
||||||
|
if merge_person_id:
|
||||||
|
person_id = int(merge_person_id)
|
||||||
|
# Update person info with latest data
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE persons SET
|
||||||
|
first_name=?, father_name=?, mother_name=?,
|
||||||
|
family_name=?, family_origin=?, nationality=?,
|
||||||
|
birth_date=?, registry_place=?,
|
||||||
|
registry_number=COALESCE(registry_number, ?),
|
||||||
|
first_name_norm=?, family_name_norm=?,
|
||||||
|
updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=?""",
|
||||||
|
(
|
||||||
|
first_name,
|
||||||
|
person_data.get("father_name"),
|
||||||
|
person_data.get("mother_name"),
|
||||||
|
person_data.get("family_name"),
|
||||||
|
person_data.get("family_origin"),
|
||||||
|
person_data.get("nationality"),
|
||||||
|
person_data.get("birth_date"),
|
||||||
|
person_data.get("registry_place"),
|
||||||
|
registry_number,
|
||||||
|
normalize_arabic(first_name),
|
||||||
|
normalize_arabic(person_data.get("family_name") or ""),
|
||||||
|
person_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Option 2: Auto-merge by registry_number
|
||||||
|
if not person_id and registry_number:
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM persons WHERE registry_number=?", (registry_number,)
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
person_id = existing["id"]
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE persons SET
|
||||||
|
first_name=?, father_name=?, mother_name=?,
|
||||||
|
family_name=?, family_origin=?, nationality=?,
|
||||||
|
birth_date=?, registry_place=?,
|
||||||
|
first_name_norm=?, family_name_norm=?,
|
||||||
|
updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=?""",
|
||||||
|
(
|
||||||
|
first_name,
|
||||||
|
person_data.get("father_name"),
|
||||||
|
person_data.get("mother_name"),
|
||||||
|
person_data.get("family_name"),
|
||||||
|
person_data.get("family_origin"),
|
||||||
|
person_data.get("nationality"),
|
||||||
|
person_data.get("birth_date"),
|
||||||
|
person_data.get("registry_place"),
|
||||||
|
normalize_arabic(first_name),
|
||||||
|
normalize_arabic(person_data.get("family_name") or ""),
|
||||||
|
person_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Option 3: Create new person
|
||||||
|
if not person_id:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""INSERT INTO persons
|
||||||
|
(first_name, father_name, mother_name, family_name, family_origin,
|
||||||
|
nationality, birth_date, registry_number, registry_place,
|
||||||
|
first_name_norm, family_name_norm)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
first_name,
|
||||||
|
person_data.get("father_name"),
|
||||||
|
person_data.get("mother_name"),
|
||||||
|
person_data.get("family_name"),
|
||||||
|
person_data.get("family_origin"),
|
||||||
|
person_data.get("nationality"),
|
||||||
|
person_data.get("birth_date"),
|
||||||
|
registry_number,
|
||||||
|
person_data.get("registry_place"),
|
||||||
|
normalize_arabic(first_name),
|
||||||
|
normalize_arabic(person_data.get("family_name") or ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
person_id = cursor.lastrowid
|
||||||
|
|
||||||
|
# Replace properties for this document
|
||||||
|
conn.execute("DELETE FROM properties WHERE document_id=?", (doc_id,))
|
||||||
|
for i, prop in enumerate(properties_data):
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO properties
|
||||||
|
(document_id, person_id, row_order, party_name, property_number,
|
||||||
|
section, block, real_estate_district, qaza, num_shares, ownership_type)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
doc_id, person_id, i,
|
||||||
|
prop.get("party_name"),
|
||||||
|
prop.get("property_number"),
|
||||||
|
prop.get("section"),
|
||||||
|
prop.get("block"),
|
||||||
|
prop.get("real_estate_district"),
|
||||||
|
prop.get("qaza"),
|
||||||
|
prop.get("num_shares"),
|
||||||
|
prop.get("ownership_type"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE documents SET status='confirmed', person_id=?,
|
||||||
|
request_number=?, request_date=?, page_info=?, search_scope=?,
|
||||||
|
request_purpose=?, data_valid_until=?, registry_office=?,
|
||||||
|
owns_properties=?, declared_property_count=?,
|
||||||
|
updated_at=CURRENT_TIMESTAMP WHERE id=?""",
|
||||||
|
(person_id, request_number, request_date, page_info, search_scope,
|
||||||
|
request_purpose, data_valid_until, registry_office,
|
||||||
|
owns_properties, declared_property_count, doc_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse({"ok": True, "person_id": person_id, "next": "/review/next"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/extract/{doc_id}")
|
||||||
|
async def retrigger_extraction(doc_id: int, provider: str = ""):
|
||||||
|
"""Re-run extraction for a document (retry after error)."""
|
||||||
|
with get_db() as conn:
|
||||||
|
doc = conn.execute(
|
||||||
|
"SELECT image_path, provider FROM documents WHERE id=?", (doc_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not doc:
|
||||||
|
return JSONResponse({"error": "not found"}, status_code=404)
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE documents SET status='pending', extraction_error=NULL WHERE id=?",
|
||||||
|
(doc_id,),
|
||||||
|
)
|
||||||
|
conn.execute("DELETE FROM properties WHERE document_id=?", (doc_id,))
|
||||||
|
|
||||||
|
from routers.upload import _extract_and_save
|
||||||
|
use_provider = provider or doc["provider"] or ""
|
||||||
|
asyncio.create_task(_extract_and_save(doc_id, doc["image_path"], use_provider))
|
||||||
|
return JSONResponse({"ok": True, "message": "Extraction started"})
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from services.search_service import (
|
||||||
|
get_person_with_properties,
|
||||||
|
search_persons,
|
||||||
|
search_properties,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search")
|
||||||
|
async def search(
|
||||||
|
request: Request,
|
||||||
|
q: str = "",
|
||||||
|
property_number: str = "",
|
||||||
|
district: str = "",
|
||||||
|
block: str = "",
|
||||||
|
):
|
||||||
|
persons = []
|
||||||
|
properties = []
|
||||||
|
selected_person = None
|
||||||
|
|
||||||
|
if q:
|
||||||
|
persons = search_persons(q)
|
||||||
|
|
||||||
|
if property_number or district or block:
|
||||||
|
properties = search_properties(property_number, district, block)
|
||||||
|
|
||||||
|
# If exactly one person found, preload their full details
|
||||||
|
if len(persons) == 1 and not properties:
|
||||||
|
selected_person = get_person_with_properties(persons[0]["id"])
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"search.html",
|
||||||
|
{
|
||||||
|
"q": q,
|
||||||
|
"property_number": property_number,
|
||||||
|
"district": district,
|
||||||
|
"block": block,
|
||||||
|
"persons": persons,
|
||||||
|
"properties": properties,
|
||||||
|
"selected_person": selected_person,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/persons/{person_id}")
|
||||||
|
async def person_detail(request: Request, person_id: int):
|
||||||
|
data = get_person_with_properties(person_id)
|
||||||
|
if not data:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"search.html",
|
||||||
|
{"error": "Person not found", "q": "", "persons": [], "properties": [], "selected_person": None, "property_number": "", "district": "", "block": ""},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"person_detail.html",
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/persons/{person_id}/export")
|
||||||
|
async def person_export_csv(person_id: int, qaza: str = ""):
|
||||||
|
import io
|
||||||
|
import csv
|
||||||
|
|
||||||
|
data = get_person_with_properties(person_id)
|
||||||
|
if not data:
|
||||||
|
return Response("Person not found", status_code=404)
|
||||||
|
|
||||||
|
person = data["person"]
|
||||||
|
properties = data["properties"]
|
||||||
|
|
||||||
|
if qaza:
|
||||||
|
properties = [p for p in properties if (p.get("search_scope") or p.get("qaza") or "").strip() == qaza.strip()]
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
# Write BOM for Excel to open Arabic UTF-8 correctly
|
||||||
|
output.write('\ufeff')
|
||||||
|
writer = csv.writer(output)
|
||||||
|
|
||||||
|
fullname_parts = [person.get("first_name", ""), person.get("father_name", ""), person.get("family_name", "")]
|
||||||
|
fullname = " ".join(f for f in fullname_parts if f)
|
||||||
|
|
||||||
|
writer.writerow(["بيانات الشخص"])
|
||||||
|
writer.writerow(["الاسم كامل", fullname])
|
||||||
|
writer.writerow(["رقم السجل", person.get("registry_number")])
|
||||||
|
writer.writerow(["مكان السجل", person.get("registry_place")])
|
||||||
|
writer.writerow(["تاريخ الولادة", person.get("birth_date")])
|
||||||
|
writer.writerow(["الهوا / المنشأ", person.get("family_origin")])
|
||||||
|
writer.writerow([])
|
||||||
|
|
||||||
|
# Document-level info (search scope, request numbers)
|
||||||
|
docs = data.get("documents", [])
|
||||||
|
if docs:
|
||||||
|
writer.writerow(["بيانات الوثائق"])
|
||||||
|
for d in docs:
|
||||||
|
parts = []
|
||||||
|
if d.get("request_number"):
|
||||||
|
parts.append(f"رقم الطلب: {d['request_number']}")
|
||||||
|
if d.get("request_date"):
|
||||||
|
parts.append(f"تاريخ: {d['request_date']}")
|
||||||
|
if d.get("search_scope"):
|
||||||
|
parts.append(f"القضاء: {d['search_scope']}")
|
||||||
|
if d.get("page_info"):
|
||||||
|
parts.append(f"صفحة: {d['page_info']}")
|
||||||
|
if parts:
|
||||||
|
writer.writerow(parts)
|
||||||
|
writer.writerow([])
|
||||||
|
|
||||||
|
writer.writerow(["العقارات المملوكة"])
|
||||||
|
writer.writerow(["اسم الفريق", "رقم العقار", "القسم", "البلوك", "المنطقة العقارية", "القضاء", "عدد الأسهم", "نوع الملكية"])
|
||||||
|
|
||||||
|
for p in properties:
|
||||||
|
writer.writerow([
|
||||||
|
p.get("party_name") or "",
|
||||||
|
p.get("property_number") or "",
|
||||||
|
p.get("section") or "",
|
||||||
|
p.get("block") or "",
|
||||||
|
p.get("real_estate_district") or "",
|
||||||
|
p.get("qaza") or "",
|
||||||
|
p.get("num_shares") or "",
|
||||||
|
p.get("ownership_type") or ""
|
||||||
|
])
|
||||||
|
|
||||||
|
content = output.getvalue()
|
||||||
|
|
||||||
|
# Safe filename – use RFC 5987 encoding for Arabic characters
|
||||||
|
from urllib.parse import quote
|
||||||
|
safename = fullname.replace(" ", "_") or "person"
|
||||||
|
if qaza:
|
||||||
|
safe_qaza = qaza.replace(" ", "_").replace(":", "").replace("-", "")
|
||||||
|
safename += f"_{safe_qaza}"
|
||||||
|
|
||||||
|
encoded_name = quote(f"report_{safename}.csv")
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename=report.csv; filename*=UTF-8''{encoded_name}"}
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, File, Form, Request, UploadFile
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from config import UPLOAD_DIR
|
||||||
|
from database.connection import get_db
|
||||||
|
from services.extractor import extract_document, get_available_providers, get_default_provider
|
||||||
|
from services.pdf_handler import pdf_to_images
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
ALLOWED_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp"}
|
||||||
|
ALLOWED_PDF_EXTS = {".pdf"}
|
||||||
|
ALLOWED_EXTENSIONS = ALLOWED_IMAGE_EXTS | ALLOWED_PDF_EXTS
|
||||||
|
|
||||||
|
|
||||||
|
def _save_image(file_bytes: bytes, original_name: str) -> str:
|
||||||
|
"""Save image to uploads/{date}/{uuid}_{name} and return relative path."""
|
||||||
|
today = date.today().isoformat()
|
||||||
|
dest_dir = Path(UPLOAD_DIR) / today
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
suffix = Path(original_name).suffix.lower() or ".jpg"
|
||||||
|
filename = f"{uuid.uuid4().hex}{suffix}"
|
||||||
|
dest = dest_dir / filename
|
||||||
|
dest.write_bytes(file_bytes)
|
||||||
|
return str(Path(today) / filename)
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_and_save(doc_id: int, image_path: str, provider: str = ""):
|
||||||
|
"""Background task: call extractor, parse result, update DB."""
|
||||||
|
try:
|
||||||
|
data = await extract_document(image_path, provider=provider)
|
||||||
|
raw_json = json.dumps(data, ensure_ascii=False)
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE documents SET status='extracted',
|
||||||
|
raw_extraction_json=?,
|
||||||
|
request_number=?,
|
||||||
|
request_date=?,
|
||||||
|
applicant_name_raw=?,
|
||||||
|
request_purpose=?,
|
||||||
|
data_valid_until=?,
|
||||||
|
registry_office=?,
|
||||||
|
owns_properties=?,
|
||||||
|
declared_property_count=?,
|
||||||
|
page_info=?,
|
||||||
|
search_scope=?,
|
||||||
|
updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=?""",
|
||||||
|
(
|
||||||
|
raw_json,
|
||||||
|
data.get("request_number"),
|
||||||
|
data.get("request_date"),
|
||||||
|
data.get("applicant_name_raw"),
|
||||||
|
data.get("request_purpose"),
|
||||||
|
data.get("data_valid_until"),
|
||||||
|
data.get("registry_office"),
|
||||||
|
data.get("owns_properties"),
|
||||||
|
data.get("declared_property_count"),
|
||||||
|
data.get("page_info"),
|
||||||
|
data.get("search_scope"),
|
||||||
|
doc_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for i, prop in enumerate(data.get("properties", [])):
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO properties
|
||||||
|
(document_id, row_order, party_name, property_number,
|
||||||
|
section, block, real_estate_district, qaza, num_shares, ownership_type)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
doc_id, i,
|
||||||
|
prop.get("party_name"),
|
||||||
|
prop.get("property_number"),
|
||||||
|
prop.get("section"),
|
||||||
|
prop.get("block"),
|
||||||
|
prop.get("real_estate_district"),
|
||||||
|
prop.get("qaza"),
|
||||||
|
prop.get("num_shares"),
|
||||||
|
prop.get("ownership_type"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE documents SET status='error', extraction_error=?,
|
||||||
|
updated_at=CURRENT_TIMESTAMP WHERE id=?""",
|
||||||
|
(str(e), doc_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def upload_page(request: Request):
|
||||||
|
with get_db() as conn:
|
||||||
|
stats = conn.execute(
|
||||||
|
"""SELECT
|
||||||
|
COUNT(*) AS total,
|
||||||
|
SUM(CASE WHEN status='confirmed' THEN 1 ELSE 0 END) AS confirmed,
|
||||||
|
SUM(CASE WHEN status='extracted' THEN 1 ELSE 0 END) AS pending_review,
|
||||||
|
SUM(CASE WHEN status='error' THEN 1 ELSE 0 END) AS errors
|
||||||
|
FROM documents"""
|
||||||
|
).fetchone()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "index.html", {
|
||||||
|
"stats": dict(stats) if stats else {},
|
||||||
|
"providers": get_available_providers(),
|
||||||
|
"default_provider": get_default_provider(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload")
|
||||||
|
async def upload_files(
|
||||||
|
request: Request,
|
||||||
|
files: list[UploadFile] = File(...),
|
||||||
|
provider: str = Form(""),
|
||||||
|
):
|
||||||
|
if not provider:
|
||||||
|
provider = get_default_provider()
|
||||||
|
|
||||||
|
doc_ids = []
|
||||||
|
|
||||||
|
for upload in files:
|
||||||
|
suffix = Path(upload.filename).suffix.lower()
|
||||||
|
if suffix not in ALLOWED_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_bytes = await upload.read()
|
||||||
|
|
||||||
|
if suffix in ALLOWED_PDF_EXTS:
|
||||||
|
# PDF: split into per-page images
|
||||||
|
pages = pdf_to_images(file_bytes, upload.filename)
|
||||||
|
for page_info in pages:
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""INSERT INTO documents
|
||||||
|
(image_path, status, provider, pdf_group_id, page_number)
|
||||||
|
VALUES (?, 'pending', ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
page_info["image_path"],
|
||||||
|
provider,
|
||||||
|
page_info["pdf_group_id"],
|
||||||
|
page_info["page_number"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
doc_ids.append((cursor.lastrowid, page_info["image_path"]))
|
||||||
|
else:
|
||||||
|
# Image file
|
||||||
|
rel_path = _save_image(file_bytes, upload.filename)
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"INSERT INTO documents (image_path, status, provider) VALUES (?, 'pending', ?)",
|
||||||
|
(rel_path, provider),
|
||||||
|
)
|
||||||
|
doc_ids.append((cursor.lastrowid, rel_path))
|
||||||
|
|
||||||
|
# Fire background extractions
|
||||||
|
for doc_id, rel_path in doc_ids:
|
||||||
|
asyncio.create_task(_extract_and_save(doc_id, rel_path, provider))
|
||||||
|
|
||||||
|
if len(doc_ids) == 1:
|
||||||
|
return RedirectResponse(f"/review/{doc_ids[0][0]}?wait=1", status_code=303)
|
||||||
|
return RedirectResponse("/documents?uploaded=1", status_code=303)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import sqlite3
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
salt = secrets.token_hex(16)
|
||||||
|
hashed = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt.encode('utf-8'), 100000).hex()
|
||||||
|
return f"{salt}${hashed}"
|
||||||
|
|
||||||
|
def verify_password(stored_password: str, provided_password: str) -> bool:
|
||||||
|
try:
|
||||||
|
salt, stored_hash = stored_password.split('$')
|
||||||
|
hashed = hashlib.pbkdf2_hmac('sha256', provided_password.encode('utf-8'), salt.encode('utf-8'), 100000).hex()
|
||||||
|
return hashed == stored_hash
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_user_by_username(username: str):
|
||||||
|
from database.connection import get_db
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute("SELECT * FROM users WHERE username = ?", (username,))
|
||||||
|
return cursor.fetchone()
|
||||||
|
|
||||||
|
def create_user(username: str, password: str):
|
||||||
|
from database.connection import get_db
|
||||||
|
with get_db() as conn:
|
||||||
|
hashed = hash_password(password)
|
||||||
|
conn.execute("INSERT INTO users (username, password_hash) VALUES (?, ?)", (username, hashed))
|
||||||
|
|
||||||
|
def delete_user(user_id: int):
|
||||||
|
from database.connection import get_db
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||||
|
|
||||||
|
def get_all_users():
|
||||||
|
from database.connection import get_db
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute("SELECT id, username, created_at FROM users ORDER BY created_at DESC")
|
||||||
|
return cursor.fetchall()
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from config import DATABASE_PATH
|
||||||
|
from database.connection import get_db
|
||||||
|
|
||||||
|
def create_backup() -> str:
|
||||||
|
"""Create a backup of the database to a backup file."""
|
||||||
|
db_file = DATABASE_PATH
|
||||||
|
if not os.path.exists(db_file):
|
||||||
|
raise FileNotFoundError("Database file does not exist.")
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
backup_file = f"{db_file}.{timestamp}.bak"
|
||||||
|
|
||||||
|
# Safe SQLite backup
|
||||||
|
with get_db() as conn:
|
||||||
|
bck = sqlite3.connect(backup_file)
|
||||||
|
with bck:
|
||||||
|
conn.backup(bck)
|
||||||
|
bck.close()
|
||||||
|
|
||||||
|
return backup_file
|
||||||
|
|
||||||
@@ -0,0 +1,551 @@
|
|||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
ANTHROPIC_API_KEY, GEMINI_API_KEY,
|
||||||
|
CLAUDE_MODEL, GEMINI_MODEL, DEFAULT_PROVIDER, UPLOAD_DIR,
|
||||||
|
)
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """You are an expert OCR assistant specializing in Lebanese government real estate documents written in Arabic (right-to-left). Your task is to extract structured data from scanned document images of "بطاقة معلومات عن الملكية العقارية" (Real Estate Property Information Cards) issued by the Lebanese Ministry of Real Estate Affairs.
|
||||||
|
|
||||||
|
CRITICAL INSTRUCTIONS:
|
||||||
|
1. Extract ALL text exactly as written in Arabic — do not translate or transliterate.
|
||||||
|
2. For dates, preserve the original format as printed (e.g., ٢٩-٣-٢٠٢٦ or 29/03/2026).
|
||||||
|
3. For the properties table, extract EVERY row — documents often have 30+ rows. If the document prominently states "لا يملك", set owns_properties to false and return an empty array [] for properties.
|
||||||
|
4. If a field is illegible or absent, use null.
|
||||||
|
5. Numbers may be in Arabic-Indic digits (٠١٢٣٤٥٦٧٨٩) or Western digits — preserve exactly as-is.
|
||||||
|
6. The header and footer sections contain these labeled fields:
|
||||||
|
- الاسم (first_name), اسم الأب (father_name), اسم الأم (mother_name)
|
||||||
|
- الشهرة (family_name / family_origin), الجنسية (nationality)
|
||||||
|
- تاريخ الولادة (birth_date), رقم السجل (registry_number), مكان السجل (registry_place)
|
||||||
|
- رقم الطلب (request_number), تاريخ الطلب (request_date)
|
||||||
|
- اسم المستدعي/الوكيل (applicant_name_raw)
|
||||||
|
- الغاية من الطلب (request_purpose)
|
||||||
|
- نطاق البحث contains القضاء or المحافظات (qaza/district/governorate) — extract as search_scope
|
||||||
|
- in the bottom footer, find the page fraction (e.g. "صفحة 1 من 3" or "1 / 3") and extract as page_info.
|
||||||
|
- معلومات محولة لغاية (data_valid_until) found near the bottom.
|
||||||
|
- أمانة السجل (registry_office) found at the bottom right.
|
||||||
|
- عدد العقارات: (declared_property_count) located right under the properties table.
|
||||||
|
7. The properties table has 8 columns in order from RIGHT to LEFT as they appear on the page:
|
||||||
|
col1(rightmost)=اسم الفريق, col2=رقم العقار, col3=القسم, col4=البلوك,
|
||||||
|
col5=المنطقة العقارية, col6=القضاء, col7=عدد الأسهم, col8(leftmost)=نوع الملكية
|
||||||
|
Map these to JSON keys: party_name, property_number, section, block, real_estate_district, qaza, num_shares, ownership_type
|
||||||
|
8. Return ONLY valid JSON matching the schema. No markdown, no explanation."""
|
||||||
|
|
||||||
|
USER_PROMPT = (
|
||||||
|
"Extract all data from this Lebanese real estate property card. "
|
||||||
|
"Return a single JSON object with these keys: "
|
||||||
|
"request_number, request_date, applicant_name_raw, request_purpose, data_valid_until, "
|
||||||
|
"registry_office, page_info, search_scope, owns_properties, declared_property_count, "
|
||||||
|
"person (object with: first_name, father_name, mother_name, family_name, "
|
||||||
|
"family_origin, nationality, birth_date, registry_number, registry_place), "
|
||||||
|
"properties (array of objects with: party_name, property_number, section, block, "
|
||||||
|
"real_estate_district, qaza, num_shares, ownership_type), "
|
||||||
|
"extraction_notes. "
|
||||||
|
"Use null for missing fields. Include every property row from the table (or empty array if none)."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _resolve_path(image_path: str) -> str:
|
||||||
|
if Path(image_path).is_absolute():
|
||||||
|
return image_path
|
||||||
|
return str(Path(UPLOAD_DIR) / image_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_image(image_path: str) -> tuple[str, str]:
|
||||||
|
path = Path(image_path)
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
media_type_map = {
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".gif": "image/gif",
|
||||||
|
}
|
||||||
|
media_type = media_type_map.get(suffix, "image/jpeg")
|
||||||
|
with open(image_path, "rb") as f:
|
||||||
|
data = base64.standard_b64encode(f.read()).decode("utf-8")
|
||||||
|
return data, media_type
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_code_fences(text: str) -> str:
|
||||||
|
text = text.strip()
|
||||||
|
if text.startswith("```"):
|
||||||
|
lines = text.split("\n")
|
||||||
|
text = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_providers() -> list[dict]:
|
||||||
|
"""Return list of all available providers (EasyOCR is always available)."""
|
||||||
|
providers = [
|
||||||
|
{"id": "easyocr", "name": "EasyOCR (مجاني)", "model": "local"},
|
||||||
|
]
|
||||||
|
if ANTHROPIC_API_KEY:
|
||||||
|
providers.append({"id": "claude", "name": "Claude (Anthropic)", "model": CLAUDE_MODEL})
|
||||||
|
if GEMINI_API_KEY:
|
||||||
|
providers.append({"id": "gemini", "name": "Gemini (Google)", "model": GEMINI_MODEL})
|
||||||
|
return providers
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_provider() -> str:
|
||||||
|
"""Return the default provider, falling back to whichever is available."""
|
||||||
|
providers = get_available_providers()
|
||||||
|
if not providers:
|
||||||
|
return "easyocr"
|
||||||
|
ids = [p["id"] for p in providers]
|
||||||
|
if DEFAULT_PROVIDER in ids:
|
||||||
|
return DEFAULT_PROVIDER
|
||||||
|
return ids[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Claude extraction ───────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _extract_with_claude(image_path: str) -> dict:
|
||||||
|
import anthropic
|
||||||
|
|
||||||
|
client = anthropic.AsyncAnthropic(api_key=ANTHROPIC_API_KEY)
|
||||||
|
full_path = _resolve_path(image_path)
|
||||||
|
img_data, media_type = _encode_image(full_path)
|
||||||
|
|
||||||
|
message = await client.messages.create(
|
||||||
|
model=CLAUDE_MODEL,
|
||||||
|
max_tokens=4096,
|
||||||
|
system=[
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": SYSTEM_PROMPT,
|
||||||
|
"cache_control": {"type": "ephemeral"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
messages=[
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"source": {
|
||||||
|
"type": "base64",
|
||||||
|
"media_type": media_type,
|
||||||
|
"data": img_data,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{"type": "text", "text": USER_PROMPT},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_text = _strip_code_fences(message.content[0].text)
|
||||||
|
return json.loads(raw_text)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Gemini extraction ───────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _extract_with_gemini(image_path: str) -> dict:
|
||||||
|
import asyncio
|
||||||
|
from google import genai
|
||||||
|
from google.genai import types
|
||||||
|
|
||||||
|
client = genai.Client(api_key=GEMINI_API_KEY)
|
||||||
|
full_path = _resolve_path(image_path)
|
||||||
|
|
||||||
|
with open(full_path, "rb") as f:
|
||||||
|
image_bytes = f.read()
|
||||||
|
|
||||||
|
suffix = Path(full_path).suffix.lower()
|
||||||
|
mime_map = {".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png", ".webp": "image/webp"}
|
||||||
|
mime_type = mime_map.get(suffix, "image/jpeg")
|
||||||
|
|
||||||
|
# Try primary model first; only fall back on 503 (overloaded), NOT on 429 (quota)
|
||||||
|
models_to_try = [GEMINI_MODEL, "gemini-2.0-flash-lite"]
|
||||||
|
|
||||||
|
def _call(model_name: str):
|
||||||
|
response = client.models.generate_content(
|
||||||
|
model=model_name,
|
||||||
|
contents=[
|
||||||
|
types.Content(parts=[
|
||||||
|
types.Part(text=SYSTEM_PROMPT + "\n\n" + USER_PROMPT),
|
||||||
|
types.Part(inline_data=types.Blob(mime_type=mime_type, data=image_bytes)),
|
||||||
|
])
|
||||||
|
],
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
temperature=0.1,
|
||||||
|
max_output_tokens=8192,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return response.text
|
||||||
|
|
||||||
|
last_error = None
|
||||||
|
for model_name in models_to_try:
|
||||||
|
for attempt in range(2):
|
||||||
|
try:
|
||||||
|
raw_text = await asyncio.get_event_loop().run_in_executor(None, lambda m=model_name: _call(m))
|
||||||
|
raw_text = _strip_code_fences(raw_text)
|
||||||
|
return json.loads(raw_text)
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
err_str = str(e)
|
||||||
|
# On quota exhaustion, skip to next model immediately (don't waste retries)
|
||||||
|
if "429" in err_str or "RESOURCE_EXHAUSTED" in err_str:
|
||||||
|
break
|
||||||
|
# On temporary overload, wait and retry same model
|
||||||
|
if "503" in err_str or "UNAVAILABLE" in err_str:
|
||||||
|
await asyncio.sleep(3 * (attempt + 1))
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
raise last_error
|
||||||
|
|
||||||
|
|
||||||
|
# ─── EasyOCR extraction (free, local) ────────────────────────────
|
||||||
|
|
||||||
|
# Cache EasyOCR reader (heavy to initialize)
|
||||||
|
_easyocr_reader = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_easyocr_reader():
|
||||||
|
global _easyocr_reader
|
||||||
|
if _easyocr_reader is None:
|
||||||
|
import easyocr
|
||||||
|
_easyocr_reader = easyocr.Reader(["ar", "en"], gpu=False)
|
||||||
|
return _easyocr_reader
|
||||||
|
|
||||||
|
|
||||||
|
# Known label strings for header fields
|
||||||
|
_LABEL_MAP = {
|
||||||
|
"الاسم": "first_name",
|
||||||
|
"اسم الأب": "father_name",
|
||||||
|
"اسم الأم": "mother_name",
|
||||||
|
"الشهرة": "family_origin",
|
||||||
|
"الجنسية": "nationality",
|
||||||
|
"تاريخ الولادة": "birth_date",
|
||||||
|
"رقم السجل": "registry_number",
|
||||||
|
"مكان السجل": "registry_place",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Table header labels that mark the start of the properties table
|
||||||
|
_TABLE_HEADERS = {"اسم الفريق", "رقم العقار", "القسم", "البلوك",
|
||||||
|
"المنطقة العقارية", "القضاء", "عدد الأسهم", "نوع الملكية"}
|
||||||
|
|
||||||
|
# Column-header text → JSON key (right→left order on page)
|
||||||
|
_COL_KEYS = [
|
||||||
|
("اسم الفريق", "party_name"),
|
||||||
|
("رقم العقار", "property_number"),
|
||||||
|
("القسم", "section"),
|
||||||
|
("البلوك", "block"),
|
||||||
|
("المنطقة العقارية", "real_estate_district"),
|
||||||
|
("القضاء", "qaza"),
|
||||||
|
("عدد الأسهم", "num_shares"),
|
||||||
|
("نوع الملكية", "ownership_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_easyocr_results(results: list) -> dict:
|
||||||
|
"""
|
||||||
|
Parse EasyOCR bounding-box results into structured document data.
|
||||||
|
Each result is (bbox, text, confidence).
|
||||||
|
bbox = [[x1,y1],[x2,y2],[x3,y3],[x4,y4]]
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
1. Build a list of items with (text, x_center, y_center, x_left, x_right).
|
||||||
|
2. Detect the table-header row → derive column X-boundary ranges.
|
||||||
|
3. Header section (above table): find label items, then take the nearest
|
||||||
|
right-adjacent item as the value (RTL: label is in the centre/left,
|
||||||
|
value is to the right of it on the same row).
|
||||||
|
4. Table body rows: assign each cell to a column by matching its X-center
|
||||||
|
to the column boundary derived from the header row.
|
||||||
|
"""
|
||||||
|
if not results:
|
||||||
|
return _empty_result("No text detected")
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for bbox, text, conf in results:
|
||||||
|
xs = [pt[0] for pt in bbox]
|
||||||
|
ys = [pt[1] for pt in bbox]
|
||||||
|
items.append({
|
||||||
|
"text": text.strip(),
|
||||||
|
"x": (min(xs) + max(xs)) / 2,
|
||||||
|
"y": (min(ys) + max(ys)) / 2,
|
||||||
|
"x_left": min(xs),
|
||||||
|
"x_right": max(xs),
|
||||||
|
})
|
||||||
|
|
||||||
|
items.sort(key=lambda t: t["y"])
|
||||||
|
|
||||||
|
# ── 1. Locate the table header row ──────────────────────────────────────
|
||||||
|
header_row_items = []
|
||||||
|
header_y = None
|
||||||
|
|
||||||
|
# Simple heuristic to find where the table starts:
|
||||||
|
# First, locate the word "العقارات" or "المملوكة" or "نطاق البحث"
|
||||||
|
for item in items:
|
||||||
|
if "العقارات" in item["text"] or "المملوكة" in item["text"]:
|
||||||
|
header_y = item["y"]
|
||||||
|
break
|
||||||
|
|
||||||
|
if header_y is None:
|
||||||
|
for item in items:
|
||||||
|
if item["text"] in _TABLE_HEADERS:
|
||||||
|
header_y = item["y"]
|
||||||
|
break
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
if header_y is not None and 0 < item["y"] - header_y < 80:
|
||||||
|
# The table headers are right below "العقارات المملوكة"
|
||||||
|
# Let's collect anything that looks like a header column label
|
||||||
|
if any(h in item["text"] for h in ["العقار", "الفريق", "القسم", "البلوك", "المنطقة", "قضاء", "أسهم", "نوع"]):
|
||||||
|
header_row_items.append(item)
|
||||||
|
|
||||||
|
if header_row_items:
|
||||||
|
# Sort header cells right→left (descending x)
|
||||||
|
header_row_items.sort(key=lambda t: -t["x"])
|
||||||
|
|
||||||
|
# ── 2. Build column X-boundaries from header row ─────────────────────────
|
||||||
|
col_boundaries = [] # list of (x_left, x_right, json_key)
|
||||||
|
|
||||||
|
# Define a fuzzy matching logic or regex to assign headers
|
||||||
|
key_keywords = {
|
||||||
|
"party_name": ["فريق"],
|
||||||
|
"property_number": ["رقم", "عقار"],
|
||||||
|
"section": ["قسم"],
|
||||||
|
"block": ["بلوك"],
|
||||||
|
"real_estate_district": ["منطقة", "عقارية"],
|
||||||
|
"qaza": ["قضاء"],
|
||||||
|
"num_shares": ["أسهم", "سهم"],
|
||||||
|
"ownership_type": ["نوع", "ملكية"],
|
||||||
|
}
|
||||||
|
|
||||||
|
if header_row_items:
|
||||||
|
for it in header_row_items:
|
||||||
|
assigned_key = None
|
||||||
|
for key, keywords in key_keywords.items():
|
||||||
|
if any(kw in it["text"] for kw in keywords):
|
||||||
|
assigned_key = key
|
||||||
|
break
|
||||||
|
if assigned_key:
|
||||||
|
# Add large horizontal padding to ensure columns grab cells correctly
|
||||||
|
col_boundaries.append((it["x_left"] - 40, it["x_right"] + 40, assigned_key))
|
||||||
|
|
||||||
|
table_start_y = header_row_items[0]["y"] if header_row_items else (header_y if header_y is not None else 600)
|
||||||
|
|
||||||
|
# ── 3. Extract header/person fields (above the table) ────────────────────
|
||||||
|
header_items = [it for it in items if it["y"] < (header_y if header_y is not None else table_start_y) - 5]
|
||||||
|
|
||||||
|
# Group header items into rows
|
||||||
|
header_rows: list[list[dict]] = []
|
||||||
|
if header_items:
|
||||||
|
current = [header_items[0]]
|
||||||
|
for it in header_items[1:]:
|
||||||
|
if abs(it["y"] - current[0]["y"]) < 15:
|
||||||
|
current.append(it)
|
||||||
|
else:
|
||||||
|
header_rows.append(sorted(current, key=lambda t: -t["x"])) # right→left
|
||||||
|
current = [it]
|
||||||
|
header_rows.append(sorted(current, key=lambda t: -t["x"]))
|
||||||
|
|
||||||
|
person: dict = {}
|
||||||
|
request_number = None
|
||||||
|
request_date = None
|
||||||
|
applicant_name = None
|
||||||
|
|
||||||
|
for row in header_rows:
|
||||||
|
# In RTL layout the label is printed first (rightmost), value follows to its left.
|
||||||
|
# We scan each item; if it matches a known label we take the next item as the value.
|
||||||
|
i = 0
|
||||||
|
while i < len(row):
|
||||||
|
txt = row[i]["text"]
|
||||||
|
|
||||||
|
# Strip trailing colon if present
|
||||||
|
clean = txt.rstrip(":").strip()
|
||||||
|
|
||||||
|
if clean in _LABEL_MAP and i + 1 < len(row):
|
||||||
|
value = row[i + 1]["text"].rstrip(":").strip()
|
||||||
|
# Skip if the value looks like another label
|
||||||
|
if value not in _LABEL_MAP:
|
||||||
|
person[_LABEL_MAP[clean]] = value
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
|
||||||
|
# رقم الطلب
|
||||||
|
if "رقم الطلب" in txt and i + 1 < len(row):
|
||||||
|
request_number = row[i + 1]["text"].strip()
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
# تاريخ الطلب
|
||||||
|
if "تاريخ الطلب" in txt and i + 1 < len(row):
|
||||||
|
request_date = row[i + 1]["text"].strip()
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
# اسم المستدعي / الوكيل
|
||||||
|
if "المستدعي" in txt or "الوكيل" in txt:
|
||||||
|
if i + 1 < len(row):
|
||||||
|
applicant_name = row[i + 1]["text"].strip()
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Also try full-text regex as fallback for request fields
|
||||||
|
full_text = " ".join(it["text"] for it in items)
|
||||||
|
if not request_number:
|
||||||
|
m = re.search(r"رقم الطلب[:\s]+([\d٠-٩]+)", full_text)
|
||||||
|
if m:
|
||||||
|
request_number = m.group(1)
|
||||||
|
if not request_date:
|
||||||
|
m = re.search(r"تاريخ الطلب[:\s]+([\d٠-٩\-/]+)", full_text)
|
||||||
|
if m:
|
||||||
|
request_date = m.group(1)
|
||||||
|
|
||||||
|
page_info = None
|
||||||
|
m_page = re.search(r"(صفحة\s*[\d٠-٩]+\s*من\s*[\d٠-٩]+|[\d٠-٩]+\s*/\s*[\d٠-٩]+)", full_text)
|
||||||
|
if m_page:
|
||||||
|
page_info = m_page.group(1).strip()
|
||||||
|
|
||||||
|
# Extract search scope (نطاق البحث / القضاء)
|
||||||
|
search_scope = None
|
||||||
|
for row in header_rows:
|
||||||
|
for j, item in enumerate(row):
|
||||||
|
if "القضاء" in item["text"] or "نطاق" in item["text"]:
|
||||||
|
# Value is the next item in the row
|
||||||
|
if j + 1 < len(row):
|
||||||
|
val = row[j + 1]["text"].rstrip(":").strip()
|
||||||
|
if val and "نطاق" not in val and "القضاء" not in val:
|
||||||
|
search_scope = val
|
||||||
|
break
|
||||||
|
if search_scope:
|
||||||
|
break
|
||||||
|
# Regex fallback
|
||||||
|
if not search_scope:
|
||||||
|
m_scope = re.search(r"القضاء[:\s]+([^\d٠-٩\-/]+?)(?:\s|$)", full_text)
|
||||||
|
if m_scope:
|
||||||
|
search_scope = m_scope.group(1).strip().rstrip(":")
|
||||||
|
|
||||||
|
# ── 4. Extract table body rows ────────────────────────────────────────────
|
||||||
|
table_items = [it for it in items if it["y"] > table_start_y + 15]
|
||||||
|
properties = _group_into_table_rows(table_items, col_boundaries)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"request_number": request_number,
|
||||||
|
"request_date": request_date,
|
||||||
|
"applicant_name_raw": applicant_name,
|
||||||
|
"page_info": page_info,
|
||||||
|
"search_scope": search_scope,
|
||||||
|
"person": person if person else {"first_name": None},
|
||||||
|
"properties": properties,
|
||||||
|
"extraction_notes": "Extracted with EasyOCR (local). Please review carefully.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _group_into_table_rows(items: list[dict], col_boundaries: list) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Group text items into table rows by Y-proximity, then assign each cell to
|
||||||
|
the correct column using col_boundaries (list of (x_left, x_right, key)).
|
||||||
|
Falls back to positional ordering when no boundaries are available.
|
||||||
|
"""
|
||||||
|
if not items:
|
||||||
|
return []
|
||||||
|
|
||||||
|
items_sorted = sorted(items, key=lambda t: t["y"])
|
||||||
|
rows: list[list[dict]] = []
|
||||||
|
current_row = [items_sorted[0]]
|
||||||
|
|
||||||
|
for item in items_sorted[1:]:
|
||||||
|
if abs(item["y"] - current_row[0]["y"]) < 14:
|
||||||
|
current_row.append(item)
|
||||||
|
else:
|
||||||
|
rows.append(current_row)
|
||||||
|
current_row = [item]
|
||||||
|
if current_row:
|
||||||
|
rows.append(current_row)
|
||||||
|
|
||||||
|
properties = []
|
||||||
|
key_order = [k for _, k in _COL_KEYS]
|
||||||
|
|
||||||
|
for row_items in rows:
|
||||||
|
texts = [c["text"] for c in row_items]
|
||||||
|
joined = " ".join(texts)
|
||||||
|
|
||||||
|
# Skip header row and single-word rows
|
||||||
|
if any(h in joined for h in _TABLE_HEADERS):
|
||||||
|
continue
|
||||||
|
if len(row_items) < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if col_boundaries:
|
||||||
|
# Assign each cell to a column by X-overlap with column boundaries
|
||||||
|
prop: dict = {k: None for _, k in _COL_KEYS}
|
||||||
|
for cell in row_items:
|
||||||
|
best_key = None
|
||||||
|
best_overlap = 0
|
||||||
|
for x_left, x_right, key in col_boundaries:
|
||||||
|
# Compute horizontal overlap between cell and column boundary
|
||||||
|
overlap = min(cell["x_right"], x_right) - max(cell["x_left"], x_left)
|
||||||
|
if overlap > best_overlap:
|
||||||
|
best_overlap = overlap
|
||||||
|
best_key = key
|
||||||
|
if best_key and best_overlap > 0:
|
||||||
|
# Concatenate if multiple cells map to same column
|
||||||
|
existing = prop.get(best_key)
|
||||||
|
prop[best_key] = (existing + " " + cell["text"]) if existing else cell["text"]
|
||||||
|
else:
|
||||||
|
# Fallback: positional right→left assignment
|
||||||
|
cells = sorted(row_items, key=lambda t: -t["x"])
|
||||||
|
prop = {key_order[i]: cells[i]["text"] if i < len(cells) else None
|
||||||
|
for i in range(len(key_order))}
|
||||||
|
|
||||||
|
properties.append(prop)
|
||||||
|
|
||||||
|
return properties
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_result(note: str) -> dict:
|
||||||
|
return {
|
||||||
|
"request_number": None,
|
||||||
|
"request_date": None,
|
||||||
|
"applicant_name_raw": None,
|
||||||
|
"page_info": None,
|
||||||
|
"search_scope": None,
|
||||||
|
"person": {"first_name": None},
|
||||||
|
"properties": [],
|
||||||
|
"extraction_notes": note,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_with_easyocr(image_path: str) -> dict:
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
full_path = _resolve_path(image_path)
|
||||||
|
|
||||||
|
def _call():
|
||||||
|
reader = _get_easyocr_reader()
|
||||||
|
results = reader.readtext(full_path)
|
||||||
|
return _parse_easyocr_results(results)
|
||||||
|
|
||||||
|
return await asyncio.get_event_loop().run_in_executor(None, _call)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Public API ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def extract_document(image_path: str, provider: str = "") -> dict:
|
||||||
|
"""
|
||||||
|
Extract structured data from a document image using the specified provider.
|
||||||
|
Falls back to the default provider if none specified.
|
||||||
|
"""
|
||||||
|
if not provider:
|
||||||
|
provider = get_default_provider()
|
||||||
|
|
||||||
|
if provider == "claude":
|
||||||
|
if not ANTHROPIC_API_KEY:
|
||||||
|
raise ValueError("ANTHROPIC_API_KEY not set")
|
||||||
|
return await _extract_with_claude(image_path)
|
||||||
|
elif provider == "gemini":
|
||||||
|
if not GEMINI_API_KEY:
|
||||||
|
raise ValueError("GEMINI_API_KEY not set")
|
||||||
|
return await _extract_with_gemini(image_path)
|
||||||
|
elif provider == "easyocr":
|
||||||
|
return await _extract_with_easyocr(image_path)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown provider: {provider}")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Convert PDF files to per-page images using PyMuPDF (no poppler dependency)."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from config import UPLOAD_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def pdf_to_images(pdf_bytes: bytes, original_name: str) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Convert a PDF to individual page images.
|
||||||
|
Returns list of dicts: [{"image_path": "relative/path.png", "page_number": 1}, ...]
|
||||||
|
"""
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
|
||||||
|
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
||||||
|
group_id = uuid.uuid4().hex
|
||||||
|
today = date.today().isoformat()
|
||||||
|
dest_dir = Path(UPLOAD_DIR) / today
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
pages = []
|
||||||
|
for page_num in range(len(doc)):
|
||||||
|
page = doc[page_num]
|
||||||
|
# Render at 200 DPI for good OCR quality
|
||||||
|
pix = page.get_pixmap(dpi=200)
|
||||||
|
|
||||||
|
filename = f"{group_id}_p{page_num + 1}.png"
|
||||||
|
dest = dest_dir / filename
|
||||||
|
pix.save(str(dest))
|
||||||
|
|
||||||
|
pages.append({
|
||||||
|
"image_path": str(Path(today) / filename),
|
||||||
|
"page_number": page_num + 1,
|
||||||
|
"pdf_group_id": group_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
doc.close()
|
||||||
|
return pages
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
from database.connection import get_db
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_arabic(text: str) -> str:
|
||||||
|
"""Normalize Arabic text for search: collapse alef variants, ta marbuta, alef maqsura."""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
for ch in "أإآ":
|
||||||
|
text = text.replace(ch, "ا")
|
||||||
|
text = text.replace("ة", "ه")
|
||||||
|
text = text.replace("ى", "ي")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def search_persons(query: str) -> list[dict]:
|
||||||
|
"""Search persons by name (first, family, or father name)."""
|
||||||
|
norm = normalize_arabic(query.strip())
|
||||||
|
pattern = f"%{norm}%"
|
||||||
|
raw_pattern = f"%{query.strip()}%"
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT p.*,
|
||||||
|
COUNT(DISTINCT pr.id) AS property_count,
|
||||||
|
COUNT(DISTINCT d.id) AS document_count,
|
||||||
|
GROUP_CONCAT(DISTINCT d.search_scope) AS search_scopes
|
||||||
|
FROM persons p
|
||||||
|
LEFT JOIN properties pr ON pr.person_id = p.id
|
||||||
|
LEFT JOIN documents d ON d.person_id = p.id
|
||||||
|
WHERE p.first_name_norm LIKE ?
|
||||||
|
OR p.family_name_norm LIKE ?
|
||||||
|
OR p.father_name LIKE ?
|
||||||
|
OR p.first_name LIKE ?
|
||||||
|
OR p.family_name LIKE ?
|
||||||
|
GROUP BY p.id
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN p.first_name_norm = ? THEN 0
|
||||||
|
WHEN p.family_name_norm = ? THEN 0
|
||||||
|
ELSE 1 END,
|
||||||
|
p.first_name
|
||||||
|
""",
|
||||||
|
(pattern, pattern, raw_pattern, raw_pattern, raw_pattern, norm, norm),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def search_properties(
|
||||||
|
property_number: str = "",
|
||||||
|
district: str = "",
|
||||||
|
block: str = "",
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Search properties by number, district, or block."""
|
||||||
|
conditions = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if property_number:
|
||||||
|
conditions.append("pr.property_number LIKE ?")
|
||||||
|
params.append(f"{property_number}%")
|
||||||
|
|
||||||
|
if district:
|
||||||
|
conditions.append("pr.real_estate_district LIKE ?")
|
||||||
|
params.append(f"%{district}%")
|
||||||
|
|
||||||
|
if block:
|
||||||
|
conditions.append("pr.block LIKE ?")
|
||||||
|
params.append(f"%{block}%")
|
||||||
|
|
||||||
|
if not conditions:
|
||||||
|
return []
|
||||||
|
|
||||||
|
where = " AND ".join(conditions)
|
||||||
|
with get_db() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT pr.*,
|
||||||
|
p.first_name, p.father_name, p.family_name, p.family_origin,
|
||||||
|
d.search_scope
|
||||||
|
FROM properties pr
|
||||||
|
LEFT JOIN persons p ON p.id = pr.person_id
|
||||||
|
LEFT JOIN documents d ON d.id = pr.document_id
|
||||||
|
WHERE {where}
|
||||||
|
ORDER BY pr.real_estate_district, pr.property_number
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for r in rows:
|
||||||
|
row_dict = dict(r)
|
||||||
|
if not row_dict.get("qaza") and row_dict.get("search_scope"):
|
||||||
|
row_dict["qaza"] = row_dict["search_scope"]
|
||||||
|
results.append(row_dict)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def get_person_with_properties(person_id: int) -> dict | None:
|
||||||
|
"""Fetch a person and all their properties."""
|
||||||
|
with get_db() as conn:
|
||||||
|
person = conn.execute(
|
||||||
|
"SELECT * FROM persons WHERE id = ?", (person_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not person:
|
||||||
|
return None
|
||||||
|
|
||||||
|
props = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT pr.*, d.image_path, d.id AS document_id, d.search_scope
|
||||||
|
FROM properties pr
|
||||||
|
LEFT JOIN documents d ON d.id = pr.document_id
|
||||||
|
WHERE pr.person_id = ?
|
||||||
|
ORDER BY pr.real_estate_district, pr.row_order
|
||||||
|
""",
|
||||||
|
(person_id,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
docs = conn.execute(
|
||||||
|
"SELECT id, image_path, request_number, request_date, status, page_info, search_scope FROM documents WHERE person_id = ?",
|
||||||
|
(person_id,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
properties_list = []
|
||||||
|
doc_ids_with_props = set()
|
||||||
|
for p in props:
|
||||||
|
p_dict = dict(p)
|
||||||
|
if not p_dict.get("qaza") and p_dict.get("search_scope"):
|
||||||
|
p_dict["qaza"] = p_dict["search_scope"]
|
||||||
|
properties_list.append(p_dict)
|
||||||
|
doc_ids_with_props.add(p_dict.get("document_id"))
|
||||||
|
|
||||||
|
# Add a "no properties" dummy row for documents that yielded no properties
|
||||||
|
# so the search request is still recorded in the properties table
|
||||||
|
docs_list = [dict(d) for d in docs]
|
||||||
|
for d in docs_list:
|
||||||
|
if d["id"] not in doc_ids_with_props:
|
||||||
|
properties_list.append({
|
||||||
|
"document_id": d["id"],
|
||||||
|
"party_name": "-",
|
||||||
|
"property_number": "-",
|
||||||
|
"section": "-",
|
||||||
|
"block": "-",
|
||||||
|
"real_estate_district": "-",
|
||||||
|
"qaza": d.get("search_scope") or "غير محدد",
|
||||||
|
"num_shares": "-",
|
||||||
|
"ownership_type": "لا يملك"
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"person": dict(person),
|
||||||
|
"properties": properties_list,
|
||||||
|
"documents": docs_list,
|
||||||
|
}
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
/* ============================
|
||||||
|
Base & Reset
|
||||||
|
============================ */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary: #1a56db;
|
||||||
|
--primary-dark: #1145b8;
|
||||||
|
--success: #057a55;
|
||||||
|
--warning: #c27803;
|
||||||
|
--danger: #c81e1e;
|
||||||
|
--bg: #f9fafb;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--text: #111827;
|
||||||
|
--text-muted: #6b7280;
|
||||||
|
--radius: 8px;
|
||||||
|
--shadow: 0 1px 3px rgba(0,0,0,.1), 0 1px 2px rgba(0,0,0,.06);
|
||||||
|
--shadow-md: 0 4px 6px rgba(0,0,0,.07), 0 2px 4px rgba(0,0,0,.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
html { font-size: 15px; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', 'Tahoma', 'Arial', sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.6;
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--primary); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
Navbar
|
||||||
|
============================ */
|
||||||
|
.navbar {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
padding: .75rem 1.5rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
.nav-container {
|
||||||
|
max-width: 1300px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
.nav-brand {
|
||||||
|
color: white;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.nav-brand:hover { text-decoration: none; opacity: .9; }
|
||||||
|
.nav-links { display: flex; gap: 1rem; }
|
||||||
|
.nav-link { color: rgba(255,255,255,.88); font-size: .9rem; padding: .25rem .5rem; border-radius: 4px; }
|
||||||
|
.nav-link:hover { color: white; background: rgba(255,255,255,.15); text-decoration: none; }
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
Layout
|
||||||
|
============================ */
|
||||||
|
.main-content {
|
||||||
|
max-width: 1300px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
text-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.page-header h1 { font-size: 1.5rem; color: var(--text); }
|
||||||
|
.subtitle { color: var(--text-muted); margin-top: .25rem; font-size: .95rem; }
|
||||||
|
.back-link { display: block; font-size: .875rem; color: var(--text-muted); margin-bottom: .35rem; }
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
Buttons
|
||||||
|
============================ */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-size: .9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s, opacity .15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.btn:hover { text-decoration: none; opacity: .9; }
|
||||||
|
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.btn-primary { background: var(--primary); color: white; }
|
||||||
|
.btn-secondary { background: white; color: var(--text); border-color: var(--border); }
|
||||||
|
.btn-success { background: var(--success); color: white; }
|
||||||
|
.btn-warning { background: var(--warning); color: white; }
|
||||||
|
.btn-danger { background: var(--danger); color: white; }
|
||||||
|
.btn-large { padding: .7rem 1.5rem; font-size: 1rem; }
|
||||||
|
.btn-sm { padding: .3rem .65rem; font-size: .8rem; }
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
Stats Bar
|
||||||
|
============================ */
|
||||||
|
.stats-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: .75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: .6rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 90px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
cursor: default;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
a.stat:hover { border-color: var(--primary); text-decoration: none; }
|
||||||
|
a.stat.active { border-color: var(--primary); background: #eff6ff; }
|
||||||
|
.stat-num { font-size: 1.4rem; font-weight: 700; line-height: 1; }
|
||||||
|
.stat-label { font-size: .75rem; color: var(--text-muted); margin-top: .15rem; }
|
||||||
|
.stat.confirmed .stat-num { color: var(--success); }
|
||||||
|
.stat.pending .stat-num { color: var(--warning); }
|
||||||
|
.stat.error .stat-num { color: var(--danger); }
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
Alerts / Banners
|
||||||
|
============================ */
|
||||||
|
.alert-banner {
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.alert-banner a { color: var(--primary); }
|
||||||
|
.error-banner {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--danger);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
.success-banner {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
Upload Card
|
||||||
|
============================ */
|
||||||
|
.upload-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.drop-zone {
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 2.5rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color .2s, background .2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.drop-zone:hover, .drop-zone.drag-over {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
.drop-icon { font-size: 2.5rem; margin-bottom: .5rem; }
|
||||||
|
.drop-text { font-size: 1rem; font-weight: 500; margin-bottom: .3rem; }
|
||||||
|
.drop-hint { font-size: .85rem; color: var(--text-muted); }
|
||||||
|
.file-input { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
.file-list { margin-top: 1rem; display: flex; flex-direction: column; gap: .35rem; }
|
||||||
|
.file-item {
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: .4rem .75rem;
|
||||||
|
font-size: .875rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.file-size { color: var(--text-muted); }
|
||||||
|
.upload-actions { display: flex; gap: .75rem; margin-top: 1rem; }
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
/* Provider selector */
|
||||||
|
.provider-section { margin-top: 1rem; }
|
||||||
|
.provider-label { font-weight: 600; font-size: .9rem; display: block; margin-bottom: .5rem; }
|
||||||
|
.provider-options { display: flex; gap: .5rem; flex-wrap: wrap; }
|
||||||
|
.provider-option { cursor: pointer; }
|
||||||
|
.provider-option input { display: none; }
|
||||||
|
.provider-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
transition: border-color .15s, background .15s;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
.provider-option input:checked + .provider-card { border-color: var(--primary); background: #eff6ff; }
|
||||||
|
.provider-option:hover .provider-card { border-color: var(--primary); }
|
||||||
|
.provider-name { font-size: .9rem; font-weight: 600; }
|
||||||
|
.provider-model { font-size: .75rem; color: var(--text-muted); }
|
||||||
|
.provider-free .provider-name::after { content: ' ✓'; color: var(--success); }
|
||||||
|
|
||||||
|
/* Merge suggestion */
|
||||||
|
.merge-banner {
|
||||||
|
background: #fef3c7;
|
||||||
|
border: 1px solid #fcd34d;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
margin-top: .75rem;
|
||||||
|
}
|
||||||
|
.merge-banner h4 { font-size: .9rem; margin-bottom: .5rem; color: #92400e; }
|
||||||
|
.merge-person {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #fcd34d;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .75rem;
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
.merge-person .mp-name { font-weight: 600; }
|
||||||
|
.merge-person .mp-count { color: var(--text-muted); font-size: .8rem; }
|
||||||
|
.merge-actions { display: flex; gap: .5rem; margin-top: .5rem; }
|
||||||
|
.merge-actions label { cursor: pointer; font-size: .85rem; display: flex; align-items: center; gap: .3rem; }
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
Quick Links
|
||||||
|
============================ */
|
||||||
|
.quick-links {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: .5rem;
|
||||||
|
}
|
||||||
|
.quick-link-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: box-shadow .15s, border-color .15s;
|
||||||
|
}
|
||||||
|
.quick-link-card:hover { border-color: var(--primary); box-shadow: var(--shadow-md); text-decoration: none; }
|
||||||
|
.ql-icon { font-size: 1.4rem; }
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
Forms
|
||||||
|
============================ */
|
||||||
|
.form-section {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.form-section h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: .5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
}
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
.form-group { display: flex; flex-direction: column; gap: .25rem; }
|
||||||
|
.form-group label { font-size: .8rem; font-weight: 500; color: var(--text-muted); }
|
||||||
|
.form-group input, .form-group select {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: .45rem .6rem;
|
||||||
|
font-size: .9rem;
|
||||||
|
font-family: inherit;
|
||||||
|
direction: rtl;
|
||||||
|
width: 100%;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
.form-group input:focus, .form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(26,86,219,.12);
|
||||||
|
}
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.keyboard-hint { font-size: .8rem; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
Tables
|
||||||
|
============================ */
|
||||||
|
.table-wrapper { overflow-x: auto; border-radius: var(--radius); border: 1px solid var(--border); }
|
||||||
|
.results-table, .props-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: .875rem;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.results-table th, .props-table th {
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: .6rem .75rem;
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: .8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.results-table td, .props-table td {
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.results-table tr:last-child td, .props-table tr:last-child td { border-bottom: none; }
|
||||||
|
.results-table tr:hover td, .props-table tr:hover td { background: #fafafa; }
|
||||||
|
.prop-num { font-family: monospace; font-weight: 600; }
|
||||||
|
.row-num { color: var(--text-muted); font-size: .8rem; }
|
||||||
|
|
||||||
|
.props-table input {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: .3rem .4rem;
|
||||||
|
font-size: .85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
direction: rtl;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 80px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.props-table input:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: white;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px rgba(26,86,219,.1);
|
||||||
|
}
|
||||||
|
.btn-del {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--danger);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .9rem;
|
||||||
|
padding: .2rem .4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.btn-del:hover { background: #fef2f2; }
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
Status Badges
|
||||||
|
============================ */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: .2rem .55rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
font-size: .75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.status-confirmed { background: #d1fae5; color: #065f46; }
|
||||||
|
.status-extracted { background: #fef3c7; color: #92400e; }
|
||||||
|
.status-pending { background: #e0f2fe; color: #075985; }
|
||||||
|
.status-error { background: #fee2e2; color: #991b1b; }
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
Person Cards
|
||||||
|
============================ */
|
||||||
|
.persons-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: .75rem;
|
||||||
|
}
|
||||||
|
.person-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
display: block;
|
||||||
|
color: var(--text);
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
.person-card:hover { border-color: var(--primary); box-shadow: var(--shadow-md); text-decoration: none; }
|
||||||
|
.person-name { font-size: 1rem; font-weight: 600; margin-bottom: .3rem; }
|
||||||
|
.person-meta { font-size: .85rem; color: var(--text-muted); }
|
||||||
|
.person-reg { font-size: .8rem; color: var(--text-muted); margin-top: .2rem; font-family: monospace; }
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
Person Detail
|
||||||
|
============================ */
|
||||||
|
.person-info-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
.info-item { display: flex; flex-direction: column; gap: .15rem; }
|
||||||
|
.info-label { font-size: .75rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .03em; }
|
||||||
|
|
||||||
|
.docs-section { margin-top: 1.5rem; }
|
||||||
|
.docs-section h3 { font-size: 1rem; font-weight: 600; margin-bottom: .75rem; }
|
||||||
|
.doc-thumbnails { display: flex; flex-wrap: wrap; gap: .75rem; }
|
||||||
|
.doc-thumb {
|
||||||
|
display: block;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
width: 120px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: .75rem;
|
||||||
|
transition: box-shadow .15s;
|
||||||
|
}
|
||||||
|
.doc-thumb:hover { box-shadow: var(--shadow-md); text-decoration: none; }
|
||||||
|
.doc-thumb img { width: 100%; height: 90px; object-fit: cover; display: block; }
|
||||||
|
.doc-thumb-info { padding: .3rem; background: #f9fafb; line-height: 1.3; }
|
||||||
|
.doc-thumb-mini { width: 48px; height: 36px; object-fit: cover; border-radius: 4px; display: block; }
|
||||||
|
.doc-page-info { font-size: .7rem; color: var(--primary); font-weight: 500; }
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
Search
|
||||||
|
============================ */
|
||||||
|
.search-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.25rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.search-tabs { display: flex; gap: .5rem; margin-bottom: 1rem; }
|
||||||
|
.tab-btn {
|
||||||
|
padding: .4rem .9rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text);
|
||||||
|
transition: background .15s, border-color .15s;
|
||||||
|
}
|
||||||
|
.tab-btn:hover { border-color: var(--primary); }
|
||||||
|
.tab-btn.active { background: var(--primary); color: white; border-color: var(--primary); }
|
||||||
|
.search-row { display: flex; gap: .75rem; }
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: .55rem .85rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
.search-input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(26,86,219,.12); }
|
||||||
|
.search-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: .75rem;
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
}
|
||||||
|
.results-section { margin-bottom: 2rem; }
|
||||||
|
.results-section h2 { font-size: 1.1rem; font-weight: 600; margin-bottom: .75rem; }
|
||||||
|
.no-results { text-align: center; padding: 2rem; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
Review page header
|
||||||
|
============================ */
|
||||||
|
.review-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
.review-title { display: flex; align-items: center; gap: .75rem; }
|
||||||
|
.review-title h2 { font-size: 1.25rem; }
|
||||||
|
.review-nav { display: flex; gap: .5rem; }
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/* Review page — two-column sticky layout */
|
||||||
|
.review-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1.4fr;
|
||||||
|
gap: 1.25rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image panel */
|
||||||
|
.image-panel {
|
||||||
|
position: sticky;
|
||||||
|
top: 70px;
|
||||||
|
background: #1f2937;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,.2);
|
||||||
|
}
|
||||||
|
.image-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: .4rem;
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
background: #111827;
|
||||||
|
}
|
||||||
|
.image-container {
|
||||||
|
overflow: auto;
|
||||||
|
max-height: calc(100vh - 140px);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: .5rem;
|
||||||
|
}
|
||||||
|
.doc-image {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
transform-origin: top center;
|
||||||
|
transition: transform .2s;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form panel */
|
||||||
|
.form-panel {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Image zoom controls
|
||||||
|
let zoomLevel = 1;
|
||||||
|
|
||||||
|
function zoomIn() {
|
||||||
|
zoomLevel = Math.min(zoomLevel + 0.2, 3);
|
||||||
|
applyZoom();
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOut() {
|
||||||
|
zoomLevel = Math.max(zoomLevel - 0.2, 0.4);
|
||||||
|
applyZoom();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetZoom() {
|
||||||
|
zoomLevel = 1;
|
||||||
|
applyZoom();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyZoom() {
|
||||||
|
const img = document.getElementById('docImage');
|
||||||
|
if (img) img.style.transform = `scale(${zoomLevel})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard navigation in properties table
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.target.tagName !== 'INPUT') return;
|
||||||
|
const row = e.target.closest('tr');
|
||||||
|
if (!row || !row.closest('#propertiesTbody')) return;
|
||||||
|
|
||||||
|
const inputs = Array.from(row.querySelectorAll('input'));
|
||||||
|
const idx = inputs.indexOf(e.target);
|
||||||
|
|
||||||
|
if (e.key === 'Tab' && !e.shiftKey && idx === inputs.length - 1) {
|
||||||
|
e.preventDefault();
|
||||||
|
const nextRow = row.nextElementSibling;
|
||||||
|
if (nextRow) {
|
||||||
|
nextRow.querySelector('input')?.focus();
|
||||||
|
} else {
|
||||||
|
// Add a new row at end of table
|
||||||
|
if (typeof addRow === 'function') addRow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ar" dir="rtl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}سجل العقارات اللبناني{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/main.css">
|
||||||
|
{% block head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% block navbar %}
|
||||||
|
<nav class="navbar">
|
||||||
|
<div class="nav-container">
|
||||||
|
<a class="nav-brand" href="/">🏠 سجل العقارات</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/" class="nav-link">رفع وثائق</a>
|
||||||
|
<a href="/review/next" class="nav-link">مراجعة</a>
|
||||||
|
<a href="/search" class="nav-link">بحث</a>
|
||||||
|
<a href="/documents" class="nav-link">قائمة الوثائق</a>
|
||||||
|
<a href="/auth/users" class="nav-link">الإدارة</a>
|
||||||
|
<a href="/auth/logout" class="nav-link">تسجيل خروج</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}قائمة الوثائق — سجل العقارات{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>قائمة الوثائق</h1>
|
||||||
|
<a href="/" class="btn btn-primary">+ رفع وثائق جديدة</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if uploaded %}
|
||||||
|
<div class="success-banner">تم رفع الملفات بنجاح وبدأ الاستخراج. قد يستغرق بعض الوقت.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="stats-bar">
|
||||||
|
<a href="/documents" class="stat {{ 'active' if not current_status }}">
|
||||||
|
<span class="stat-num">{{ stats.total or 0 }}</span><span class="stat-label">الكل</span>
|
||||||
|
</a>
|
||||||
|
<a href="/documents?status=extracted" class="stat pending {{ 'active' if current_status=='extracted' }}">
|
||||||
|
<span class="stat-num">{{ stats.pending_review or 0 }}</span><span class="stat-label">بانتظار المراجعة</span>
|
||||||
|
</a>
|
||||||
|
<a href="/documents?status=confirmed" class="stat confirmed {{ 'active' if current_status=='confirmed' }}">
|
||||||
|
<span class="stat-num">{{ stats.confirmed or 0 }}</span><span class="stat-label">مؤكّدة</span>
|
||||||
|
</a>
|
||||||
|
<a href="/documents?status=pending" class="stat {{ 'active' if current_status=='pending' }}">
|
||||||
|
<span class="stat-num">{{ stats.processing or 0 }}</span><span class="stat-label">جارٍ المعالجة</span>
|
||||||
|
</a>
|
||||||
|
{% if stats.errors %}
|
||||||
|
<a href="/documents?status=error" class="stat error {{ 'active' if current_status=='error' }}">
|
||||||
|
<span class="stat-num">{{ stats.errors }}</span><span class="stat-label">أخطاء</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if stats.pending_review %}
|
||||||
|
<div class="alert-banner">
|
||||||
|
<a href="/review/next">▶ ابدأ مراجعة {{ stats.pending_review }} وثيقة</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="results-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>الصورة</th>
|
||||||
|
<th>الشخص</th>
|
||||||
|
<th>رقم الطلب</th>
|
||||||
|
<th>الحالة</th>
|
||||||
|
<th>التاريخ</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in documents %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ d.id }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/review/{{ d.id }}">
|
||||||
|
<img src="/uploads/{{ d.image_path }}" class="doc-thumb-mini" alt="">
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if d.person_id %}
|
||||||
|
<a href="/persons/{{ d.person_id }}">{{ d.first_name or '' }} {{ d.family_name or '' }}</a>
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ d.request_number or '—' }}</td>
|
||||||
|
<td><span class="status-badge status-{{ d.status }}">{{ d.status }}</span></td>
|
||||||
|
<td>{{ d.created_at[:10] if d.created_at else '' }}</td>
|
||||||
|
<td class="doc-actions">
|
||||||
|
<a href="/review/{{ d.id }}" class="btn btn-sm btn-secondary">
|
||||||
|
{% if d.status == 'confirmed' %}عرض{% else %}مراجعة{% endif %}
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="deleteDoc({{ d.id }}, this)">حذف</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not documents %}
|
||||||
|
<div class="no-results">
|
||||||
|
<p>لا توجد وثائق. <a href="/">ارفع وثائق جديدة</a></p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
async function deleteDoc(docId, btn) {
|
||||||
|
if (!confirm('حذف هذه الوثيقة نهائياً؟ لا يمكن التراجع.')) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '...';
|
||||||
|
const res = await fetch(`/documents/${docId}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
btn.closest('tr').remove();
|
||||||
|
} else {
|
||||||
|
alert('حدث خطأ أثناء الحذف.');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'حذف';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}رفع وثائق — سجل العقارات{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>بطاقات معلومات الملكية العقارية</h1>
|
||||||
|
<p class="subtitle">ارفع صور أو ملفات PDF للوثائق — اختر محرك الاستخراج (مجاني أو ذكاء اصطناعي)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if stats and stats.total %}
|
||||||
|
<div class="stats-bar">
|
||||||
|
<div class="stat"><span class="stat-num">{{ stats.total }}</span><span class="stat-label">إجمالي الوثائق</span></div>
|
||||||
|
<div class="stat confirmed"><span class="stat-num">{{ stats.confirmed or 0 }}</span><span class="stat-label">مؤكّدة</span></div>
|
||||||
|
<div class="stat pending"><span class="stat-num">{{ stats.pending_review or 0 }}</span><span class="stat-label">بانتظار المراجعة</span></div>
|
||||||
|
{% if stats.errors %}<div class="stat error"><span class="stat-num">{{ stats.errors }}</span><span class="stat-label">أخطاء</span></div>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if stats.pending_review %}
|
||||||
|
<div class="alert-banner">
|
||||||
|
<a href="/review/next">▶ ابدأ مراجعة {{ stats.pending_review }} وثيقة</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="upload-card">
|
||||||
|
<form id="uploadForm" action="/upload" method="post" enctype="multipart/form-data">
|
||||||
|
<div class="drop-zone" id="dropZone">
|
||||||
|
<div class="drop-icon">📄</div>
|
||||||
|
<p class="drop-text">اسحب الملفات هنا أو انقر للاختيار</p>
|
||||||
|
<p class="drop-hint">JPG، PNG، PDF — يمكن رفع عدة ملفات دفعة واحدة</p>
|
||||||
|
<input type="file" name="files" id="fileInput" multiple accept=".jpg,.jpeg,.png,.webp,.pdf" class="file-input">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="fileList" class="file-list hidden"></div>
|
||||||
|
|
||||||
|
<!-- Provider selector -->
|
||||||
|
<div id="providerSection" class="provider-section hidden">
|
||||||
|
<label class="provider-label">محرك الاستخراج:</label>
|
||||||
|
<div class="provider-options">
|
||||||
|
{% for p in providers %}
|
||||||
|
<label class="provider-option">
|
||||||
|
<input type="radio" name="provider" value="{{ p.id }}"
|
||||||
|
{{ 'checked' if p.id == default_provider else '' }}>
|
||||||
|
<span class="provider-card {{ 'provider-free' if p.id == 'easyocr' else 'provider-ai' }}">
|
||||||
|
<span class="provider-name">{{ p.name }}</span>
|
||||||
|
<span class="provider-model">{{ p.model }}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="uploadActions" class="upload-actions hidden">
|
||||||
|
<button type="submit" class="btn btn-primary btn-large">
|
||||||
|
⬆ رفع ومعالجة
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="clearFiles()">إلغاء</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="quick-links">
|
||||||
|
<a href="/search" class="quick-link-card">
|
||||||
|
<span class="ql-icon">🔍</span>
|
||||||
|
<span>البحث في قاعدة البيانات</span>
|
||||||
|
</a>
|
||||||
|
<a href="/documents" class="quick-link-card">
|
||||||
|
<span class="ql-icon">📋</span>
|
||||||
|
<span>قائمة جميع الوثائق</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
const dropZone = document.getElementById('dropZone');
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
const fileList = document.getElementById('fileList');
|
||||||
|
const uploadActions = document.getElementById('uploadActions');
|
||||||
|
|
||||||
|
dropZone.addEventListener('click', () => fileInput.click());
|
||||||
|
|
||||||
|
dropZone.addEventListener('dragover', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.add('drag-over');
|
||||||
|
});
|
||||||
|
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
|
||||||
|
dropZone.addEventListener('drop', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.remove('drag-over');
|
||||||
|
fileInput.files = e.dataTransfer.files;
|
||||||
|
showFiles(fileInput.files);
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', () => showFiles(fileInput.files));
|
||||||
|
|
||||||
|
function showFiles(files) {
|
||||||
|
if (!files.length) return;
|
||||||
|
fileList.innerHTML = Array.from(files).map(f =>
|
||||||
|
`<div class="file-item">${f.name.endsWith('.pdf')?'📕':'📄'} ${f.name} <span class="file-size">${(f.size/1024).toFixed(0)} KB</span></div>`
|
||||||
|
).join('');
|
||||||
|
fileList.classList.remove('hidden');
|
||||||
|
document.getElementById('providerSection').classList.remove('hidden');
|
||||||
|
uploadActions.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFiles() {
|
||||||
|
fileInput.value = '';
|
||||||
|
fileList.innerHTML = '';
|
||||||
|
fileList.classList.add('hidden');
|
||||||
|
document.getElementById('providerSection').classList.add('hidden');
|
||||||
|
uploadActions.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('uploadForm').addEventListener('submit', function() {
|
||||||
|
document.querySelector('.btn-primary').textContent = '⏳ جارٍ الرفع...';
|
||||||
|
document.querySelector('.btn-primary').disabled = true;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}تسجيل الدخول - سجل العقارات اللبناني{% endblock %}
|
||||||
|
|
||||||
|
{% block navbar %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="login-container" style="max-width: 400px; margin: 50px auto; padding: 20px; border: 1px solid #ccc; border-radius: 8px;">
|
||||||
|
<h2>تسجيل الدخول</h2>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div style="color: red; margin-bottom: 10px;">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/auth/login">
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<label for="username" style="display: block; margin-bottom: 5px;">اسم المستخدم:</label>
|
||||||
|
<input type="text" id="username" name="username" required style="width: 100%; padding: 8px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<label for="password" style="display: block; margin-bottom: 5px;">كلمة المرور:</label>
|
||||||
|
<input type="password" id="password" name="password" required style="width: 100%; padding: 8px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" style="width: 100%; padding: 10px; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer;">
|
||||||
|
دخول
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ person.first_name }} {{ person.family_name or '' }} — سجل العقارات{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<a href="/search" class="back-link">← العودة للبحث</a>
|
||||||
|
<h1>{{ person.first_name }} {{ person.father_name or '' }} {{ person.family_name or '' }}</h1>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{% set scopes = documents | selectattr('search_scope') | map(attribute='search_scope') | unique | list %}
|
||||||
|
<div class="export-dropdown" style="position: relative; display: inline-block;">
|
||||||
|
<button class="btn btn-primary" onclick="this.nextElementSibling.classList.toggle('show')">📥 تصدير كملف إكسل ▾</button>
|
||||||
|
<div class="export-menu" style="display:none; position:absolute; left:0; top:100%; background:#fff; border:1px solid #ddd; border-radius:8px; box-shadow:0 4px 12px rgba(0,0,0,.15); z-index:100; min-width:220px; margin-top:4px;">
|
||||||
|
<a href="/persons/{{ person.id }}/export" class="export-option" download style="display:block; padding:10px 16px; text-decoration:none; color:#333; border-bottom:1px solid #eee;">📥 تصدير الكل</a>
|
||||||
|
{% for scope in scopes %}
|
||||||
|
{% if scope %}
|
||||||
|
<a href="/persons/{{ person.id }}/export?qaza={{ scope|urlencode }}" class="export-option" download style="display:block; padding:10px 16px; text-decoration:none; color:#333;">📥 {{ scope }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="person-info-card">
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item"><span class="info-label">الاسم الكامل</span><span>{{ person.first_name }} {{ person.father_name or '' }} {{ person.family_name or '' }}</span></div>
|
||||||
|
{% if person.mother_name %}<div class="info-item"><span class="info-label">اسم الأم</span><span>{{ person.mother_name }}</span></div>{% endif %}
|
||||||
|
{% if person.family_origin %}<div class="info-item"><span class="info-label">الهوا</span><span>{{ person.family_origin }}</span></div>{% endif %}
|
||||||
|
{% if person.nationality %}<div class="info-item"><span class="info-label">الجنسية</span><span>{{ person.nationality }}</span></div>{% endif %}
|
||||||
|
{% if person.birth_date %}<div class="info-item"><span class="info-label">تاريخ الولادة</span><span>{{ person.birth_date }}</span></div>{% endif %}
|
||||||
|
{% if person.registry_number %}<div class="info-item"><span class="info-label">رقم السجل</span><span>{{ person.registry_number }}</span></div>{% endif %}
|
||||||
|
{% if person.registry_place %}<div class="info-item"><span class="info-label">مكان السجل</span><span>{{ person.registry_place }}</span></div>{% endif %}
|
||||||
|
{% set scopes = documents | selectattr('search_scope') | map(attribute='search_scope') | unique | list %}
|
||||||
|
{% if scopes %}<div class="info-item"><span class="info-label">القضاء</span><span>{{ scopes | join('، ') }}</span></div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>العقارات المملوكة ({{ properties|length }})</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if properties %}
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="results-table props-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>اسم الفريق</th>
|
||||||
|
<th>رقم العقار</th>
|
||||||
|
<th>القسم</th>
|
||||||
|
<th>البلوك</th>
|
||||||
|
<th>المنطقة العقارية</th>
|
||||||
|
<th>القضاء</th>
|
||||||
|
<th>عدد الأسهم</th>
|
||||||
|
<th>نوع الملكية</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for pr in properties %}
|
||||||
|
<tr {% if pr.ownership_type == 'لا يملك' %}style="background-color: #fff3f3; color: #dc3545; font-weight: bold;"{% endif %}>
|
||||||
|
<td class="row-num">{{ loop.index }}</td>
|
||||||
|
<td>{{ pr.party_name or '' }}</td>
|
||||||
|
<td class="prop-num">{{ pr.property_number or '' }}</td>
|
||||||
|
<td>{{ pr.section or '' }}</td>
|
||||||
|
<td>{{ pr.block or '' }}</td>
|
||||||
|
<td>{{ pr.real_estate_district or '' }}</td>
|
||||||
|
<td>{{ pr.qaza or '' }}</td>
|
||||||
|
<td>{{ pr.num_shares or '' }}</td>
|
||||||
|
<td>{{ pr.ownership_type or '' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="margin: 1rem 0; padding: 15px; background-color: #fff3f3; border: 1px solid #ffcaca; border-radius: 8px; color: #dc3545; font-weight: bold; text-align: center;">
|
||||||
|
⚠️ نتيجة البحث: لا يملك أي عقار في نطاق البحث المذكور.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if documents %}
|
||||||
|
<div class="docs-section">
|
||||||
|
<h3>الوثائق الأصلية</h3>
|
||||||
|
<div class="doc-thumbnails">
|
||||||
|
{% for d in documents %}
|
||||||
|
<a href="/review/{{ d.id }}" class="doc-thumb">
|
||||||
|
<img src="/uploads/{{ d.image_path }}" alt="وثيقة">
|
||||||
|
<div class="doc-thumb-info">
|
||||||
|
{% if d.request_number %}طلب {{ d.request_number }}{% endif %}
|
||||||
|
{% if d.request_date %}<br>{{ d.request_date }}{% endif %}
|
||||||
|
{% if d.search_scope %}<br>القضاء: {{ d.search_scope }}{% endif %}
|
||||||
|
{% if d.page_info %}<br><span class="doc-page-info">{{ d.page_info }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// Export dropdown toggle
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
document.querySelectorAll('.export-menu').forEach(function(menu) {
|
||||||
|
if (!menu.parentElement.contains(e.target)) {
|
||||||
|
menu.classList.remove('show');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var style = document.createElement('style');
|
||||||
|
style.textContent = '.export-menu.show { display: block !important; } .export-option:hover { background-color: #f0f0f0; }';
|
||||||
|
document.head.appendChild(style);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}مراجعة الوثيقة #{{ doc.id }}{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<link rel="stylesheet" href="/static/css/review.css">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="review-header">
|
||||||
|
<div class="review-title">
|
||||||
|
<h2>مراجعة الوثيقة #{{ doc.id }}</h2>
|
||||||
|
<span class="status-badge status-{{ doc.status }}">{{ doc.status }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="review-nav">
|
||||||
|
<a href="/documents" class="btn btn-secondary btn-sm">← قائمة الوثائق</a>
|
||||||
|
<a href="/review/next" class="btn btn-secondary btn-sm">التالية ←</a>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="deleteDoc({{ doc.id }})">🗑 حذف الوثيقة</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if doc.status == 'error' %}
|
||||||
|
<div class="error-banner">
|
||||||
|
⚠ خطأ في الاستخراج: {{ doc.extraction_error }}
|
||||||
|
<button class="btn btn-sm btn-warning" onclick="retriggerExtraction({{ doc.id }})">إعادة المحاولة</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="review-layout">
|
||||||
|
<!-- Image Panel -->
|
||||||
|
<div class="image-panel">
|
||||||
|
<div class="image-toolbar">
|
||||||
|
<button onclick="zoomIn()" class="btn btn-sm">🔍+</button>
|
||||||
|
<button onclick="zoomOut()" class="btn btn-sm">🔍-</button>
|
||||||
|
<button onclick="resetZoom()" class="btn btn-sm">↺</button>
|
||||||
|
</div>
|
||||||
|
<div class="image-container" id="imageContainer">
|
||||||
|
<img id="docImage" src="/uploads/{{ doc.image_path }}" alt="وثيقة" class="doc-image">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Panel -->
|
||||||
|
<div class="form-panel">
|
||||||
|
<form id="reviewForm">
|
||||||
|
|
||||||
|
<!-- Person Info -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>بيانات الشخص</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>الاسم *</label>
|
||||||
|
<input type="text" name="first_name" value="{{ doc.person.get('first_name') or '' }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>اسم الأب</label>
|
||||||
|
<input type="text" name="father_name" value="{{ doc.person.get('father_name') or '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>اسم الأم</label>
|
||||||
|
<input type="text" name="mother_name" value="{{ doc.person.get('mother_name') or '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>اللقب</label>
|
||||||
|
<input type="text" name="family_name" value="{{ doc.person.get('family_name') or '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>الهوا / المنشأ</label>
|
||||||
|
<input type="text" name="family_origin" value="{{ doc.person.get('family_origin') or '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>الجنسية</label>
|
||||||
|
<input type="text" name="nationality" value="{{ doc.person.get('nationality') or '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>تاريخ الولادة</label>
|
||||||
|
<input type="text" name="birth_date" value="{{ doc.person.get('birth_date') or '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>رقم السجل</label>
|
||||||
|
<input type="text" name="registry_number" value="{{ doc.person.get('registry_number') or '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>مكان السجل</label>
|
||||||
|
<input type="text" name="registry_place" value="{{ doc.person.get('registry_place') or '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Merge suggestion (populated by JS) -->
|
||||||
|
<div id="mergeSuggestion" class="merge-banner hidden">
|
||||||
|
<h4>شخص مشابه موجود في قاعدة البيانات:</h4>
|
||||||
|
<div id="mergeMatches"></div>
|
||||||
|
<div class="merge-actions">
|
||||||
|
<label><input type="radio" name="merge_action" value="merge" checked> دمج مع الشخص الموجود</label>
|
||||||
|
<label><input type="radio" name="merge_action" value="new"> إنشاء شخص جديد</label>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="mergePersonId" value="">
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Document Info -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>بيانات الطلب</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>رقم الطلب</label>
|
||||||
|
<input type="text" name="request_number" value="{{ doc.request_number or '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>تاريخ الطلب</label>
|
||||||
|
<input type="text" name="request_date" value="{{ doc.request_date or '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>معلومات الصفحة</label>
|
||||||
|
<input type="text" name="page_info" value="{{ doc.page_info or '' }}" placeholder="مثال: 1 من 3">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>نطاق البحث (القضاء)</label>
|
||||||
|
<input type="text" name="search_scope" value="{{ doc.search_scope or '' }}" placeholder="مثال: المتن">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Properties Table -->
|
||||||
|
<section class="form-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>العقارات المملوكة</h3>
|
||||||
|
<button type="button" class="btn btn-sm btn-success" onclick="addRow()">+ إضافة صف</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table id="propertiesTable" class="props-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>اسم الفريق</th>
|
||||||
|
<th>رقم العقار</th>
|
||||||
|
<th>القسم</th>
|
||||||
|
<th>البلوك</th>
|
||||||
|
<th>المنطقة العقارية</th>
|
||||||
|
<th>القضاء</th>
|
||||||
|
<th>عدد الأسهم</th>
|
||||||
|
<th>نوع الملكية</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="propertiesTbody">
|
||||||
|
{% for prop in doc.properties %}
|
||||||
|
<tr>
|
||||||
|
<td><input type="text" value="{{ prop.party_name or '' }}"></td>
|
||||||
|
<td><input type="text" value="{{ prop.property_number or '' }}"></td>
|
||||||
|
<td><input type="text" value="{{ prop.section or '' }}"></td>
|
||||||
|
<td><input type="text" value="{{ prop.block or '' }}"></td>
|
||||||
|
<td><input type="text" value="{{ prop.real_estate_district or '' }}"></td>
|
||||||
|
<td><input type="text" value="{{ prop.qaza or '' }}"></td>
|
||||||
|
<td><input type="text" value="{{ prop.num_shares or '' }}"></td>
|
||||||
|
<td><input type="text" value="{{ prop.ownership_type or '' }}"></td>
|
||||||
|
<td><button type="button" class="btn-del" onclick="removeRow(this)">✕</button></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-primary btn-large" onclick="confirmDocument()" id="confirmBtn">
|
||||||
|
✓ تأكيد والانتقال للتالية
|
||||||
|
</button>
|
||||||
|
<span class="keyboard-hint">أو اضغط Ctrl+Enter</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="/static/js/review.js"></script>
|
||||||
|
<script>
|
||||||
|
const DOC_ID = {{ doc.id }};
|
||||||
|
|
||||||
|
function addRow() {
|
||||||
|
const tbody = document.getElementById('propertiesTbody');
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td><input type="text" value=""></td>
|
||||||
|
<td><input type="text" value=""></td>
|
||||||
|
<td><input type="text" value=""></td>
|
||||||
|
<td><input type="text" value=""></td>
|
||||||
|
<td><input type="text" value=""></td>
|
||||||
|
<td><input type="text" value=""></td>
|
||||||
|
<td><input type="text" value=""></td>
|
||||||
|
<td><input type="text" value=""></td>
|
||||||
|
<td><button type="button" class="btn-del" onclick="removeRow(this)">✕</button></td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
tr.querySelector('input').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRow(btn) {
|
||||||
|
btn.closest('tr').remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFormData() {
|
||||||
|
const form = document.getElementById('reviewForm');
|
||||||
|
const person = {
|
||||||
|
first_name: form.querySelector('[name=first_name]').value,
|
||||||
|
father_name: form.querySelector('[name=father_name]').value,
|
||||||
|
mother_name: form.querySelector('[name=mother_name]').value,
|
||||||
|
family_name: form.querySelector('[name=family_name]').value,
|
||||||
|
family_origin: form.querySelector('[name=family_origin]').value,
|
||||||
|
nationality: form.querySelector('[name=nationality]').value,
|
||||||
|
birth_date: form.querySelector('[name=birth_date]').value,
|
||||||
|
registry_number: form.querySelector('[name=registry_number]').value,
|
||||||
|
registry_place: form.querySelector('[name=registry_place]').value,
|
||||||
|
};
|
||||||
|
const rows = document.querySelectorAll('#propertiesTbody tr');
|
||||||
|
const properties = Array.from(rows).map(row => {
|
||||||
|
const inputs = row.querySelectorAll('input');
|
||||||
|
return {
|
||||||
|
party_name: inputs[0].value,
|
||||||
|
property_number: inputs[1].value,
|
||||||
|
section: inputs[2].value,
|
||||||
|
block: inputs[3].value,
|
||||||
|
real_estate_district: inputs[4].value,
|
||||||
|
qaza: inputs[5].value,
|
||||||
|
num_shares: inputs[6].value,
|
||||||
|
ownership_type: inputs[7].value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const reqNum = form.querySelector('[name=request_number]').value;
|
||||||
|
const reqDate = form.querySelector('[name=request_date]').value;
|
||||||
|
const pageInfo = form.querySelector('[name=page_info]').value;
|
||||||
|
const searchScope = form.querySelector('[name=search_scope]').value;
|
||||||
|
|
||||||
|
return {
|
||||||
|
person,
|
||||||
|
properties,
|
||||||
|
request_number: reqNum,
|
||||||
|
request_date: reqDate,
|
||||||
|
page_info: pageInfo,
|
||||||
|
search_scope: searchScope
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Duplicate person check ────────────────────────────
|
||||||
|
let _dupTimer = null;
|
||||||
|
|
||||||
|
function checkDuplicate() {
|
||||||
|
clearTimeout(_dupTimer);
|
||||||
|
_dupTimer = setTimeout(async () => {
|
||||||
|
const form = document.getElementById('reviewForm');
|
||||||
|
const first = form.querySelector('[name=first_name]').value.trim();
|
||||||
|
const father = form.querySelector('[name=father_name]').value.trim();
|
||||||
|
const family = form.querySelector('[name=family_name]').value.trim();
|
||||||
|
if (!first) return;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({first_name: first, father_name: father, family_name: family});
|
||||||
|
const res = await fetch('/api/check-duplicate?' + params);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const banner = document.getElementById('mergeSuggestion');
|
||||||
|
const matchesDiv = document.getElementById('mergeMatches');
|
||||||
|
const mergeIdInput = document.getElementById('mergePersonId');
|
||||||
|
|
||||||
|
if (data.matches && data.matches.length > 0) {
|
||||||
|
matchesDiv.innerHTML = data.matches.map(m =>
|
||||||
|
`<div class="merge-person" data-person-id="${m.id}">
|
||||||
|
<span class="mp-name">${m.first_name} ${m.father_name||''} ${m.family_name||''}</span>
|
||||||
|
<span class="mp-count">${m.property_count} عقار</span>
|
||||||
|
${m.family_origin ? '<span class="mp-count">'+m.family_origin+'</span>' : ''}
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
mergeIdInput.value = data.matches[0].id;
|
||||||
|
banner.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Click to select a match
|
||||||
|
matchesDiv.querySelectorAll('.merge-person').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
matchesDiv.querySelectorAll('.merge-person').forEach(e => e.style.outline = '');
|
||||||
|
el.style.outline = '2px solid var(--primary)';
|
||||||
|
mergeIdInput.value = el.dataset.personId;
|
||||||
|
form.querySelector('[name=merge_action][value=merge]').checked = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
banner.classList.add('hidden');
|
||||||
|
mergeIdInput.value = '';
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach duplicate check to name fields
|
||||||
|
document.querySelectorAll('[name=first_name],[name=father_name],[name=family_name]').forEach(input => {
|
||||||
|
input.addEventListener('blur', checkDuplicate);
|
||||||
|
});
|
||||||
|
// Run once on load
|
||||||
|
checkDuplicate();
|
||||||
|
|
||||||
|
// ─── Confirm ───────────────────────────────────────────
|
||||||
|
async function confirmDocument() {
|
||||||
|
const btn = document.getElementById('confirmBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '⏳ جارٍ الحفظ...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = getFormData();
|
||||||
|
|
||||||
|
// Check if user chose to merge
|
||||||
|
const mergeAction = document.querySelector('[name=merge_action]:checked');
|
||||||
|
const mergeId = document.getElementById('mergePersonId').value;
|
||||||
|
if (mergeAction && mergeAction.value === 'merge' && mergeId) {
|
||||||
|
formData.merge_person_id = parseInt(mergeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`/confirm/${DOC_ID}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) {
|
||||||
|
window.location.href = data.next;
|
||||||
|
} else {
|
||||||
|
alert('حدث خطأ: ' + JSON.stringify(data));
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '✓ تأكيد والانتقال للتالية';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('خطأ في الشبكة: ' + e);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '✓ تأكيد والانتقال للتالية';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retriggerExtraction(docId) {
|
||||||
|
if (!confirm('إعادة استخراج البيانات بالذكاء الاصطناعي؟')) return;
|
||||||
|
await fetch(`/extract/${docId}`, { method: 'POST' });
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDoc(docId) {
|
||||||
|
if (!confirm('حذف هذه الوثيقة نهائياً؟ لا يمكن التراجع.')) return;
|
||||||
|
const res = await fetch(`/documents/${docId}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
window.location.href = '/review/next';
|
||||||
|
} else {
|
||||||
|
alert('حدث خطأ أثناء الحذف.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
confirmDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}بحث — سجل العقارات{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>البحث في قاعدة البيانات</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-card">
|
||||||
|
<form method="get" action="/search" id="searchForm">
|
||||||
|
<div class="search-tabs">
|
||||||
|
<button type="button" class="tab-btn active" onclick="showTab('person')">بحث بالاسم</button>
|
||||||
|
<button type="button" class="tab-btn" onclick="showTab('property')">بحث بالعقار</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-person" class="tab-content">
|
||||||
|
<div class="search-row">
|
||||||
|
<input type="text" name="q" id="qInput" value="{{ q }}"
|
||||||
|
placeholder="اكتب اسم الشخص..." class="search-input" autocomplete="off">
|
||||||
|
<button type="submit" class="btn btn-primary">🔍 بحث</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-property" class="tab-content hidden">
|
||||||
|
<div class="search-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>رقم العقار</label>
|
||||||
|
<input type="text" name="property_number" value="{{ property_number }}" placeholder="مثال: 162">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>المنطقة العقارية</label>
|
||||||
|
<input type="text" name="district" value="{{ district }}" placeholder="مثال: مراح الزيتات">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>البلوك</label>
|
||||||
|
<input type="text" name="block" value="{{ block }}" placeholder="رقم البلوك">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">🔍 بحث</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="error-banner">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Person results -->
|
||||||
|
{% if persons %}
|
||||||
|
<div class="results-section">
|
||||||
|
<h2>نتائج البحث بالاسم ({{ persons|length }} شخص)</h2>
|
||||||
|
|
||||||
|
{% if selected_person %}
|
||||||
|
<!-- Single person: show full details inline -->
|
||||||
|
<div class="person-info-card">
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item"><span class="info-label">الاسم الكامل</span><span>{{ selected_person.person.first_name }} {{ selected_person.person.father_name or '' }} {{ selected_person.person.family_name or '' }}</span></div>
|
||||||
|
{% if selected_person.person.family_origin %}<div class="info-item"><span class="info-label">الهوا</span><span>{{ selected_person.person.family_origin }}</span></div>{% endif %}
|
||||||
|
{% if selected_person.person.nationality %}<div class="info-item"><span class="info-label">الجنسية</span><span>{{ selected_person.person.nationality }}</span></div>{% endif %}
|
||||||
|
{% if selected_person.person.registry_number %}<div class="info-item"><span class="info-label">رقم السجل</span><span>{{ selected_person.person.registry_number }}</span></div>{% endif %}
|
||||||
|
{% set scopes = selected_person.documents | selectattr('search_scope') | map(attribute='search_scope') | unique | list %}
|
||||||
|
{% if scopes %}<div class="info-item"><span class="info-label">القضاء</span><span>{{ scopes | join('، ') }}</span></div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:1rem">
|
||||||
|
<a href="/persons/{{ selected_person.person.id }}" class="btn btn-secondary">عرض التفاصيل كاملة</a>
|
||||||
|
{% set scopes = selected_person.documents | selectattr('search_scope') | map(attribute='search_scope') | unique | list %}
|
||||||
|
<div class="export-dropdown" style="position: relative; display: inline-block;">
|
||||||
|
<button class="btn btn-primary" onclick="this.nextElementSibling.classList.toggle('show')">📥 تصدير كملف إكسل ▾</button>
|
||||||
|
<div class="export-menu" style="display:none; position:absolute; left:0; top:100%; background:#fff; border:1px solid #ddd; border-radius:8px; box-shadow:0 4px 12px rgba(0,0,0,.15); z-index:100; min-width:220px; margin-top:4px;">
|
||||||
|
<a href="/persons/{{ selected_person.person.id }}/export" class="export-option" download style="display:block; padding:10px 16px; text-decoration:none; color:#333; border-bottom:1px solid #eee;">📥 تصدير الكل</a>
|
||||||
|
{% for scope in scopes %}
|
||||||
|
{% if scope %}
|
||||||
|
<a href="/persons/{{ selected_person.person.id }}/export?qaza={{ scope|urlencode }}" class="export-option" download style="display:block; padding:10px 16px; text-decoration:none; color:#333;">📥 {{ scope }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if selected_person.properties %}
|
||||||
|
<div class="table-wrapper" style="margin-top:1rem">
|
||||||
|
<table class="results-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>اسم الفريق</th>
|
||||||
|
<th>رقم العقار</th>
|
||||||
|
<th>القسم</th>
|
||||||
|
<th>البلوك</th>
|
||||||
|
<th>المنطقة العقارية</th>
|
||||||
|
<th>القضاء</th>
|
||||||
|
<th>عدد الأسهم</th>
|
||||||
|
<th>نوع الملكية</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for pr in selected_person.properties %}
|
||||||
|
<tr {% if pr.ownership_type == 'لا يملك' %}style="background-color: #fff3f3; color: #dc3545; font-weight: bold;"{% endif %}>
|
||||||
|
<td>{{ loop.index }}</td>
|
||||||
|
<td>{{ pr.party_name or '' }}</td>
|
||||||
|
<td>{{ pr.property_number or '' }}</td>
|
||||||
|
<td>{{ pr.section or '' }}</td>
|
||||||
|
<td>{{ pr.block or '' }}</td>
|
||||||
|
<td>{{ pr.real_estate_district or '' }}</td>
|
||||||
|
<td>{{ pr.qaza or '' }}</td>
|
||||||
|
<td>{{ pr.num_shares or '' }}</td>
|
||||||
|
<td>{{ pr.ownership_type or '' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="margin-top: 1rem; padding: 15px; background-color: #fff3f3; border: 1px solid #ffcaca; border-radius: 8px; color: #dc3545; font-weight: bold; text-align: center;">
|
||||||
|
⚠️ نتيجة البحث: لا يملك أي عقار في القضاء المذكور.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="persons-grid">
|
||||||
|
{% for p in persons %}
|
||||||
|
<a href="/persons/{{ p.id }}" class="person-card">
|
||||||
|
<div class="person-name">{{ p.first_name }} {{ p.father_name or '' }} {{ p.family_name or '' }}</div>
|
||||||
|
<div class="person-meta">
|
||||||
|
{% if p.family_origin %}{{ p.family_origin }} · {% endif %}
|
||||||
|
{% if p.property_count > 0 %}
|
||||||
|
{{ p.property_count }} عقار
|
||||||
|
{% else %}
|
||||||
|
<span style="color: #dc3545; font-weight: bold;">لا يملك عقارات</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if p.search_scopes %}
|
||||||
|
<div class="person-reg">نطاق البحث: {{ p.search_scopes | replace(',', '، ') }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if p.registry_number %}
|
||||||
|
<div class="person-reg">سجل: {{ p.registry_number }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Property results -->
|
||||||
|
{% if properties %}
|
||||||
|
<div class="results-section">
|
||||||
|
<h2>نتائج البحث بالعقار ({{ properties|length }} سجل)</h2>
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="results-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>المالك</th>
|
||||||
|
<th>اسم الفريق</th>
|
||||||
|
<th>رقم العقار</th>
|
||||||
|
<th>القسم</th>
|
||||||
|
<th>البلوك</th>
|
||||||
|
<th>المنطقة العقارية</th>
|
||||||
|
<th>القضاء</th>
|
||||||
|
<th>عدد الأسهم</th>
|
||||||
|
<th>نوع الملكية</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for pr in properties %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{% if pr.person_id %}
|
||||||
|
<a href="/persons/{{ pr.person_id }}">{{ pr.first_name or '' }} {{ pr.family_name or '' }}</a>
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ pr.party_name or '' }}</td>
|
||||||
|
<td class="prop-num">{{ pr.property_number or '' }}</td>
|
||||||
|
<td>{{ pr.section or '' }}</td>
|
||||||
|
<td>{{ pr.block or '' }}</td>
|
||||||
|
<td>{{ pr.real_estate_district or '' }}</td>
|
||||||
|
<td>{{ pr.qaza or '' }}</td>
|
||||||
|
<td>{{ pr.num_shares or '' }}</td>
|
||||||
|
<td>{{ pr.ownership_type or '' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if (q or property_number or district or block) and not persons and not properties %}
|
||||||
|
<div class="no-results">
|
||||||
|
<p>لا توجد نتائج للبحث.</p>
|
||||||
|
<p><a href="/search">بحث جديد</a></p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function showTab(tab) {
|
||||||
|
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
|
||||||
|
document.getElementById('tab-' + tab).classList.remove('hidden');
|
||||||
|
event.target.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export dropdown toggle
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
document.querySelectorAll('.export-menu').forEach(function(menu) {
|
||||||
|
if (!menu.parentElement.contains(e.target)) {
|
||||||
|
menu.classList.remove('show');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// CSS for show class
|
||||||
|
var style = document.createElement('style');
|
||||||
|
style.textContent = '.export-menu.show { display: block !important; } .export-option:hover { background-color: #f0f0f0; }';
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
// Auto-select correct tab based on query params
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
if (params.get('property_number') || params.get('district') || params.get('block')) {
|
||||||
|
showTab('property');
|
||||||
|
document.querySelectorAll('.tab-btn')[1].classList.add('active');
|
||||||
|
document.querySelectorAll('.tab-btn')[0].classList.remove('active');
|
||||||
|
} else {
|
||||||
|
document.getElementById('qInput').focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}إدارة المستخدمين - سجل العقارات اللبناني{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="users-container" style="max-width: 800px; margin: 20px auto; padding: 20px;">
|
||||||
|
<h2>إدارة النظام والمستخدمين</h2>
|
||||||
|
|
||||||
|
{% if backup_msg %}
|
||||||
|
<div style="padding: 10px; background-color: #d4edda; color: #155724; border-radius: 4px; margin-bottom: 15px;">
|
||||||
|
{{ backup_msg }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="margin-bottom: 30px; padding: 15px; border: 1px solid #ddd; border-radius: 8px;">
|
||||||
|
<h3>نسخ احتياطي لقاعدة البيانات</h3>
|
||||||
|
<p>قم بإنشاء نسخة احتياطية من قاعدة البيانات الحالية.</p>
|
||||||
|
<a href="/auth/backup" style="display: inline-block; padding: 8px 15px; background-color: #28a745; color: white; text-decoration: none; border-radius: 4px;">إنشاء نسخة احتياطية</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 30px; padding: 15px; border: 1px solid #ddd; border-radius: 8px;">
|
||||||
|
<h3>إضافة مستخدم جديد</h3>
|
||||||
|
<form method="post" action="/auth/users/create" style="display: flex; gap: 10px; align-items: flex-end;">
|
||||||
|
<div>
|
||||||
|
<label for="username" style="display: block; margin-bottom: 5px;">اسم المستخدم</label>
|
||||||
|
<input type="text" id="username" name="username" required style="padding: 8px;">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password" style="display: block; margin-bottom: 5px;">كلمة المرور</label>
|
||||||
|
<input type="password" id="password" name="password" required style="padding: 8px;">
|
||||||
|
</div>
|
||||||
|
<button type="submit" style="padding: 9px 15px; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer;">إضافة</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding: 15px; border: 1px solid #ddd; border-radius: 8px;">
|
||||||
|
<h3>المستخدمين</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse; margin-top: 10px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background-color: #f8f9fa;">
|
||||||
|
<th style="padding: 10px; border-bottom: 2px solid #dee2e6; text-align: right;">الرقم</th>
|
||||||
|
<th style="padding: 10px; border-bottom: 2px solid #dee2e6; text-align: right;">اسم المستخدم</th>
|
||||||
|
<th style="padding: 10px; border-bottom: 2px solid #dee2e6; text-align: right;">تاريخ الإنشاء</th>
|
||||||
|
<th style="padding: 10px; border-bottom: 2px solid #dee2e6; text-align: right;">إجراءات</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user in users %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px; border-bottom: 1px solid #dee2e6;">{{ user.id }}</td>
|
||||||
|
<td style="padding: 10px; border-bottom: 1px solid #dee2e6;">{{ user.username }}</td>
|
||||||
|
<td style="padding: 10px; border-bottom: 1px solid #dee2e6;">{{ user.created_at }}</td>
|
||||||
|
<td style="padding: 10px; border-bottom: 1px solid #dee2e6;">
|
||||||
|
<form method="post" action="/auth/users/delete/{{ user.id }}" onsubmit="return confirm('هل أنت متأكد من حذف هذا المستخدم؟');" style="display: inline;">
|
||||||
|
<button type="submit" style="padding: 5px 10px; background-color: #dc3545; color: white; border: none; border-radius: 4px; cursor: pointer;">حذف</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user