UI beautification: SVG icons, polished buttons, glass navbar, refined login, animated orbs, modern cards

This commit is contained in:
Georges Haddad
2026-04-11 02:31:11 +03:00
parent 7e1e0ac426
commit 1b6d5543a6
15 changed files with 1734 additions and 504 deletions
+8 -5
View File
@@ -70,11 +70,14 @@ async def users_list(request: Request, _=Depends(get_current_user)):
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
async def add_user(request: Request, username: str = Form(...), password: str = Form(...), _=Depends(get_current_user)):
if len(password) < 8:
users = get_all_users()
return templates.TemplateResponse(request=request, name="users.html", context={"users": users, "error": "Password must be at least 8 characters."})
if get_user_by_username(username):
users = get_all_users()
return templates.TemplateResponse(request=request, name="users.html", context={"users": users, "error": f"User '{username}' already exists."})
create_user(username, password)
return RedirectResponse(url="/auth/users", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/users/delete/{user_id}")
+266 -82
View File
@@ -1,18 +1,20 @@
import json
import asyncio
from fastapi import APIRouter, Request
from fastapi import APIRouter, HTTPException, Request, status
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
from services.search_service import normalize_arabic, _normalize_scope
router = APIRouter()
templates = Jinja2Templates(directory="templates")
REVIEWABLE_DOCUMENT_STATUSES = {"extracted", "confirmed", "error"}
def _get_document(doc_id: int) -> dict | None:
with get_db() as conn:
@@ -43,6 +45,98 @@ def _get_document(doc_id: int) -> dict | None:
return doc
def _parse_optional_bool(value):
if value is None:
return None
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
normalized = str(value).strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off", ""}:
return False
return None
def _get_merge_candidates(
first_name: str,
father_name: str = "",
family_name: str = "",
search_scope: str = "",
registry_number: str = "",
) -> list[dict]:
if not first_name:
return []
first_norm = normalize_arabic(first_name.strip())
family_norm = normalize_arabic(family_name.strip()) if family_name else ""
normalized_scope = _normalize_scope(search_scope)
normalized_registry = (registry_number or "").strip()
with get_db() as conn:
if family_norm:
rows = conn.execute(
"""SELECT p.*,
COUNT(DISTINCT pr.id) AS property_count,
COUNT(DISTINCT d.id) AS document_count,
GROUP_CONCAT(DISTINCT NULLIF(TRIM(d.search_scope), '')) AS search_scopes
FROM persons p
LEFT JOIN documents d ON d.person_id = p.id
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,
COUNT(DISTINCT d.id) AS document_count,
GROUP_CONCAT(DISTINCT NULLIF(TRIM(d.search_scope), '')) AS search_scopes
FROM persons p
LEFT JOIN documents d ON d.person_id = p.id
LEFT JOIN properties pr ON pr.person_id = p.id
WHERE p.first_name_norm = ?
GROUP BY p.id""",
(first_norm,),
).fetchall()
matches = []
normalized_father = normalize_arabic(father_name.strip()) if father_name else ""
for row in rows:
person = dict(row)
if normalized_father and person.get("father_name"):
if normalize_arabic(person["father_name"]) != normalized_father:
continue
scope_values = [scope.strip() for scope in (person.get("search_scopes") or "").split(",") if scope.strip()]
same_scope = bool(normalized_scope and normalized_scope in scope_values)
registry_match = bool(
normalized_registry
and person.get("registry_number")
and person["registry_number"].strip() == normalized_registry
)
person["search_scope_list"] = scope_values
person["same_scope"] = same_scope
person["registry_match"] = registry_match
person["merge_allowed"] = same_scope or registry_match
matches.append(person)
matches.sort(
key=lambda person: (
0 if person.get("merge_allowed") else 1,
0 if person.get("same_scope") else 1,
0 if person.get("registry_match") else 1,
-(person.get("document_count") or 0),
-(person.get("property_count") or 0),
person.get("id") or 0,
)
)
return matches
@router.get("/review/next")
async def review_next():
with get_db() as conn:
@@ -100,49 +194,34 @@ async def check_duplicate(
first_name: str = "",
father_name: str = "",
family_name: str = "",
search_scope: str = "",
registry_number: 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"],
})
matches = []
for candidate in _get_merge_candidates(
first_name,
father_name,
family_name,
search_scope,
registry_number,
):
matches.append({
"id": candidate["id"],
"first_name": candidate["first_name"],
"father_name": candidate.get("father_name"),
"family_name": candidate.get("family_name"),
"family_origin": candidate.get("family_origin"),
"property_count": candidate.get("property_count", 0),
"document_count": candidate.get("document_count", 0),
"search_scopes": candidate.get("search_scope_list", []),
"same_scope": candidate.get("same_scope", False),
"registry_match": candidate.get("registry_match", False),
"merge_allowed": candidate.get("merge_allowed", False),
})
return JSONResponse({"matches": matches})
@@ -155,34 +234,104 @@ async def confirm_document(doc_id: int, request: Request):
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
if not isinstance(person_data, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid person payload")
if not isinstance(properties_data, list):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid properties payload")
first_name = (person_data.get("first_name") or "").strip()
if not first_name:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="First name is required")
registry_number = (person_data.get("registry_number") or "").strip() or None
with get_db() as conn:
current_doc = conn.execute(
"SELECT * FROM documents WHERE id=?",
(doc_id,),
).fetchone()
if not current_doc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
current_doc = dict(current_doc)
if current_doc["status"] == "pending":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Document is still being processed",
)
if current_doc["status"] not in REVIEWABLE_DOCUMENT_STATUSES:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Document status '{current_doc['status']}' cannot be confirmed",
)
def _body_or_existing(field_name: str):
if field_name in body:
value = body.get(field_name)
if isinstance(value, str):
value = value.strip()
return value or None
return current_doc.get(field_name)
request_number = _body_or_existing("request_number")
request_date = _body_or_existing("request_date")
page_info = _body_or_existing("page_info")
search_scope = _body_or_existing("search_scope")
request_purpose = _body_or_existing("request_purpose")
data_valid_until = _body_or_existing("data_valid_until")
registry_office = _body_or_existing("registry_office")
normalized_search_scope = _normalize_scope(search_scope) or ""
if "owns_properties" in body:
owns_properties = _parse_optional_bool(body.get("owns_properties"))
else:
owns_properties = current_doc.get("owns_properties")
if "declared_property_count" in body:
declared_property_count = body.get("declared_property_count")
if declared_property_count is not None and str(declared_property_count).strip() != "":
try:
declared_property_count = int(declared_property_count)
except (TypeError, ValueError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid declared property count",
)
else:
declared_property_count = None
else:
declared_property_count = current_doc.get("declared_property_count")
person_id = None
# Option 1: User explicitly chose to merge with an existing person
if merge_person_id:
person_id = int(merge_person_id)
try:
person_id = int(merge_person_id)
except (TypeError, ValueError):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid merge target")
existing_person = conn.execute(
"SELECT * FROM persons WHERE id=?",
(person_id,),
).fetchone()
if not existing_person:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Merge target not found")
allowed_merge_ids = {candidate["id"] for candidate in _get_merge_candidates(
first_name,
person_data.get("father_name") or "",
person_data.get("family_name") or "",
normalized_search_scope,
registry_number or "",
) if candidate.get("merge_allowed")}
if person_id not in allowed_merge_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Merge target is not a verified match for this search scope",
)
# Update person info with latest data
conn.execute(
"""UPDATE persons SET
@@ -241,31 +390,66 @@ async def confirm_document(doc_id: int, request: Request):
# 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
existing_person = None
if current_doc.get("person_id"):
existing_person = conn.execute(
"SELECT id FROM persons WHERE id=?",
(current_doc["person_id"],),
).fetchone()
if existing_person:
person_id = existing_person["id"]
conn.execute(
"""UPDATE persons SET
first_name=?, father_name=?, mother_name=?,
family_name=?, family_origin=?, nationality=?,
birth_date=?, registry_number=?, 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"),
registry_number,
person_data.get("registry_place"),
normalize_arabic(first_name),
normalize_arabic(person_data.get("family_name") or ""),
person_id,
),
)
else:
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):
if not isinstance(prop, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid property payload")
conn.execute(
"""INSERT INTO properties
(document_id, person_id, row_order, party_name, property_number,
+7 -7
View File
@@ -32,7 +32,10 @@ async def search(
# 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"])
selected_person = get_person_with_properties(
persons[0]["id"],
persons[0].get("search_scope"),
)
return templates.TemplateResponse(
request,
@@ -50,8 +53,8 @@ async def search(
@router.get("/persons/{person_id}")
async def person_detail(request: Request, person_id: int):
data = get_person_with_properties(person_id)
async def person_detail(request: Request, person_id: int, search_scope: str = ""):
data = get_person_with_properties(person_id, search_scope.strip() or None)
if not data:
return templates.TemplateResponse(
request,
@@ -71,16 +74,13 @@ async def person_export_csv(person_id: int, qaza: str = ""):
import io
import csv
data = get_person_with_properties(person_id)
data = get_person_with_properties(person_id, qaza.strip() or None)
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')
+2 -2
View File
@@ -1,5 +1,5 @@
import sqlite3
import hashlib
import hmac
import secrets
def hash_password(password: str) -> str:
@@ -11,7 +11,7 @@ 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
return hmac.compare_digest(hashed, stored_hash)
except Exception:
return False
+42 -20
View File
@@ -12,37 +12,53 @@ def normalize_arabic(text: str) -> str:
return text
def _normalize_scope(text: str | None) -> str | None:
if text is None:
return None
normalized = text.strip()
return normalized or None
def search_persons(query: str) -> list[dict]:
"""Search persons by name (first, family, or father name)."""
"""Search persons by name, keeping separate result rows per search scope."""
norm = normalize_arabic(query.strip())
pattern = f"%{norm}%"
raw_pattern = f"%{query.strip()}%"
scope_expr = "COALESCE(NULLIF(TRIM(d.search_scope), ''), '')"
with get_db() as conn:
rows = conn.execute(
"""
f"""
SELECT p.*,
{scope_expr} AS search_scope,
COUNT(DISTINCT pr.id) AS property_count,
COUNT(DISTINCT d.id) AS document_count,
GROUP_CONCAT(DISTINCT d.search_scope) AS search_scopes
COUNT(DISTINCT d.id) AS document_count
FROM persons p
LEFT JOIN properties pr ON pr.person_id = p.id
LEFT JOIN documents d ON d.person_id = p.id
LEFT JOIN properties pr ON pr.document_id = d.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
GROUP BY p.id, {scope_expr}
ORDER BY
CASE WHEN p.first_name_norm = ? THEN 0
WHEN p.family_name_norm = ? THEN 0
ELSE 1 END,
p.first_name
p.first_name,
p.family_name,
search_scope
""",
(pattern, pattern, raw_pattern, raw_pattern, raw_pattern, norm, norm),
).fetchall()
return [dict(r) for r in rows]
results = []
for row in rows:
person = dict(row)
person["search_scope"] = _normalize_scope(person.get("search_scope"))
results.append(person)
return results
def search_properties(
@@ -94,8 +110,9 @@ def search_properties(
return results
def get_person_with_properties(person_id: int) -> dict | None:
"""Fetch a person and all their properties."""
def get_person_with_properties(person_id: int, search_scope: str | None = None) -> dict | None:
"""Fetch a person and all their properties, optionally filtered by search scope."""
normalized_scope = _normalize_scope(search_scope)
with get_db() as conn:
person = conn.execute(
"SELECT * FROM persons WHERE id = ?", (person_id,)
@@ -103,21 +120,25 @@ def get_person_with_properties(person_id: int) -> dict | None:
if not person:
return None
props = conn.execute(
"""
props_query = """
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()
"""
props_params = [person_id]
if normalized_scope:
props_query += " AND COALESCE(NULLIF(TRIM(d.search_scope), ''), '') = ?"
props_params.append(normalized_scope)
props_query += " ORDER BY pr.real_estate_district, pr.row_order"
props = conn.execute(props_query, props_params).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()
docs_query = "SELECT id, image_path, request_number, request_date, status, page_info, search_scope FROM documents WHERE person_id = ?"
docs_params = [person_id]
if normalized_scope:
docs_query += " AND COALESCE(NULLIF(TRIM(search_scope), ''), '') = ?"
docs_params.append(normalized_scope)
docs = conn.execute(docs_query, docs_params).fetchall()
properties_list = []
doc_ids_with_props = set()
@@ -149,4 +170,5 @@ def get_person_with_properties(person_id: int) -> dict | None:
"person": dict(person),
"properties": properties_list,
"documents": docs_list,
"current_search_scope": normalized_scope,
}
+809 -171
View File
File diff suppressed because it is too large Load Diff
+148 -12
View File
@@ -1,25 +1,27 @@
/* Review page — two-column sticky layout */
.review-layout {
display: grid;
grid-template-columns: 1fr 1.4fr;
gap: 1.25rem;
grid-template-columns: minmax(320px, .92fr) minmax(0, 1.4fr);
gap: 1.4rem;
align-items: start;
}
/* Image panel */
.image-panel {
position: sticky;
top: 70px;
background: #1f2937;
border-radius: 8px;
top: 88px;
background: linear-gradient(180deg, #1a2e38, #0f1c22);
border-radius: 20px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,.2);
box-shadow: 0 16px 48px rgba(15, 28, 34, .28);
border: 1px solid rgba(255,255,255,.06);
}
.image-toolbar {
display: flex;
gap: .4rem;
padding: .5rem .75rem;
background: #111827;
gap: .35rem;
padding: .65rem .75rem;
background: rgba(10, 18, 22, .94);
border-bottom: 1px solid rgba(255,255,255,.06);
}
.image-container {
overflow: auto;
@@ -27,15 +29,24 @@
display: flex;
align-items: flex-start;
justify-content: center;
padding: .5rem;
padding: .9rem;
background:
linear-gradient(45deg, rgba(255,255,255,.03) 25%, transparent 25%),
linear-gradient(-45deg, rgba(255,255,255,.03) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(255,255,255,.03) 75%),
linear-gradient(-45deg, transparent 75%, rgba(255,255,255,.03) 75%);
background-size: 24px 24px;
background-position: 0 0, 0 12px, 12px -12px, -12px 0;
}
.doc-image {
max-width: 100%;
height: auto;
display: block;
transform-origin: top center;
transition: transform .2s;
border-radius: 4px;
transition: transform .2s cubic-bezier(.4,0,.2,1);
border-radius: 12px;
box-shadow: 0 12px 36px rgba(0,0,0,.32);
background: white;
}
/* Form panel */
@@ -43,6 +54,27 @@
min-width: 0;
}
.table-wrapper-editable {
overflow: visible;
}
.mobile-edit-table td[data-label]::before {
content: attr(data-label);
display: none;
}
.review-title {
align-items: flex-start;
}
.review-title .page-kicker {
margin-bottom: .45rem;
}
.review-title h2 {
line-height: 1.35;
}
/* ============================
Review — Responsive
============================ */
@@ -57,6 +89,107 @@
.image-container {
max-height: 50vh;
}
.review-nav {
width: 100%;
}
.review-nav .btn {
flex: 1 1 calc(50% - .5rem);
}
}
@media (max-width: 640px) {
.review-layout {
gap: 1rem;
}
.image-container {
max-height: 36vh;
padding: .7rem;
}
.image-toolbar {
padding: .65rem;
}
.review-nav .btn {
flex-basis: 100%;
}
.form-section {
padding: 1rem;
}
.table-wrapper-editable {
border: none;
box-shadow: none;
background: transparent;
margin-inline: 0;
}
.mobile-edit-table {
border-collapse: separate;
border-spacing: 0;
background: transparent;
}
.mobile-edit-table thead {
display: none;
}
.mobile-edit-table tbody,
.mobile-edit-table tr,
.mobile-edit-table td {
display: block;
width: 100%;
}
.mobile-edit-table tr {
margin-bottom: .85rem;
padding: .85rem;
border: 1px solid rgba(23,37,44,.08);
border-radius: 18px;
background: rgba(255,255,255,.96);
box-shadow: 0 10px 24px rgba(23,37,44,.08);
}
.mobile-edit-table td {
padding: 0;
border-bottom: none;
margin-bottom: .65rem;
}
.mobile-edit-table td:last-child {
margin-bottom: 0;
}
.mobile-edit-table td[data-label]::before {
display: block;
font-size: .76rem;
font-weight: 700;
color: var(--text-muted);
margin-bottom: .25rem;
}
.mobile-edit-table input {
min-width: 0;
padding: .7rem .8rem;
background: rgba(248,244,236,.72);
border: 1px solid rgba(23,37,44,.08);
}
.mobile-edit-table td[data-label="حذف"] {
margin-top: .2rem;
}
.mobile-edit-table td[data-label="حذف"] .btn-del {
width: 100%;
min-height: 42px;
border: 1px solid #fecaca;
background: #fff5f5;
border-radius: 12px;
}
.form-actions {
position: sticky;
bottom: calc(5.4rem + env(safe-area-inset-bottom));
z-index: 20;
margin-top: 1rem;
padding: .85rem;
border-radius: 18px 18px 0 0;
box-shadow: 0 -12px 24px rgba(23,37,44,.12);
background: rgba(255,255,255,.96);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
padding-bottom: calc(.85rem + env(safe-area-inset-bottom));
}
.keyboard-hint {
text-align: center;
width: 100%;
}
}
@media (max-width: 480px) {
@@ -66,4 +199,7 @@
.image-toolbar {
justify-content: center;
}
.image-panel {
border-radius: 18px;
}
}
+110 -9
View File
@@ -2,26 +2,62 @@
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<title>{% block title %}سجل العقارات اللبناني{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Kufi+Arabic:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/main.css">
{% block head %}{% endblock %}
</head>
<body>
<div class="page-backdrop" aria-hidden="true">
<span class="backdrop-orb orb-1"></span>
<span class="backdrop-orb orb-2"></span>
</div>
{% block navbar %}
<nav class="navbar">
<div class="nav-container">
<a class="nav-brand" href="/">🏠 سجل العقارات</a>
<button class="nav-toggle" id="navToggle" aria-label="القائمة">
<a class="nav-brand" href="/">
<span class="nav-brand-mark">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 21h18"/>
<path d="M5 21V7l7-4 7 4v14"/>
<path d="M9 21v-6h6v6"/>
</svg>
</span>
<span class="nav-brand-text">سجل العقارات</span>
</a>
<button class="nav-toggle" id="navToggle" aria-label="القائمة" aria-expanded="false" aria-controls="navLinks">
<span></span><span></span><span></span>
</button>
<div class="nav-links" id="navLinks">
<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>
<a href="/" class="nav-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
رفع وثائق
</a>
<a href="/review/next" class="nav-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
مراجعة
</a>
<a href="/search" class="nav-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
بحث
</a>
<a href="/documents" class="nav-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
قائمة الوثائق
</a>
<a href="/auth/users" class="nav-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15c-4 0-7 2-7 4v1h14v-1c0-2-3-4-7-4z"/><circle cx="12" cy="8" r="4"/></svg>
الإدارة
</a>
<a href="/auth/logout" class="nav-link nav-link-logout">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
خروج
</a>
</div>
</div>
</nav>
@@ -31,6 +67,33 @@
{% block content %}{% endblock %}
</main>
<nav class="mobile-dock" aria-label="التنقل السريع">
<a href="/" class="mobile-dock-link" data-match="/">
<span class="mobile-dock-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
</span>
<span>رفع</span>
</a>
<a href="/review/next" class="mobile-dock-link" data-match="/review">
<span class="mobile-dock-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
</span>
<span>مراجعة</span>
</a>
<a href="/search" class="mobile-dock-link" data-match="/search,/persons">
<span class="mobile-dock-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</span>
<span>بحث</span>
</a>
<a href="/documents" class="mobile-dock-link" data-match="/documents">
<span class="mobile-dock-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
</span>
<span>وثائق</span>
</a>
</nav>
<script>
const navToggle = document.getElementById('navToggle');
const navLinks = document.getElementById('navLinks');
@@ -38,8 +101,46 @@
navToggle.addEventListener('click', () => {
navLinks.classList.toggle('open');
navToggle.classList.toggle('active');
navToggle.setAttribute('aria-expanded', String(navLinks.classList.contains('open')));
});
navLinks.querySelectorAll('a').forEach(link => {
link.addEventListener('click', () => {
navLinks.classList.remove('open');
navToggle.classList.remove('active');
navToggle.setAttribute('aria-expanded', 'false');
});
});
}
document.addEventListener('click', event => {
document.querySelectorAll('.export-dropdown').forEach(dropdown => {
if (!dropdown.contains(event.target)) {
dropdown.querySelector('.export-menu')?.classList.remove('show');
}
});
});
document.querySelectorAll('[data-export-toggle]').forEach(button => {
button.addEventListener('click', () => {
button.nextElementSibling?.classList.toggle('show');
});
});
const currentPath = window.location.pathname;
const mobileDock = document.querySelector('.mobile-dock');
if (currentPath.startsWith('/auth/login')) {
mobileDock?.setAttribute('hidden', 'hidden');
}
document.querySelectorAll('.mobile-dock-link').forEach(link => {
const patterns = (link.dataset.match || '').split(',').filter(Boolean);
const isRoot = patterns.includes('/') && currentPath === '/';
const isMatched = isRoot || patterns.some(pattern => pattern !== '/' && currentPath.startsWith(pattern));
if (isMatched) {
link.classList.add('active');
}
});
</script>
{% block scripts %}{% endblock %}
</body>
+19 -12
View File
@@ -3,8 +3,15 @@
{% block content %}
<div class="page-header">
<h1>قائمة الوثائق</h1>
<a href="/" class="btn btn-primary">+ رفع وثائق جديدة</a>
<div>
<span class="page-kicker">متابعة المعالجة</span>
<h1>قائمة الوثائق</h1>
<p class="subtitle">تابع حالة كل وثيقة وانتقل مباشرة إلى شاشة المراجعة عند الحاجة.</p>
</div>
<a href="/" class="btn btn-primary">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
رفع وثائق جديدة
</a>
</div>
{% if uploaded %}
@@ -33,12 +40,12 @@
{% if stats.pending_review %}
<div class="alert-banner">
<a href="/review/next">ابدأ مراجعة {{ stats.pending_review }} وثيقة</a>
<a href="/review/next">ابدأ مراجعة {{ stats.pending_review }} وثيقة</a>
</div>
{% endif %}
<div class="table-wrapper">
<table class="results-table">
<table class="results-table responsive-table">
<thead>
<tr>
<th>#</th>
@@ -53,21 +60,21 @@
<tbody>
{% for d in documents %}
<tr>
<td>{{ d.id }}</td>
<td>
<a href="/review/{{ d.id }}">
<td data-label="#">{{ d.id }}</td>
<td data-label="الصورة">
<a href="/review/{{ d.id }}" title="فتح الوثيقة {{ d.id }} للمراجعة" aria-label="فتح الوثيقة {{ d.id }} للمراجعة">
<img src="/uploads/{{ d.image_path }}" class="doc-thumb-mini" alt="">
</a>
</td>
<td>
<td data-label="الشخص">
{% 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">
<td data-label="رقم الطلب">{{ d.request_number or '—' }}</td>
<td data-label="الحالة"><span class="status-badge status-{{ d.status }}">{{ d.status }}</span></td>
<td data-label="التاريخ">{{ d.created_at[:10] if d.created_at else '' }}</td>
<td data-label="الإجراءات" class="doc-actions">
<a href="/review/{{ d.id }}" class="btn btn-sm btn-secondary">
{% if d.status == 'confirmed' %}عرض{% else %}مراجعة{% endif %}
</a>
+38 -12
View File
@@ -2,9 +2,16 @@
{% block title %}رفع وثائق — سجل العقارات{% endblock %}
{% block content %}
<div class="page-header">
<h1>بطاقات معلومات الملكية العقارية</h1>
<p class="subtitle">ارفع صور أو ملفات PDF للوثائق — اختر محرك الاستخراج (مجاني أو ذكاء اصطناعي)</p>
<div class="page-header page-header-hero">
<div>
<span class="page-kicker">منصة الأرشفة والمراجعة</span>
<h1>بطاقات معلومات الملكية العقارية</h1>
<p class="subtitle">ارفع صور أو ملفات PDF، ثم راجع النتائج واستكمل البحث من واجهة واحدة واضحة وسريعة.</p>
</div>
<div class="header-actions">
<a href="/search" class="btn btn-secondary">فتح البحث</a>
<a href="/documents" class="btn btn-primary">عرض الوثائق</a>
</div>
</div>
{% if stats and stats.total %}
@@ -16,16 +23,24 @@
</div>
{% if stats.pending_review %}
<div class="alert-banner">
<a href="/review/next">ابدأ مراجعة {{ stats.pending_review }} وثيقة</a>
<a href="/review/next">ابدأ مراجعة {{ stats.pending_review }} وثيقة</a>
</div>
{% endif %}
{% endif %}
<div class="upload-card">
<div class="card-heading">
<div>
<h2>رفع ملفات جديدة</h2>
<p>اختر دفعة صور أو PDF، ثم حدّد محرك الاستخراج قبل بدء المعالجة.</p>
</div>
</div>
<form id="uploadForm" action="/upload" method="post" enctype="multipart/form-data">
<input type="file" name="files" id="fileInput" multiple accept=".jpg,.jpeg,.png,.webp,.pdf" class="file-input">
<label for="fileInput" class="drop-zone" id="dropZone">
<div class="drop-icon">📄</div>
<div class="drop-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
</div>
<p class="drop-text">اسحب الملفات هنا أو انقر للاختيار</p>
<p class="drop-hint">JPG، PNG، PDF — يمكن رفع عدة ملفات دفعة واحدة</p>
</label>
@@ -51,7 +66,8 @@
<div id="uploadActions" class="upload-actions hidden">
<button type="submit" class="btn btn-primary btn-large">
⬆ رفع ومعالجة
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
رفع ومعالجة
</button>
<button type="button" class="btn btn-secondary" onclick="clearFiles()">إلغاء</button>
</div>
@@ -60,12 +76,22 @@
<div class="quick-links">
<a href="/search" class="quick-link-card">
<span class="ql-icon">🔍</span>
<span>البحث في قاعدة البيانات</span>
<span class="ql-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</span>
<span>
<strong>البحث في قاعدة البيانات</strong>
<small>ابحث بالأسماء أو بالعقارات مع نتائج منظّمة.</small>
</span>
</a>
<a href="/documents" class="quick-link-card">
<span class="ql-icon">📋</span>
<span>قائمة جميع الوثائق</span>
<span class="ql-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
</span>
<span>
<strong>قائمة جميع الوثائق</strong>
<small>تابع الحالات، افتح المراجعة، أو احذف المستندات غير المطلوبة.</small>
</span>
</a>
</div>
{% endblock %}
@@ -96,7 +122,7 @@ 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>`
`<div class="file-item">${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');
@@ -112,7 +138,7 @@ function clearFiles() {
}
document.getElementById('uploadForm').addEventListener('submit', function() {
document.querySelector('.btn-primary').textContent = 'جارٍ الرفع...';
document.querySelector('.btn-primary').textContent = 'جارٍ الرفع...';
document.querySelector('.btn-primary').disabled = true;
});
</script>
+12 -2
View File
@@ -6,7 +6,17 @@
{% block content %}
<div class="login-card">
<h2>🏠 تسجيل الدخول</h2>
<div class="login-logo">
<span class="login-logo-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 21h18"/>
<path d="M5 21V7l7-4 7 4v14"/>
<path d="M9 21v-6h6v6"/>
</svg>
</span>
</div>
<h2>تسجيل الدخول</h2>
<p class="login-subtitle">أدخل بياناتك للوصول إلى سجل العقارات</p>
{% if error %}
<div class="login-error">{{ error }}</div>
@@ -23,7 +33,7 @@
<input type="password" id="password" name="password" required placeholder="أدخل كلمة المرور">
</div>
<button type="submit" class="btn btn-primary btn-large" style="width:100%; justify-content:center; margin-top:.5rem;">
<button type="submit" class="btn btn-primary btn-large btn-block login-submit">
دخول
</button>
</form>
+33 -34
View File
@@ -4,18 +4,28 @@
{% block content %}
<div class="page-header">
<div>
<a href="/search" class="back-link">← العودة للبحث</a>
<a href="/search" class="back-link no-print">← العودة للبحث</a>
<h1>{{ person.first_name }} {{ person.father_name or '' }} {{ person.family_name or '' }}</h1>
{% if current_search_scope %}
<p class="subtitle">عرض نتائج نطاق البحث: {{ current_search_scope }}</p>
{% endif %}
</div>
<div>
<div class="header-actions no-print">
<button type="button" class="btn btn-secondary" onclick="window.print()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
طباعة
</button>
{% set scopes = documents | selectattr('search_scope') | map(attribute='search_scope') | unique | list %}
<div class="export-dropdown">
<button class="btn btn-primary" onclick="this.nextElementSibling.classList.toggle('show')">📥 تصدير كملف إكسل ▾</button>
<button type="button" class="btn btn-primary" data-export-toggle>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
تصدير كملف إكسل ▾
</button>
<div class="export-menu">
<a href="/persons/{{ person.id }}/export" class="export-option" download>📥 تصدير الكل</a>
<a href="/persons/{{ person.id }}/export" class="export-option" download>تصدير الكل</a>
{% for scope in scopes %}
{% if scope %}
<a href="/persons/{{ person.id }}/export?qaza={{ scope|urlencode }}" class="export-option" download>📥 {{ scope }}</a>
<a href="/persons/{{ person.id }}/export?qaza={{ scope|urlencode }}" class="export-option" download>{{ scope }}</a>
{% endif %}
{% endfor %}
</div>
@@ -35,6 +45,11 @@
{% 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>
{% if current_search_scope %}
<div class="person-actions no-print">
<a href="/persons/{{ person.id }}" class="btn btn-secondary">عرض كل النطاقات</a>
</div>
{% endif %}
</div>
<div class="section-header">
@@ -43,7 +58,7 @@
{% if properties %}
<div class="table-wrapper">
<table class="results-table props-full">
<table class="results-table props-full responsive-table">
<thead>
<tr>
<th>#</th>
@@ -59,29 +74,29 @@
</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 class="{{ 'ownership-alert' if pr.ownership_type == 'لا يملك' else '' }}">
<td data-label="#" class="row-num">{{ loop.index }}</td>
<td data-label="اسم الفريق">{{ pr.party_name or '' }}</td>
<td data-label="رقم العقار" class="prop-num">{{ pr.property_number or '' }}</td>
<td data-label="القسم">{{ pr.section or '' }}</td>
<td data-label="البلوك">{{ pr.block or '' }}</td>
<td data-label="المنطقة العقارية">{{ pr.real_estate_district or '' }}</td>
<td data-label="القضاء">{{ pr.qaza or '' }}</td>
<td data-label="عدد الأسهم">{{ pr.num_shares or '' }}</td>
<td data-label="نوع الملكية">{{ pr.ownership_type or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="error-banner" style="justify-content:center;">
<div class="error-banner centered-banner">
⚠️ نتيجة البحث: لا يملك أي عقار في نطاق البحث المذكور.
</div>
{% endif %}
{% if documents %}
<div class="docs-section">
<div class="docs-section no-print">
<h3>الوثائق الأصلية</h3>
<div class="doc-thumbnails">
{% for d in documents %}
@@ -101,19 +116,3 @@
{% 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 %}
+140 -67
View File
@@ -8,13 +8,19 @@
{% block content %}
<div class="review-header">
<div class="review-title">
<h2>مراجعة الوثيقة #{{ doc.id }}</h2>
<div>
<span class="page-kicker">المراجعة والتحقق</span>
<h2>مراجعة الوثيقة #{{ doc.id }}</h2>
</div>
<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>
<button class="btn btn-danger btn-sm" onclick="deleteDoc({{ doc.id }})">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
حذف الوثيقة
</button>
</div>
</div>
@@ -29,9 +35,15 @@
<!-- 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>
<button type="button" onclick="zoomIn()" class="btn btn-sm btn-secondary" title="تكبير">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
</button>
<button type="button" onclick="zoomOut()" class="btn btn-sm btn-secondary" title="تصغير">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
</button>
<button type="button" onclick="resetZoom()" class="btn btn-sm btn-secondary" title="إعادة ضبط">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
</button>
</div>
<div class="image-container" id="imageContainer">
<img id="docImage" src="/uploads/{{ doc.image_path }}" alt="وثيقة" class="doc-image">
@@ -47,46 +59,47 @@
<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>
<label for="first_name">الاسم *</label>
<input id="first_name" type="text" name="first_name" value="{{ doc.person.get('first_name') or '' }}" title="الاسم" required>
</div>
<div class="form-group">
<label>اسم الأب</label>
<input type="text" name="father_name" value="{{ doc.person.get('father_name') or '' }}">
<label for="father_name">اسم الأب</label>
<input id="father_name" type="text" name="father_name" value="{{ doc.person.get('father_name') or '' }}" title="اسم الأب">
</div>
<div class="form-group">
<label>اسم الأم</label>
<input type="text" name="mother_name" value="{{ doc.person.get('mother_name') or '' }}">
<label for="mother_name">اسم الأم</label>
<input id="mother_name" type="text" name="mother_name" value="{{ doc.person.get('mother_name') or '' }}" title="اسم الأم">
</div>
<div class="form-group">
<label>اللقب</label>
<input type="text" name="family_name" value="{{ doc.person.get('family_name') or '' }}">
<label for="family_name">اللقب</label>
<input id="family_name" type="text" name="family_name" value="{{ doc.person.get('family_name') or '' }}" title="اللقب">
</div>
<div class="form-group">
<label>الهوا / المنشأ</label>
<input type="text" name="family_origin" value="{{ doc.person.get('family_origin') or '' }}">
<label for="family_origin">الهوا / المنشأ</label>
<input id="family_origin" type="text" name="family_origin" value="{{ doc.person.get('family_origin') or '' }}" title="الهوا أو المنشأ">
</div>
<div class="form-group">
<label>الجنسية</label>
<input type="text" name="nationality" value="{{ doc.person.get('nationality') or '' }}">
<label for="nationality">الجنسية</label>
<input id="nationality" type="text" name="nationality" value="{{ doc.person.get('nationality') or '' }}" title="الجنسية">
</div>
<div class="form-group">
<label>تاريخ الولادة</label>
<input type="text" name="birth_date" value="{{ doc.person.get('birth_date') or '' }}">
<label for="birth_date">تاريخ الولادة</label>
<input id="birth_date" type="text" name="birth_date" value="{{ doc.person.get('birth_date') or '' }}" title="تاريخ الولادة">
</div>
<div class="form-group">
<label>رقم السجل</label>
<input type="text" name="registry_number" value="{{ doc.person.get('registry_number') or '' }}">
<label for="registry_number">رقم السجل</label>
<input id="registry_number" type="text" name="registry_number" value="{{ doc.person.get('registry_number') or '' }}" title="رقم السجل">
</div>
<div class="form-group">
<label>مكان السجل</label>
<input type="text" name="registry_place" value="{{ doc.person.get('registry_place') or '' }}">
<label for="registry_place">مكان السجل</label>
<input id="registry_place" type="text" name="registry_place" value="{{ doc.person.get('registry_place') or '' }}" title="مكان السجل">
</div>
</div>
<!-- Merge suggestion (populated by JS) -->
<div id="mergeSuggestion" class="merge-banner hidden">
<h4>شخص مشابه موجود في قاعدة البيانات:</h4>
<p id="mergeScopeNotice" class="merge-note hidden"></p>
<div id="mergeMatches"></div>
<div class="merge-actions">
<label><input type="radio" name="merge_action" value="merge" checked> دمج مع الشخص الموجود</label>
@@ -101,20 +114,20 @@
<h3>بيانات الطلب</h3>
<div class="form-grid">
<div class="form-group">
<label>رقم الطلب</label>
<input type="text" name="request_number" value="{{ doc.request_number or '' }}">
<label for="request_number">رقم الطلب</label>
<input id="request_number" type="text" name="request_number" value="{{ doc.request_number or '' }}" title="رقم الطلب">
</div>
<div class="form-group">
<label>تاريخ الطلب</label>
<input type="text" name="request_date" value="{{ doc.request_date or '' }}">
<label for="request_date">تاريخ الطلب</label>
<input id="request_date" type="text" name="request_date" value="{{ doc.request_date or '' }}" title="تاريخ الطلب">
</div>
<div class="form-group">
<label>معلومات الصفحة</label>
<input type="text" name="page_info" value="{{ doc.page_info or '' }}" placeholder="مثال: 1 من 3">
<label for="page_info">معلومات الصفحة</label>
<input id="page_info" type="text" name="page_info" value="{{ doc.page_info or '' }}" title="معلومات الصفحة" placeholder="مثال: 1 من 3">
</div>
<div class="form-group">
<label>نطاق البحث (القضاء)</label>
<input type="text" name="search_scope" value="{{ doc.search_scope or '' }}" placeholder="مثال: المتن">
<label for="search_scope">نطاق البحث (القضاء)</label>
<input id="search_scope" type="text" name="search_scope" value="{{ doc.search_scope or '' }}" title="نطاق البحث" placeholder="مثال: المتن">
</div>
</div>
</section>
@@ -125,8 +138,8 @@
<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">
<div class="table-wrapper table-wrapper-editable">
<table id="propertiesTable" class="props-table mobile-edit-table">
<thead>
<tr>
<th>اسم الفريق</th>
@@ -143,15 +156,15 @@
<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>
<td data-label="اسم الفريق"><input type="text" value="{{ prop.party_name or '' }}" title="اسم الفريق" aria-label="اسم الفريق"></td>
<td data-label="رقم العقار"><input type="text" value="{{ prop.property_number or '' }}" title="رقم العقار" aria-label="رقم العقار"></td>
<td data-label="القسم"><input type="text" value="{{ prop.section or '' }}" title="القسم" aria-label="القسم"></td>
<td data-label="البلوك"><input type="text" value="{{ prop.block or '' }}" title="البلوك" aria-label="البلوك"></td>
<td data-label="المنطقة العقارية"><input type="text" value="{{ prop.real_estate_district or '' }}" title="المنطقة العقارية" aria-label="المنطقة العقارية"></td>
<td data-label="القضاء"><input type="text" value="{{ prop.qaza or '' }}" title="القضاء" aria-label="القضاء"></td>
<td data-label="عدد الأسهم"><input type="text" value="{{ prop.num_shares or '' }}" title="عدد الأسهم" aria-label="عدد الأسهم"></td>
<td data-label="نوع الملكية"><input type="text" value="{{ prop.ownership_type or '' }}" title="نوع الملكية" aria-label="نوع الملكية"></td>
<td data-label="حذف"><button type="button" class="btn-del" onclick="removeRow(this)"></button></td>
</tr>
{% endfor %}
</tbody>
@@ -162,7 +175,8 @@
<!-- Actions -->
<div class="form-actions">
<button type="button" class="btn btn-primary btn-large" onclick="confirmDocument()" id="confirmBtn">
✓ تأكيد والانتقال للتالية
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
تأكيد والانتقال للتالية
</button>
<span class="keyboard-hint">أو اضغط Ctrl+Enter</span>
</div>
@@ -181,15 +195,15 @@ 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>
<td data-label="اسم الفريق"><input type="text" value="" title="اسم الفريق" aria-label="اسم الفريق"></td>
<td data-label="رقم العقار"><input type="text" value="" title="رقم العقار" aria-label="رقم العقار"></td>
<td data-label="القسم"><input type="text" value="" title="القسم" aria-label="القسم"></td>
<td data-label="البلوك"><input type="text" value="" title="البلوك" aria-label="البلوك"></td>
<td data-label="المنطقة العقارية"><input type="text" value="" title="المنطقة العقارية" aria-label="المنطقة العقارية"></td>
<td data-label="القضاء"><input type="text" value="" title="القضاء" aria-label="القضاء"></td>
<td data-label="عدد الأسهم"><input type="text" value="" title="عدد الأسهم" aria-label="عدد الأسهم"></td>
<td data-label="نوع الملكية"><input type="text" value="" title="نوع الملكية" aria-label="نوع الملكية"></td>
<td data-label="حذف"><button type="button" class="btn-del" onclick="removeRow(this)">✕</button></td>
`;
tbody.appendChild(tr);
tr.querySelector('input').focus();
@@ -252,25 +266,68 @@ function checkDuplicate() {
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 scope = form.querySelector('[name=search_scope]').value.trim();
const registry = form.querySelector('[name=registry_number]').value.trim();
const banner = document.getElementById('mergeSuggestion');
const matchesDiv = document.getElementById('mergeMatches');
const mergeIdInput = document.getElementById('mergePersonId');
const scopeNotice = document.getElementById('mergeScopeNotice');
const mergeRadio = form.querySelector('[name=merge_action][value=merge]');
const newRadio = form.querySelector('[name=merge_action][value=new]');
if (!first) {
banner.classList.add('hidden');
matchesDiv.innerHTML = '';
mergeIdInput.value = '';
mergeRadio.disabled = false;
scopeNotice.classList.add('hidden');
newRadio.checked = true;
return;
}
const params = new URLSearchParams({
first_name: first,
father_name: father,
family_name: family,
search_scope: scope,
registry_number: registry,
});
const res = await fetch('/api/check-duplicate?' + params);
const data = await res.json();
if (data.matches && data.matches.length > 0) {
matchesDiv.innerHTML = data.matches.map(m =>
`<div class="merge-person" data-person-id="${m.id}">
const firstAllowed = data.matches.find(m => m.merge_allowed);
matchesDiv.innerHTML = data.matches.map(m => {
const status = m.registry_match
? 'مطابقة رقم سجل'
: (m.same_scope ? 'نفس نطاق البحث' : 'نطاق مختلف');
const scopes = (m.search_scopes || []).length ? (m.search_scopes || []).join('، ') : 'غير محدد';
const safeClass = m.merge_allowed ? 'merge-person-safe' : 'merge-person-unsafe';
const disabledAttr = m.merge_allowed ? '' : 'data-merge-disabled="true"';
return `<div class="merge-person ${safeClass}" data-person-id="${m.id}" ${disabledAttr}>
<span class="mp-name">${m.first_name} ${m.father_name||''} ${m.family_name||''}</span>
<span class="mp-count">${m.property_count} عقار</span>
<span class="mp-count">${m.document_count || 0} طلب</span>
${m.family_origin ? '<span class="mp-count">'+m.family_origin+'</span>' : ''}
</div>`
).join('');
mergeIdInput.value = data.matches[0].id;
<span class="mp-count">${status}</span>
<span class="mp-count">النطاقات: ${scopes}</span>
</div>`;
}).join('');
if (firstAllowed) {
mergeIdInput.value = firstAllowed.id;
mergeRadio.checked = true;
mergeRadio.disabled = false;
scopeNotice.textContent = 'تم تفعيل الدمج فقط للمرشحين ضمن نفس نطاق البحث أو عند تطابق رقم السجل.';
scopeNotice.classList.remove('hidden');
} else {
mergeIdInput.value = '';
newRadio.checked = true;
mergeRadio.disabled = true;
scopeNotice.textContent = 'المرشحون الموجودون يحملون الاسم نفسه لكن في نطاقات بحث مختلفة. سيتم إنشاء شخص جديد ما لم يوجد تطابق برقم السجل.';
scopeNotice.classList.remove('hidden');
}
banner.classList.remove('hidden');
// Click to select a match
@@ -278,19 +335,35 @@ function checkDuplicate() {
el.addEventListener('click', () => {
matchesDiv.querySelectorAll('.merge-person').forEach(e => e.style.outline = '');
el.style.outline = '2px solid var(--primary)';
if (el.dataset.mergeDisabled === 'true') {
mergeIdInput.value = '';
newRadio.checked = true;
mergeRadio.disabled = true;
return;
}
mergeIdInput.value = el.dataset.personId;
form.querySelector('[name=merge_action][value=merge]').checked = true;
mergeRadio.disabled = false;
mergeRadio.checked = true;
});
});
if (firstAllowed) {
const selected = matchesDiv.querySelector(`[data-person-id="${firstAllowed.id}"]`);
if (selected) {
selected.style.outline = '2px solid var(--primary)';
}
}
} else {
banner.classList.add('hidden');
mergeIdInput.value = '';
mergeRadio.disabled = false;
scopeNotice.classList.add('hidden');
}
}, 500);
}
// Attach duplicate check to name fields
document.querySelectorAll('[name=first_name],[name=father_name],[name=family_name]').forEach(input => {
document.querySelectorAll('[name=first_name],[name=father_name],[name=family_name],[name=search_scope],[name=registry_number]').forEach(input => {
input.addEventListener('blur', checkDuplicate);
});
// Run once on load
@@ -300,7 +373,7 @@ checkDuplicate();
async function confirmDocument() {
const btn = document.getElementById('confirmBtn');
btn.disabled = true;
btn.textContent = 'جارٍ الحفظ...';
btn.textContent = 'جارٍ الحفظ...';
try {
const formData = getFormData();
@@ -321,7 +394,7 @@ async function confirmDocument() {
if (data.ok) {
window.location.href = data.next;
} else {
alert('حدث خطأ: ' + JSON.stringify(data));
alert('حدث خطأ: ' + (data.detail || JSON.stringify(data)));
btn.disabled = false;
btn.textContent = '✓ تأكيد والانتقال للتالية';
}
+77 -59
View File
@@ -3,21 +3,36 @@
{% block content %}
<div class="page-header">
<h1>البحث في قاعدة البيانات</h1>
<div>
<span class="page-kicker">البحث والاستكشاف</span>
<h1>البحث في قاعدة البيانات</h1>
<p class="subtitle">اختر طريقة البحث المناسبة ثم انتقل مباشرة إلى سجل الشخص أو العقار المطلوب.</p>
</div>
{% if persons or properties %}
<div class="header-actions no-print">
<button type="button" class="btn btn-secondary" onclick="window.print()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
طباعة
</button>
</div>
{% endif %}
</div>
<div class="search-card">
<div class="search-card no-print">
<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>
<button type="button" class="tab-btn active" data-tab="person">بحث بالاسم</button>
<button type="button" class="tab-btn" data-tab="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>
<button type="submit" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
بحث
</button>
</div>
</div>
@@ -36,7 +51,10 @@
<input type="text" name="block" value="{{ block }}" placeholder="رقم البلوك">
</div>
</div>
<button type="submit" class="btn btn-primary">🔍 بحث</button>
<button type="submit" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
بحث
</button>
</div>
</form>
</div>
@@ -61,16 +79,23 @@
{% 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>
<div class="person-actions">
<a href="/persons/{{ selected_person.person.id }}{% if selected_person.current_search_scope %}?search_scope={{ selected_person.current_search_scope|urlencode }}{% endif %}" class="btn btn-secondary no-print">عرض التفاصيل كاملة</a>
<button type="button" class="btn btn-secondary no-print" onclick="window.print()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
طباعة
</button>
{% set scopes = selected_person.documents | selectattr('search_scope') | map(attribute='search_scope') | unique | list %}
<div class="export-dropdown">
<button class="btn btn-primary" onclick="this.nextElementSibling.classList.toggle('show')">📥 تصدير كملف إكسل ▾</button>
<div class="export-dropdown no-print">
<button type="button" class="btn btn-primary" data-export-toggle>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
تصدير كملف إكسل ▾
</button>
<div class="export-menu">
<a href="/persons/{{ selected_person.person.id }}/export" class="export-option" download>📥 تصدير الكل</a>
<a href="/persons/{{ selected_person.person.id }}/export" class="export-option" download>تصدير الكل</a>
{% for scope in scopes %}
{% if scope %}
<a href="/persons/{{ selected_person.person.id }}/export?qaza={{ scope|urlencode }}" class="export-option" download>📥 {{ scope }}</a>
<a href="/persons/{{ selected_person.person.id }}/export?qaza={{ scope|urlencode }}" class="export-option" download>{{ scope }}</a>
{% endif %}
{% endfor %}
</div>
@@ -79,8 +104,8 @@
</div>
{% if selected_person.properties %}
<div class="table-wrapper" style="margin-top:1rem">
<table class="results-table">
<div class="table-wrapper table-wrapper-spaced">
<table class="results-table responsive-table">
<thead>
<tr>
<th>#</th>
@@ -96,42 +121,41 @@
</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 class="{{ 'ownership-alert' if pr.ownership_type == 'لا يملك' else '' }}">
<td data-label="#">{{ loop.index }}</td>
<td data-label="اسم الفريق">{{ pr.party_name or '' }}</td>
<td data-label="رقم العقار">{{ pr.property_number or '' }}</td>
<td data-label="القسم">{{ pr.section or '' }}</td>
<td data-label="البلوك">{{ pr.block or '' }}</td>
<td data-label="المنطقة العقارية">{{ pr.real_estate_district or '' }}</td>
<td data-label="القضاء">{{ pr.qaza or '' }}</td>
<td data-label="عدد الأسهم">{{ pr.num_shares or '' }}</td>
<td data-label="نوع الملكية">{{ pr.ownership_type or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="error-banner" style="margin-top:1rem; justify-content:center;">
<div class="error-banner centered-banner banner-spaced">
⚠️ نتيجة البحث: لا يملك أي عقار في القضاء المذكور.
</div>
{% endif %}
{% else %}
<div class="persons-grid">
{% for p in persons %}
<a href="/persons/{{ p.id }}" class="person-card">
<a href="/persons/{{ p.id }}{% if p.search_scope %}?search_scope={{ p.search_scope|urlencode }}{% endif %}" 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>
<span class="person-meta-alert">لا يملك عقارات</span>
{% endif %}
</div>
{% if p.search_scopes %}
<div class="person-reg">نطاق البحث: {{ p.search_scopes | replace(',', '، ') }}</div>
{% endif %}
<div class="person-reg">نطاق البحث: {{ p.search_scope or 'غير محدد' }}</div>
{% if p.document_count %}<div class="person-reg">عدد الطلبات: {{ p.document_count }}</div>{% endif %}
{% if p.registry_number %}
<div class="person-reg">سجل: {{ p.registry_number }}</div>
{% endif %}
@@ -147,7 +171,7 @@
<div class="results-section">
<h2>نتائج البحث بالعقار ({{ properties|length }} سجل)</h2>
<div class="table-wrapper">
<table class="results-table">
<table class="results-table responsive-table">
<thead>
<tr>
<th>المالك</th>
@@ -164,19 +188,19 @@
<tbody>
{% for pr in properties %}
<tr>
<td>
<td data-label="المالك">
{% 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>
<td data-label="اسم الفريق">{{ pr.party_name or '' }}</td>
<td data-label="رقم العقار" class="prop-num">{{ pr.property_number or '' }}</td>
<td data-label="القسم">{{ pr.section or '' }}</td>
<td data-label="البلوك">{{ pr.block or '' }}</td>
<td data-label="المنطقة العقارية">{{ pr.real_estate_district or '' }}</td>
<td data-label="القضاء">{{ pr.qaza or '' }}</td>
<td data-label="عدد الأسهم">{{ pr.num_shares or '' }}</td>
<td data-label="نوع الملكية">{{ pr.ownership_type or '' }}</td>
</tr>
{% endfor %}
</tbody>
@@ -196,35 +220,29 @@
{% block scripts %}
<script>
function showTab(tab) {
function showTab(tab, button = null) {
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');
if (button) {
button.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', () => {
document.querySelectorAll('.tab-btn').forEach(button => {
button.addEventListener('click', () => showTab(button.dataset.tab, button));
});
const params = new URLSearchParams(location.search);
const personButton = document.querySelector('.tab-btn[data-tab="person"]');
const propertyButton = document.querySelector('.tab-btn[data-tab="property"]');
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');
showTab('property', propertyButton);
} else {
showTab('person', personButton);
document.getElementById('qInput').focus();
}
});
+23 -10
View File
@@ -5,7 +5,11 @@
{% block content %}
<div class="admin-container">
<div class="page-header">
<h1>إدارة النظام والمستخدمين</h1>
<div>
<span class="page-kicker">الإعدادات والإدارة</span>
<h1>إدارة النظام والمستخدمين</h1>
<p class="subtitle">أنشئ نسخاً احتياطية وأدر حسابات الدخول من مكان واحد.</p>
</div>
</div>
{% if backup_msg %}
@@ -13,13 +17,19 @@
{% endif %}
<div class="admin-section">
<h3>💾 نسخ احتياطي لقاعدة البيانات</h3>
<h3>
<svg class="section-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
نسخ احتياطي لقاعدة البيانات
</h3>
<p>قم بإنشاء نسخة احتياطية من قاعدة البيانات الحالية.</p>
<a href="/auth/backup" class="btn btn-success">إنشاء نسخة احتياطية</a>
</div>
<div class="admin-section">
<h3> إضافة مستخدم جديد</h3>
<h3>
<svg class="section-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
إضافة مستخدم جديد
</h3>
<form method="post" action="/auth/users/create" class="add-user-form">
<div class="form-group">
<label for="username">اسم المستخدم</label>
@@ -34,9 +44,12 @@
</div>
<div class="admin-section">
<h3>👥 المستخدمين</h3>
<h3>
<svg class="section-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
المستخدمين
</h3>
<div class="table-wrapper">
<table class="results-table">
<table class="results-table responsive-table">
<thead>
<tr>
<th>الرقم</th>
@@ -48,11 +61,11 @@
<tbody>
{% for user in users %}
<tr>
<td>{{ user.id }}</td>
<td>{{ user.username }}</td>
<td>{{ user.created_at }}</td>
<td>
<form method="post" action="/auth/users/delete/{{ user.id }}" onsubmit="return confirm('هل أنت متأكد من حذف هذا المستخدم؟');" style="display:inline;">
<td data-label="الرقم">{{ user.id }}</td>
<td data-label="اسم المستخدم">{{ user.username }}</td>
<td data-label="تاريخ الإنشاء">{{ user.created_at }}</td>
<td data-label="الإجراءات">
<form method="post" action="/auth/users/delete/{{ user.id }}" onsubmit="return confirm('هل أنت متأكد من حذف هذا المستخدم؟');" class="inline-form">
<button type="submit" class="btn btn-sm btn-danger">حذف</button>
</form>
</td>