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}) return templates.TemplateResponse(request=request, name="users.html", context={"users": users})
@router.post("/users/create") @router.post("/users/create")
async def add_user(username: str = Form(...), password: str = Form(...), _=Depends(get_current_user)): async def add_user(request: Request, username: str = Form(...), password: str = Form(...), _=Depends(get_current_user)):
try: if len(password) < 8:
create_user(username, password) users = get_all_users()
except Exception: return templates.TemplateResponse(request=request, name="users.html", context={"users": users, "error": "Password must be at least 8 characters."})
pass # Probably duplicate 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) return RedirectResponse(url="/auth/users", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/users/delete/{user_id}") @router.post("/users/delete/{user_id}")
+266 -82
View File
@@ -1,18 +1,20 @@
import json import json
import asyncio import asyncio
from fastapi import APIRouter, Request from fastapi import APIRouter, HTTPException, Request, status
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from config import UPLOAD_DIR from config import UPLOAD_DIR
from database.connection import get_db from database.connection import get_db
from services.extractor import extract_document from services.extractor import extract_document
from services.search_service import normalize_arabic from services.search_service import normalize_arabic, _normalize_scope
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
REVIEWABLE_DOCUMENT_STATUSES = {"extracted", "confirmed", "error"}
def _get_document(doc_id: int) -> dict | None: def _get_document(doc_id: int) -> dict | None:
with get_db() as conn: with get_db() as conn:
@@ -43,6 +45,98 @@ def _get_document(doc_id: int) -> dict | None:
return doc 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") @router.get("/review/next")
async def review_next(): async def review_next():
with get_db() as conn: with get_db() as conn:
@@ -100,49 +194,34 @@ async def check_duplicate(
first_name: str = "", first_name: str = "",
father_name: str = "", father_name: str = "",
family_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.""" """Check if a person with similar name already exists. Called via AJAX from review page."""
if not first_name: if not first_name:
return JSONResponse({"matches": []}) return JSONResponse({"matches": []})
first_norm = normalize_arabic(first_name.strip()) matches = []
family_norm = normalize_arabic(family_name.strip()) if family_name else "" for candidate in _get_merge_candidates(
first_name,
with get_db() as conn: father_name,
if family_norm: family_name,
rows = conn.execute( search_scope,
"""SELECT p.*, COUNT(DISTINCT pr.id) AS property_count registry_number,
FROM persons p ):
LEFT JOIN properties pr ON pr.person_id = p.id matches.append({
WHERE p.first_name_norm = ? AND p.family_name_norm = ? "id": candidate["id"],
GROUP BY p.id""", "first_name": candidate["first_name"],
(first_norm, family_norm), "father_name": candidate.get("father_name"),
).fetchall() "family_name": candidate.get("family_name"),
else: "family_origin": candidate.get("family_origin"),
rows = conn.execute( "property_count": candidate.get("property_count", 0),
"""SELECT p.*, COUNT(DISTINCT pr.id) AS property_count "document_count": candidate.get("document_count", 0),
FROM persons p "search_scopes": candidate.get("search_scope_list", []),
LEFT JOIN properties pr ON pr.person_id = p.id "same_scope": candidate.get("same_scope", False),
WHERE p.first_name_norm = ? "registry_match": candidate.get("registry_match", False),
GROUP BY p.id""", "merge_allowed": candidate.get("merge_allowed", False),
(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}) return JSONResponse({"matches": matches})
@@ -155,34 +234,104 @@ async def confirm_document(doc_id: int, request: Request):
properties_data = body.get("properties", []) properties_data = body.get("properties", [])
merge_person_id = body.get("merge_person_id") # If user chose to merge merge_person_id = body.get("merge_person_id") # If user chose to merge
first_name = (person_data.get("first_name") or "").strip() if not isinstance(person_data, dict):
registry_number = (person_data.get("registry_number") or "").strip() or None raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid person payload")
if not isinstance(properties_data, list):
# We should update document fields as well since user might have edited them raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid properties payload")
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
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: 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 person_id = None
# Option 1: User explicitly chose to merge with an existing person # Option 1: User explicitly chose to merge with an existing person
if merge_person_id: 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 # Update person info with latest data
conn.execute( conn.execute(
"""UPDATE persons SET """UPDATE persons SET
@@ -241,31 +390,66 @@ async def confirm_document(doc_id: int, request: Request):
# Option 3: Create new person # Option 3: Create new person
if not person_id: if not person_id:
cursor = conn.execute( existing_person = None
"""INSERT INTO persons if current_doc.get("person_id"):
(first_name, father_name, mother_name, family_name, family_origin, existing_person = conn.execute(
nationality, birth_date, registry_number, registry_place, "SELECT id FROM persons WHERE id=?",
first_name_norm, family_name_norm) (current_doc["person_id"],),
VALUES (?,?,?,?,?,?,?,?,?,?,?)""", ).fetchone()
(
first_name, if existing_person:
person_data.get("father_name"), person_id = existing_person["id"]
person_data.get("mother_name"), conn.execute(
person_data.get("family_name"), """UPDATE persons SET
person_data.get("family_origin"), first_name=?, father_name=?, mother_name=?,
person_data.get("nationality"), family_name=?, family_origin=?, nationality=?,
person_data.get("birth_date"), birth_date=?, registry_number=?, registry_place=?,
registry_number, first_name_norm=?, family_name_norm=?,
person_data.get("registry_place"), updated_at=CURRENT_TIMESTAMP
normalize_arabic(first_name), WHERE id=?""",
normalize_arabic(person_data.get("family_name") or ""), (
), first_name,
) person_data.get("father_name"),
person_id = cursor.lastrowid 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 # Replace properties for this document
conn.execute("DELETE FROM properties WHERE document_id=?", (doc_id,)) conn.execute("DELETE FROM properties WHERE document_id=?", (doc_id,))
for i, prop in enumerate(properties_data): 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( conn.execute(
"""INSERT INTO properties """INSERT INTO properties
(document_id, person_id, row_order, party_name, property_number, (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 exactly one person found, preload their full details
if len(persons) == 1 and not properties: 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( return templates.TemplateResponse(
request, request,
@@ -50,8 +53,8 @@ async def search(
@router.get("/persons/{person_id}") @router.get("/persons/{person_id}")
async def person_detail(request: Request, person_id: int): async def person_detail(request: Request, person_id: int, search_scope: str = ""):
data = get_person_with_properties(person_id) data = get_person_with_properties(person_id, search_scope.strip() or None)
if not data: if not data:
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
@@ -71,16 +74,13 @@ async def person_export_csv(person_id: int, qaza: str = ""):
import io import io
import csv import csv
data = get_person_with_properties(person_id) data = get_person_with_properties(person_id, qaza.strip() or None)
if not data: if not data:
return Response("Person not found", status_code=404) return Response("Person not found", status_code=404)
person = data["person"] person = data["person"]
properties = data["properties"] 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() output = io.StringIO()
# Write BOM for Excel to open Arabic UTF-8 correctly # Write BOM for Excel to open Arabic UTF-8 correctly
output.write('\ufeff') output.write('\ufeff')
+2 -2
View File
@@ -1,5 +1,5 @@
import sqlite3
import hashlib import hashlib
import hmac
import secrets import secrets
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
@@ -11,7 +11,7 @@ def verify_password(stored_password: str, provided_password: str) -> bool:
try: try:
salt, stored_hash = stored_password.split('$') salt, stored_hash = stored_password.split('$')
hashed = hashlib.pbkdf2_hmac('sha256', provided_password.encode('utf-8'), salt.encode('utf-8'), 100000).hex() 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: except Exception:
return False return False
+42 -20
View File
@@ -12,37 +12,53 @@ def normalize_arabic(text: str) -> str:
return text 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]: 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()) norm = normalize_arabic(query.strip())
pattern = f"%{norm}%" pattern = f"%{norm}%"
raw_pattern = f"%{query.strip()}%" raw_pattern = f"%{query.strip()}%"
scope_expr = "COALESCE(NULLIF(TRIM(d.search_scope), ''), '')"
with get_db() as conn: with get_db() as conn:
rows = conn.execute( rows = conn.execute(
""" f"""
SELECT p.*, SELECT p.*,
{scope_expr} AS search_scope,
COUNT(DISTINCT pr.id) AS property_count, COUNT(DISTINCT pr.id) AS property_count,
COUNT(DISTINCT d.id) AS document_count, COUNT(DISTINCT d.id) AS document_count
GROUP_CONCAT(DISTINCT d.search_scope) AS search_scopes
FROM persons p 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 documents d ON d.person_id = p.id
LEFT JOIN properties pr ON pr.document_id = d.id
WHERE p.first_name_norm LIKE ? WHERE p.first_name_norm LIKE ?
OR p.family_name_norm LIKE ? OR p.family_name_norm LIKE ?
OR p.father_name LIKE ? OR p.father_name LIKE ?
OR p.first_name LIKE ? OR p.first_name LIKE ?
OR p.family_name LIKE ? OR p.family_name LIKE ?
GROUP BY p.id GROUP BY p.id, {scope_expr}
ORDER BY ORDER BY
CASE WHEN p.first_name_norm = ? THEN 0 CASE WHEN p.first_name_norm = ? THEN 0
WHEN p.family_name_norm = ? THEN 0 WHEN p.family_name_norm = ? THEN 0
ELSE 1 END, ELSE 1 END,
p.first_name p.first_name,
p.family_name,
search_scope
""", """,
(pattern, pattern, raw_pattern, raw_pattern, raw_pattern, norm, norm), (pattern, pattern, raw_pattern, raw_pattern, raw_pattern, norm, norm),
).fetchall() ).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( def search_properties(
@@ -94,8 +110,9 @@ def search_properties(
return results return results
def get_person_with_properties(person_id: int) -> dict | None: def get_person_with_properties(person_id: int, search_scope: str | None = None) -> dict | None:
"""Fetch a person and all their properties.""" """Fetch a person and all their properties, optionally filtered by search scope."""
normalized_scope = _normalize_scope(search_scope)
with get_db() as conn: with get_db() as conn:
person = conn.execute( person = conn.execute(
"SELECT * FROM persons WHERE id = ?", (person_id,) "SELECT * FROM persons WHERE id = ?", (person_id,)
@@ -103,21 +120,25 @@ def get_person_with_properties(person_id: int) -> dict | None:
if not person: if not person:
return None return None
props = conn.execute( props_query = """
"""
SELECT pr.*, d.image_path, d.id AS document_id, d.search_scope SELECT pr.*, d.image_path, d.id AS document_id, d.search_scope
FROM properties pr FROM properties pr
LEFT JOIN documents d ON d.id = pr.document_id LEFT JOIN documents d ON d.id = pr.document_id
WHERE pr.person_id = ? WHERE pr.person_id = ?
ORDER BY pr.real_estate_district, pr.row_order """
""", props_params = [person_id]
(person_id,), if normalized_scope:
).fetchall() 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( docs_query = "SELECT id, image_path, request_number, request_date, status, page_info, search_scope FROM documents WHERE person_id = ?"
"SELECT id, image_path, request_number, request_date, status, page_info, search_scope FROM documents WHERE person_id = ?", docs_params = [person_id]
(person_id,), if normalized_scope:
).fetchall() docs_query += " AND COALESCE(NULLIF(TRIM(search_scope), ''), '') = ?"
docs_params.append(normalized_scope)
docs = conn.execute(docs_query, docs_params).fetchall()
properties_list = [] properties_list = []
doc_ids_with_props = set() doc_ids_with_props = set()
@@ -149,4 +170,5 @@ def get_person_with_properties(person_id: int) -> dict | None:
"person": dict(person), "person": dict(person),
"properties": properties_list, "properties": properties_list,
"documents": docs_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 page — two-column sticky layout */
.review-layout { .review-layout {
display: grid; display: grid;
grid-template-columns: 1fr 1.4fr; grid-template-columns: minmax(320px, .92fr) minmax(0, 1.4fr);
gap: 1.25rem; gap: 1.4rem;
align-items: start; align-items: start;
} }
/* Image panel */ /* Image panel */
.image-panel { .image-panel {
position: sticky; position: sticky;
top: 70px; top: 88px;
background: #1f2937; background: linear-gradient(180deg, #1a2e38, #0f1c22);
border-radius: 8px; border-radius: 20px;
overflow: hidden; 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 { .image-toolbar {
display: flex; display: flex;
gap: .4rem; gap: .35rem;
padding: .5rem .75rem; padding: .65rem .75rem;
background: #111827; background: rgba(10, 18, 22, .94);
border-bottom: 1px solid rgba(255,255,255,.06);
} }
.image-container { .image-container {
overflow: auto; overflow: auto;
@@ -27,15 +29,24 @@
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: center; 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 { .doc-image {
max-width: 100%; max-width: 100%;
height: auto; height: auto;
display: block; display: block;
transform-origin: top center; transform-origin: top center;
transition: transform .2s; transition: transform .2s cubic-bezier(.4,0,.2,1);
border-radius: 4px; border-radius: 12px;
box-shadow: 0 12px 36px rgba(0,0,0,.32);
background: white;
} }
/* Form panel */ /* Form panel */
@@ -43,6 +54,27 @@
min-width: 0; 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 Review — Responsive
============================ */ ============================ */
@@ -57,6 +89,107 @@
.image-container { .image-container {
max-height: 50vh; 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) { @media (max-width: 480px) {
@@ -66,4 +199,7 @@
.image-toolbar { .image-toolbar {
justify-content: center; justify-content: center;
} }
.image-panel {
border-radius: 18px;
}
} }
+110 -9
View File
@@ -2,26 +2,62 @@
<html lang="ar" dir="rtl"> <html lang="ar" dir="rtl">
<head> <head>
<meta charset="UTF-8"> <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> <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"> <link rel="stylesheet" href="/static/css/main.css">
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body> <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 %} {% block navbar %}
<nav class="navbar"> <nav class="navbar">
<div class="nav-container"> <div class="nav-container">
<a class="nav-brand" href="/">🏠 سجل العقارات</a> <a class="nav-brand" href="/">
<button class="nav-toggle" id="navToggle" aria-label="القائمة"> <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> <span></span><span></span><span></span>
</button> </button>
<div class="nav-links" id="navLinks"> <div class="nav-links" id="navLinks">
<a href="/" class="nav-link">رفع وثائق</a> <a href="/" class="nav-link">
<a href="/review/next" class="nav-link">مراجعة</a> <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 href="/search" class="nav-link">بحث</a> رفع وثائق
<a href="/documents" class="nav-link">قائمة الوثائق</a> </a>
<a href="/auth/users" class="nav-link">الإدارة</a> <a href="/review/next" class="nav-link">
<a href="/auth/logout" class="nav-link">تسجيل خروج</a> <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>
</div> </div>
</nav> </nav>
@@ -31,6 +67,33 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </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> <script>
const navToggle = document.getElementById('navToggle'); const navToggle = document.getElementById('navToggle');
const navLinks = document.getElementById('navLinks'); const navLinks = document.getElementById('navLinks');
@@ -38,8 +101,46 @@
navToggle.addEventListener('click', () => { navToggle.addEventListener('click', () => {
navLinks.classList.toggle('open'); navLinks.classList.toggle('open');
navToggle.classList.toggle('active'); 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> </script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
+19 -12
View File
@@ -3,8 +3,15 @@
{% block content %} {% block content %}
<div class="page-header"> <div class="page-header">
<h1>قائمة الوثائق</h1> <div>
<a href="/" class="btn btn-primary">+ رفع وثائق جديدة</a> <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> </div>
{% if uploaded %} {% if uploaded %}
@@ -33,12 +40,12 @@
{% if stats.pending_review %} {% if stats.pending_review %}
<div class="alert-banner"> <div class="alert-banner">
<a href="/review/next">ابدأ مراجعة {{ stats.pending_review }} وثيقة</a> <a href="/review/next">ابدأ مراجعة {{ stats.pending_review }} وثيقة</a>
</div> </div>
{% endif %} {% endif %}
<div class="table-wrapper"> <div class="table-wrapper">
<table class="results-table"> <table class="results-table responsive-table">
<thead> <thead>
<tr> <tr>
<th>#</th> <th>#</th>
@@ -53,21 +60,21 @@
<tbody> <tbody>
{% for d in documents %} {% for d in documents %}
<tr> <tr>
<td>{{ d.id }}</td> <td data-label="#">{{ d.id }}</td>
<td> <td data-label="الصورة">
<a href="/review/{{ d.id }}"> <a href="/review/{{ d.id }}" title="فتح الوثيقة {{ d.id }} للمراجعة" aria-label="فتح الوثيقة {{ d.id }} للمراجعة">
<img src="/uploads/{{ d.image_path }}" class="doc-thumb-mini" alt=""> <img src="/uploads/{{ d.image_path }}" class="doc-thumb-mini" alt="">
</a> </a>
</td> </td>
<td> <td data-label="الشخص">
{% if d.person_id %} {% if d.person_id %}
<a href="/persons/{{ d.person_id }}">{{ d.first_name or '' }} {{ d.family_name or '' }}</a> <a href="/persons/{{ d.person_id }}">{{ d.first_name or '' }} {{ d.family_name or '' }}</a>
{% else %}—{% endif %} {% else %}—{% endif %}
</td> </td>
<td>{{ d.request_number or '—' }}</td> <td data-label="رقم الطلب">{{ d.request_number or '—' }}</td>
<td><span class="status-badge status-{{ d.status }}">{{ d.status }}</span></td> <td data-label="الحالة"><span class="status-badge status-{{ d.status }}">{{ d.status }}</span></td>
<td>{{ d.created_at[:10] if d.created_at else '' }}</td> <td data-label="التاريخ">{{ d.created_at[:10] if d.created_at else '' }}</td>
<td class="doc-actions"> <td data-label="الإجراءات" class="doc-actions">
<a href="/review/{{ d.id }}" class="btn btn-sm btn-secondary"> <a href="/review/{{ d.id }}" class="btn btn-sm btn-secondary">
{% if d.status == 'confirmed' %}عرض{% else %}مراجعة{% endif %} {% if d.status == 'confirmed' %}عرض{% else %}مراجعة{% endif %}
</a> </a>
+38 -12
View File
@@ -2,9 +2,16 @@
{% block title %}رفع وثائق — سجل العقارات{% endblock %} {% block title %}رفع وثائق — سجل العقارات{% endblock %}
{% block content %} {% block content %}
<div class="page-header"> <div class="page-header page-header-hero">
<h1>بطاقات معلومات الملكية العقارية</h1> <div>
<p class="subtitle">ارفع صور أو ملفات PDF للوثائق — اختر محرك الاستخراج (مجاني أو ذكاء اصطناعي)</p> <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> </div>
{% if stats and stats.total %} {% if stats and stats.total %}
@@ -16,16 +23,24 @@
</div> </div>
{% if stats.pending_review %} {% if stats.pending_review %}
<div class="alert-banner"> <div class="alert-banner">
<a href="/review/next">ابدأ مراجعة {{ stats.pending_review }} وثيقة</a> <a href="/review/next">ابدأ مراجعة {{ stats.pending_review }} وثيقة</a>
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}
<div class="upload-card"> <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"> <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"> <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"> <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-text">اسحب الملفات هنا أو انقر للاختيار</p>
<p class="drop-hint">JPG، PNG، PDF — يمكن رفع عدة ملفات دفعة واحدة</p> <p class="drop-hint">JPG، PNG، PDF — يمكن رفع عدة ملفات دفعة واحدة</p>
</label> </label>
@@ -51,7 +66,8 @@
<div id="uploadActions" class="upload-actions hidden"> <div id="uploadActions" class="upload-actions hidden">
<button type="submit" class="btn btn-primary btn-large"> <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>
<button type="button" class="btn btn-secondary" onclick="clearFiles()">إلغاء</button> <button type="button" class="btn btn-secondary" onclick="clearFiles()">إلغاء</button>
</div> </div>
@@ -60,12 +76,22 @@
<div class="quick-links"> <div class="quick-links">
<a href="/search" class="quick-link-card"> <a href="/search" class="quick-link-card">
<span class="ql-icon">🔍</span> <span class="ql-icon">
<span>البحث في قاعدة البيانات</span> <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>
<a href="/documents" class="quick-link-card"> <a href="/documents" class="quick-link-card">
<span class="ql-icon">📋</span> <span class="ql-icon">
<span>قائمة جميع الوثائق</span> <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> </a>
</div> </div>
{% endblock %} {% endblock %}
@@ -96,7 +122,7 @@ fileInput.addEventListener('change', () => showFiles(fileInput.files));
function showFiles(files) { function showFiles(files) {
if (!files.length) return; if (!files.length) return;
fileList.innerHTML = Array.from(files).map(f => 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(''); ).join('');
fileList.classList.remove('hidden'); fileList.classList.remove('hidden');
document.getElementById('providerSection').classList.remove('hidden'); document.getElementById('providerSection').classList.remove('hidden');
@@ -112,7 +138,7 @@ function clearFiles() {
} }
document.getElementById('uploadForm').addEventListener('submit', function() { document.getElementById('uploadForm').addEventListener('submit', function() {
document.querySelector('.btn-primary').textContent = 'جارٍ الرفع...'; document.querySelector('.btn-primary').textContent = 'جارٍ الرفع...';
document.querySelector('.btn-primary').disabled = true; document.querySelector('.btn-primary').disabled = true;
}); });
</script> </script>
+12 -2
View File
@@ -6,7 +6,17 @@
{% block content %} {% block content %}
<div class="login-card"> <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 %} {% if error %}
<div class="login-error">{{ error }}</div> <div class="login-error">{{ error }}</div>
@@ -23,7 +33,7 @@
<input type="password" id="password" name="password" required placeholder="أدخل كلمة المرور"> <input type="password" id="password" name="password" required placeholder="أدخل كلمة المرور">
</div> </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> </button>
</form> </form>
+33 -34
View File
@@ -4,18 +4,28 @@
{% block content %} {% block content %}
<div class="page-header"> <div class="page-header">
<div> <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> <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> <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 %} {% set scopes = documents | selectattr('search_scope') | map(attribute='search_scope') | unique | list %}
<div class="export-dropdown"> <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"> <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 %} {% for scope in scopes %}
{% if scope %} {% 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 %} {% endif %}
{% endfor %} {% endfor %}
</div> </div>
@@ -35,6 +45,11 @@
{% set scopes = documents | selectattr('search_scope') | map(attribute='search_scope') | unique | list %} {% 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 %} {% if scopes %}<div class="info-item"><span class="info-label">القضاء</span><span>{{ scopes | join('، ') }}</span></div>{% endif %}
</div> </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>
<div class="section-header"> <div class="section-header">
@@ -43,7 +58,7 @@
{% if properties %} {% if properties %}
<div class="table-wrapper"> <div class="table-wrapper">
<table class="results-table props-full"> <table class="results-table props-full responsive-table">
<thead> <thead>
<tr> <tr>
<th>#</th> <th>#</th>
@@ -59,29 +74,29 @@
</thead> </thead>
<tbody> <tbody>
{% for pr in properties %} {% for pr in properties %}
<tr {% if pr.ownership_type == 'لا يملك' %}style="background-color: #fff3f3; color: #dc3545; font-weight: bold;"{% endif %}> <tr class="{{ 'ownership-alert' if pr.ownership_type == 'لا يملك' else '' }}">
<td class="row-num">{{ loop.index }}</td> <td data-label="#" class="row-num">{{ loop.index }}</td>
<td>{{ pr.party_name or '' }}</td> <td data-label="اسم الفريق">{{ pr.party_name or '' }}</td>
<td class="prop-num">{{ pr.property_number or '' }}</td> <td data-label="رقم العقار" class="prop-num">{{ pr.property_number or '' }}</td>
<td>{{ pr.section or '' }}</td> <td data-label="القسم">{{ pr.section or '' }}</td>
<td>{{ pr.block or '' }}</td> <td data-label="البلوك">{{ pr.block or '' }}</td>
<td>{{ pr.real_estate_district or '' }}</td> <td data-label="المنطقة العقارية">{{ pr.real_estate_district or '' }}</td>
<td>{{ pr.qaza or '' }}</td> <td data-label="القضاء">{{ pr.qaza or '' }}</td>
<td>{{ pr.num_shares or '' }}</td> <td data-label="عدد الأسهم">{{ pr.num_shares or '' }}</td>
<td>{{ pr.ownership_type or '' }}</td> <td data-label="نوع الملكية">{{ pr.ownership_type or '' }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% else %} {% else %}
<div class="error-banner" style="justify-content:center;"> <div class="error-banner centered-banner">
⚠️ نتيجة البحث: لا يملك أي عقار في نطاق البحث المذكور. ⚠️ نتيجة البحث: لا يملك أي عقار في نطاق البحث المذكور.
</div> </div>
{% endif %} {% endif %}
{% if documents %} {% if documents %}
<div class="docs-section"> <div class="docs-section no-print">
<h3>الوثائق الأصلية</h3> <h3>الوثائق الأصلية</h3>
<div class="doc-thumbnails"> <div class="doc-thumbnails">
{% for d in documents %} {% for d in documents %}
@@ -101,19 +116,3 @@
{% endblock %} {% 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 %} {% block content %}
<div class="review-header"> <div class="review-header">
<div class="review-title"> <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> <span class="status-badge status-{{ doc.status }}">{{ doc.status }}</span>
</div> </div>
<div class="review-nav"> <div class="review-nav">
<a href="/documents" class="btn btn-secondary btn-sm">← قائمة الوثائق</a> <a href="/documents" class="btn btn-secondary btn-sm">← قائمة الوثائق</a>
<a href="/review/next" 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>
</div> </div>
@@ -29,9 +35,15 @@
<!-- Image Panel --> <!-- Image Panel -->
<div class="image-panel"> <div class="image-panel">
<div class="image-toolbar"> <div class="image-toolbar">
<button onclick="zoomIn()" class="btn btn-sm">🔍+</button> <button type="button" onclick="zoomIn()" class="btn btn-sm btn-secondary" title="تكبير">
<button onclick="zoomOut()" class="btn btn-sm">🔍-</button> <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 onclick="resetZoom()" class="btn btn-sm"></button> </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>
<div class="image-container" id="imageContainer"> <div class="image-container" id="imageContainer">
<img id="docImage" src="/uploads/{{ doc.image_path }}" alt="وثيقة" class="doc-image"> <img id="docImage" src="/uploads/{{ doc.image_path }}" alt="وثيقة" class="doc-image">
@@ -47,46 +59,47 @@
<h3>بيانات الشخص</h3> <h3>بيانات الشخص</h3>
<div class="form-grid"> <div class="form-grid">
<div class="form-group"> <div class="form-group">
<label>الاسم *</label> <label for="first_name">الاسم *</label>
<input type="text" name="first_name" value="{{ doc.person.get('first_name') or '' }}" required> <input id="first_name" type="text" name="first_name" value="{{ doc.person.get('first_name') or '' }}" title="الاسم" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>اسم الأب</label> <label for="father_name">اسم الأب</label>
<input type="text" name="father_name" value="{{ doc.person.get('father_name') or '' }}"> <input id="father_name" type="text" name="father_name" value="{{ doc.person.get('father_name') or '' }}" title="اسم الأب">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>اسم الأم</label> <label for="mother_name">اسم الأم</label>
<input type="text" name="mother_name" value="{{ doc.person.get('mother_name') or '' }}"> <input id="mother_name" type="text" name="mother_name" value="{{ doc.person.get('mother_name') or '' }}" title="اسم الأم">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>اللقب</label> <label for="family_name">اللقب</label>
<input type="text" name="family_name" value="{{ doc.person.get('family_name') or '' }}"> <input id="family_name" type="text" name="family_name" value="{{ doc.person.get('family_name') or '' }}" title="اللقب">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>الهوا / المنشأ</label> <label for="family_origin">الهوا / المنشأ</label>
<input type="text" name="family_origin" value="{{ doc.person.get('family_origin') or '' }}"> <input id="family_origin" type="text" name="family_origin" value="{{ doc.person.get('family_origin') or '' }}" title="الهوا أو المنشأ">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>الجنسية</label> <label for="nationality">الجنسية</label>
<input type="text" name="nationality" value="{{ doc.person.get('nationality') or '' }}"> <input id="nationality" type="text" name="nationality" value="{{ doc.person.get('nationality') or '' }}" title="الجنسية">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>تاريخ الولادة</label> <label for="birth_date">تاريخ الولادة</label>
<input type="text" name="birth_date" value="{{ doc.person.get('birth_date') or '' }}"> <input id="birth_date" type="text" name="birth_date" value="{{ doc.person.get('birth_date') or '' }}" title="تاريخ الولادة">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>رقم السجل</label> <label for="registry_number">رقم السجل</label>
<input type="text" name="registry_number" value="{{ doc.person.get('registry_number') or '' }}"> <input id="registry_number" type="text" name="registry_number" value="{{ doc.person.get('registry_number') or '' }}" title="رقم السجل">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>مكان السجل</label> <label for="registry_place">مكان السجل</label>
<input type="text" name="registry_place" value="{{ doc.person.get('registry_place') or '' }}"> <input id="registry_place" type="text" name="registry_place" value="{{ doc.person.get('registry_place') or '' }}" title="مكان السجل">
</div> </div>
</div> </div>
<!-- Merge suggestion (populated by JS) --> <!-- Merge suggestion (populated by JS) -->
<div id="mergeSuggestion" class="merge-banner hidden"> <div id="mergeSuggestion" class="merge-banner hidden">
<h4>شخص مشابه موجود في قاعدة البيانات:</h4> <h4>شخص مشابه موجود في قاعدة البيانات:</h4>
<p id="mergeScopeNotice" class="merge-note hidden"></p>
<div id="mergeMatches"></div> <div id="mergeMatches"></div>
<div class="merge-actions"> <div class="merge-actions">
<label><input type="radio" name="merge_action" value="merge" checked> دمج مع الشخص الموجود</label> <label><input type="radio" name="merge_action" value="merge" checked> دمج مع الشخص الموجود</label>
@@ -101,20 +114,20 @@
<h3>بيانات الطلب</h3> <h3>بيانات الطلب</h3>
<div class="form-grid"> <div class="form-grid">
<div class="form-group"> <div class="form-group">
<label>رقم الطلب</label> <label for="request_number">رقم الطلب</label>
<input type="text" name="request_number" value="{{ doc.request_number or '' }}"> <input id="request_number" type="text" name="request_number" value="{{ doc.request_number or '' }}" title="رقم الطلب">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>تاريخ الطلب</label> <label for="request_date">تاريخ الطلب</label>
<input type="text" name="request_date" value="{{ doc.request_date or '' }}"> <input id="request_date" type="text" name="request_date" value="{{ doc.request_date or '' }}" title="تاريخ الطلب">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>معلومات الصفحة</label> <label for="page_info">معلومات الصفحة</label>
<input type="text" name="page_info" value="{{ doc.page_info or '' }}" placeholder="مثال: 1 من 3"> <input id="page_info" type="text" name="page_info" value="{{ doc.page_info or '' }}" title="معلومات الصفحة" placeholder="مثال: 1 من 3">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>نطاق البحث (القضاء)</label> <label for="search_scope">نطاق البحث (القضاء)</label>
<input type="text" name="search_scope" value="{{ doc.search_scope or '' }}" placeholder="مثال: المتن"> <input id="search_scope" type="text" name="search_scope" value="{{ doc.search_scope or '' }}" title="نطاق البحث" placeholder="مثال: المتن">
</div> </div>
</div> </div>
</section> </section>
@@ -125,8 +138,8 @@
<h3>العقارات المملوكة</h3> <h3>العقارات المملوكة</h3>
<button type="button" class="btn btn-sm btn-success" onclick="addRow()">+ إضافة صف</button> <button type="button" class="btn btn-sm btn-success" onclick="addRow()">+ إضافة صف</button>
</div> </div>
<div class="table-wrapper"> <div class="table-wrapper table-wrapper-editable">
<table id="propertiesTable" class="props-table"> <table id="propertiesTable" class="props-table mobile-edit-table">
<thead> <thead>
<tr> <tr>
<th>اسم الفريق</th> <th>اسم الفريق</th>
@@ -143,15 +156,15 @@
<tbody id="propertiesTbody"> <tbody id="propertiesTbody">
{% for prop in doc.properties %} {% for prop in doc.properties %}
<tr> <tr>
<td><input type="text" value="{{ prop.party_name or '' }}"></td> <td data-label="اسم الفريق"><input type="text" value="{{ prop.party_name or '' }}" title="اسم الفريق" aria-label="اسم الفريق"></td>
<td><input type="text" value="{{ prop.property_number or '' }}"></td> <td data-label="رقم العقار"><input type="text" value="{{ prop.property_number or '' }}" title="رقم العقار" aria-label="رقم العقار"></td>
<td><input type="text" value="{{ prop.section or '' }}"></td> <td data-label="القسم"><input type="text" value="{{ prop.section or '' }}" title="القسم" aria-label="القسم"></td>
<td><input type="text" value="{{ prop.block or '' }}"></td> <td data-label="البلوك"><input type="text" value="{{ prop.block or '' }}" title="البلوك" aria-label="البلوك"></td>
<td><input type="text" value="{{ prop.real_estate_district or '' }}"></td> <td data-label="المنطقة العقارية"><input type="text" value="{{ prop.real_estate_district or '' }}" title="المنطقة العقارية" aria-label="المنطقة العقارية"></td>
<td><input type="text" value="{{ prop.qaza or '' }}"></td> <td data-label="القضاء"><input type="text" value="{{ prop.qaza or '' }}" title="القضاء" aria-label="القضاء"></td>
<td><input type="text" value="{{ prop.num_shares or '' }}"></td> <td data-label="عدد الأسهم"><input type="text" value="{{ prop.num_shares or '' }}" title="عدد الأسهم" aria-label="عدد الأسهم"></td>
<td><input type="text" value="{{ prop.ownership_type or '' }}"></td> <td data-label="نوع الملكية"><input type="text" value="{{ prop.ownership_type or '' }}" title="نوع الملكية" aria-label="نوع الملكية"></td>
<td><button type="button" class="btn-del" onclick="removeRow(this)"></button></td> <td data-label="حذف"><button type="button" class="btn-del" onclick="removeRow(this)"></button></td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -162,7 +175,8 @@
<!-- Actions --> <!-- Actions -->
<div class="form-actions"> <div class="form-actions">
<button type="button" class="btn btn-primary btn-large" onclick="confirmDocument()" id="confirmBtn"> <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> </button>
<span class="keyboard-hint">أو اضغط Ctrl+Enter</span> <span class="keyboard-hint">أو اضغط Ctrl+Enter</span>
</div> </div>
@@ -181,15 +195,15 @@ function addRow() {
const tbody = document.getElementById('propertiesTbody'); const tbody = document.getElementById('propertiesTbody');
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.innerHTML = ` tr.innerHTML = `
<td><input type="text" value=""></td> <td data-label="اسم الفريق"><input type="text" value="" title="اسم الفريق" aria-label="اسم الفريق"></td>
<td><input type="text" value=""></td> <td data-label="رقم العقار"><input type="text" value="" title="رقم العقار" aria-label="رقم العقار"></td>
<td><input type="text" value=""></td> <td data-label="القسم"><input type="text" value="" title="القسم" aria-label="القسم"></td>
<td><input type="text" value=""></td> <td data-label="البلوك"><input type="text" value="" title="البلوك" aria-label="البلوك"></td>
<td><input type="text" value=""></td> <td data-label="المنطقة العقارية"><input type="text" value="" title="المنطقة العقارية" aria-label="المنطقة العقارية"></td>
<td><input type="text" value=""></td> <td data-label="القضاء"><input type="text" value="" title="القضاء" aria-label="القضاء"></td>
<td><input type="text" value=""></td> <td data-label="عدد الأسهم"><input type="text" value="" title="عدد الأسهم" aria-label="عدد الأسهم"></td>
<td><input type="text" value=""></td> <td data-label="نوع الملكية"><input type="text" value="" title="نوع الملكية" aria-label="نوع الملكية"></td>
<td><button type="button" class="btn-del" onclick="removeRow(this)">✕</button></td> <td data-label="حذف"><button type="button" class="btn-del" onclick="removeRow(this)">✕</button></td>
`; `;
tbody.appendChild(tr); tbody.appendChild(tr);
tr.querySelector('input').focus(); tr.querySelector('input').focus();
@@ -252,25 +266,68 @@ function checkDuplicate() {
const first = form.querySelector('[name=first_name]').value.trim(); const first = form.querySelector('[name=first_name]').value.trim();
const father = form.querySelector('[name=father_name]').value.trim(); const father = form.querySelector('[name=father_name]').value.trim();
const family = form.querySelector('[name=family_name]').value.trim(); const family = form.querySelector('[name=family_name]').value.trim();
if (!first) return; const scope = form.querySelector('[name=search_scope]').value.trim();
const registry = form.querySelector('[name=registry_number]').value.trim();
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 banner = document.getElementById('mergeSuggestion');
const matchesDiv = document.getElementById('mergeMatches'); const matchesDiv = document.getElementById('mergeMatches');
const mergeIdInput = document.getElementById('mergePersonId'); 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) { if (data.matches && data.matches.length > 0) {
matchesDiv.innerHTML = data.matches.map(m => const firstAllowed = data.matches.find(m => m.merge_allowed);
`<div class="merge-person" data-person-id="${m.id}"> 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-name">${m.first_name} ${m.father_name||''} ${m.family_name||''}</span>
<span class="mp-count">${m.property_count} عقار</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>' : ''} ${m.family_origin ? '<span class="mp-count">'+m.family_origin+'</span>' : ''}
</div>` <span class="mp-count">${status}</span>
).join(''); <span class="mp-count">النطاقات: ${scopes}</span>
mergeIdInput.value = data.matches[0].id; </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'); banner.classList.remove('hidden');
// Click to select a match // Click to select a match
@@ -278,19 +335,35 @@ function checkDuplicate() {
el.addEventListener('click', () => { el.addEventListener('click', () => {
matchesDiv.querySelectorAll('.merge-person').forEach(e => e.style.outline = ''); matchesDiv.querySelectorAll('.merge-person').forEach(e => e.style.outline = '');
el.style.outline = '2px solid var(--primary)'; 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; 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 { } else {
banner.classList.add('hidden'); banner.classList.add('hidden');
mergeIdInput.value = ''; mergeIdInput.value = '';
mergeRadio.disabled = false;
scopeNotice.classList.add('hidden');
} }
}, 500); }, 500);
} }
// Attach duplicate check to name fields // 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); input.addEventListener('blur', checkDuplicate);
}); });
// Run once on load // Run once on load
@@ -300,7 +373,7 @@ checkDuplicate();
async function confirmDocument() { async function confirmDocument() {
const btn = document.getElementById('confirmBtn'); const btn = document.getElementById('confirmBtn');
btn.disabled = true; btn.disabled = true;
btn.textContent = 'جارٍ الحفظ...'; btn.textContent = 'جارٍ الحفظ...';
try { try {
const formData = getFormData(); const formData = getFormData();
@@ -321,7 +394,7 @@ async function confirmDocument() {
if (data.ok) { if (data.ok) {
window.location.href = data.next; window.location.href = data.next;
} else { } else {
alert('حدث خطأ: ' + JSON.stringify(data)); alert('حدث خطأ: ' + (data.detail || JSON.stringify(data)));
btn.disabled = false; btn.disabled = false;
btn.textContent = '✓ تأكيد والانتقال للتالية'; btn.textContent = '✓ تأكيد والانتقال للتالية';
} }
+77 -59
View File
@@ -3,21 +3,36 @@
{% block content %} {% block content %}
<div class="page-header"> <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>
<div class="search-card"> <div class="search-card no-print">
<form method="get" action="/search" id="searchForm"> <form method="get" action="/search" id="searchForm">
<div class="search-tabs"> <div class="search-tabs">
<button type="button" class="tab-btn active" onclick="showTab('person')">بحث بالاسم</button> <button type="button" class="tab-btn active" data-tab="person">بحث بالاسم</button>
<button type="button" class="tab-btn" onclick="showTab('property')">بحث بالعقار</button> <button type="button" class="tab-btn" data-tab="property">بحث بالعقار</button>
</div> </div>
<div id="tab-person" class="tab-content"> <div id="tab-person" class="tab-content">
<div class="search-row"> <div class="search-row">
<input type="text" name="q" id="qInput" value="{{ q }}" <input type="text" name="q" id="qInput" value="{{ q }}"
placeholder="اكتب اسم الشخص..." class="search-input" autocomplete="off"> 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>
</div> </div>
@@ -36,7 +51,10 @@
<input type="text" name="block" value="{{ block }}" placeholder="رقم البلوك"> <input type="text" name="block" value="{{ block }}" placeholder="رقم البلوك">
</div> </div>
</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> </div>
</form> </form>
</div> </div>
@@ -61,16 +79,23 @@
{% set scopes = selected_person.documents | selectattr('search_scope') | map(attribute='search_scope') | unique | list %} {% 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 %} {% if scopes %}<div class="info-item"><span class="info-label">القضاء</span><span>{{ scopes | join('، ') }}</span></div>{% endif %}
</div> </div>
<div style="margin-top:1rem"> <div class="person-actions">
<a href="/persons/{{ selected_person.person.id }}" class="btn btn-secondary">عرض التفاصيل كاملة</a> <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 %} {% set scopes = selected_person.documents | selectattr('search_scope') | map(attribute='search_scope') | unique | list %}
<div class="export-dropdown"> <div class="export-dropdown no-print">
<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"> <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 %} {% for scope in scopes %}
{% if scope %} {% 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 %} {% endif %}
{% endfor %} {% endfor %}
</div> </div>
@@ -79,8 +104,8 @@
</div> </div>
{% if selected_person.properties %} {% if selected_person.properties %}
<div class="table-wrapper" style="margin-top:1rem"> <div class="table-wrapper table-wrapper-spaced">
<table class="results-table"> <table class="results-table responsive-table">
<thead> <thead>
<tr> <tr>
<th>#</th> <th>#</th>
@@ -96,42 +121,41 @@
</thead> </thead>
<tbody> <tbody>
{% for pr in selected_person.properties %} {% for pr in selected_person.properties %}
<tr {% if pr.ownership_type == 'لا يملك' %}style="background-color: #fff3f3; color: #dc3545; font-weight: bold;"{% endif %}> <tr class="{{ 'ownership-alert' if pr.ownership_type == 'لا يملك' else '' }}">
<td>{{ loop.index }}</td> <td data-label="#">{{ loop.index }}</td>
<td>{{ pr.party_name or '' }}</td> <td data-label="اسم الفريق">{{ pr.party_name or '' }}</td>
<td>{{ pr.property_number or '' }}</td> <td data-label="رقم العقار">{{ pr.property_number or '' }}</td>
<td>{{ pr.section or '' }}</td> <td data-label="القسم">{{ pr.section or '' }}</td>
<td>{{ pr.block or '' }}</td> <td data-label="البلوك">{{ pr.block or '' }}</td>
<td>{{ pr.real_estate_district or '' }}</td> <td data-label="المنطقة العقارية">{{ pr.real_estate_district or '' }}</td>
<td>{{ pr.qaza or '' }}</td> <td data-label="القضاء">{{ pr.qaza or '' }}</td>
<td>{{ pr.num_shares or '' }}</td> <td data-label="عدد الأسهم">{{ pr.num_shares or '' }}</td>
<td>{{ pr.ownership_type or '' }}</td> <td data-label="نوع الملكية">{{ pr.ownership_type or '' }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% else %} {% else %}
<div class="error-banner" style="margin-top:1rem; justify-content:center;"> <div class="error-banner centered-banner banner-spaced">
⚠️ نتيجة البحث: لا يملك أي عقار في القضاء المذكور. ⚠️ نتيجة البحث: لا يملك أي عقار في القضاء المذكور.
</div> </div>
{% endif %} {% endif %}
{% else %} {% else %}
<div class="persons-grid"> <div class="persons-grid">
{% for p in persons %} {% 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-name">{{ p.first_name }} {{ p.father_name or '' }} {{ p.family_name or '' }}</div>
<div class="person-meta"> <div class="person-meta">
{% if p.family_origin %}{{ p.family_origin }} · {% endif %} {% if p.family_origin %}{{ p.family_origin }} · {% endif %}
{% if p.property_count > 0 %} {% if p.property_count > 0 %}
{{ p.property_count }} عقار {{ p.property_count }} عقار
{% else %} {% else %}
<span style="color: #dc3545; font-weight: bold;">لا يملك عقارات</span> <span class="person-meta-alert">لا يملك عقارات</span>
{% endif %} {% endif %}
</div> </div>
{% if p.search_scopes %} <div class="person-reg">نطاق البحث: {{ p.search_scope or 'غير محدد' }}</div>
<div class="person-reg">نطاق البحث: {{ p.search_scopes | replace(',', '، ') }}</div> {% if p.document_count %}<div class="person-reg">عدد الطلبات: {{ p.document_count }}</div>{% endif %}
{% endif %}
{% if p.registry_number %} {% if p.registry_number %}
<div class="person-reg">سجل: {{ p.registry_number }}</div> <div class="person-reg">سجل: {{ p.registry_number }}</div>
{% endif %} {% endif %}
@@ -147,7 +171,7 @@
<div class="results-section"> <div class="results-section">
<h2>نتائج البحث بالعقار ({{ properties|length }} سجل)</h2> <h2>نتائج البحث بالعقار ({{ properties|length }} سجل)</h2>
<div class="table-wrapper"> <div class="table-wrapper">
<table class="results-table"> <table class="results-table responsive-table">
<thead> <thead>
<tr> <tr>
<th>المالك</th> <th>المالك</th>
@@ -164,19 +188,19 @@
<tbody> <tbody>
{% for pr in properties %} {% for pr in properties %}
<tr> <tr>
<td> <td data-label="المالك">
{% if pr.person_id %} {% if pr.person_id %}
<a href="/persons/{{ pr.person_id }}">{{ pr.first_name or '' }} {{ pr.family_name or '' }}</a> <a href="/persons/{{ pr.person_id }}">{{ pr.first_name or '' }} {{ pr.family_name or '' }}</a>
{% else %}—{% endif %} {% else %}—{% endif %}
</td> </td>
<td>{{ pr.party_name or '' }}</td> <td data-label="اسم الفريق">{{ pr.party_name or '' }}</td>
<td class="prop-num">{{ pr.property_number or '' }}</td> <td data-label="رقم العقار" class="prop-num">{{ pr.property_number or '' }}</td>
<td>{{ pr.section or '' }}</td> <td data-label="القسم">{{ pr.section or '' }}</td>
<td>{{ pr.block or '' }}</td> <td data-label="البلوك">{{ pr.block or '' }}</td>
<td>{{ pr.real_estate_district or '' }}</td> <td data-label="المنطقة العقارية">{{ pr.real_estate_district or '' }}</td>
<td>{{ pr.qaza or '' }}</td> <td data-label="القضاء">{{ pr.qaza or '' }}</td>
<td>{{ pr.num_shares or '' }}</td> <td data-label="عدد الأسهم">{{ pr.num_shares or '' }}</td>
<td>{{ pr.ownership_type or '' }}</td> <td data-label="نوع الملكية">{{ pr.ownership_type or '' }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -196,35 +220,29 @@
{% block scripts %} {% block scripts %}
<script> <script>
function showTab(tab) { function showTab(tab, button = null) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden')); document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active')); document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
document.getElementById('tab-' + tab).classList.remove('hidden'); 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 // Auto-select correct tab based on query params
window.addEventListener('DOMContentLoaded', () => { window.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.tab-btn').forEach(button => {
button.addEventListener('click', () => showTab(button.dataset.tab, button));
});
const params = new URLSearchParams(location.search); 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')) { if (params.get('property_number') || params.get('district') || params.get('block')) {
showTab('property'); showTab('property', propertyButton);
document.querySelectorAll('.tab-btn')[1].classList.add('active');
document.querySelectorAll('.tab-btn')[0].classList.remove('active');
} else { } else {
showTab('person', personButton);
document.getElementById('qInput').focus(); document.getElementById('qInput').focus();
} }
}); });
+23 -10
View File
@@ -5,7 +5,11 @@
{% block content %} {% block content %}
<div class="admin-container"> <div class="admin-container">
<div class="page-header"> <div class="page-header">
<h1>إدارة النظام والمستخدمين</h1> <div>
<span class="page-kicker">الإعدادات والإدارة</span>
<h1>إدارة النظام والمستخدمين</h1>
<p class="subtitle">أنشئ نسخاً احتياطية وأدر حسابات الدخول من مكان واحد.</p>
</div>
</div> </div>
{% if backup_msg %} {% if backup_msg %}
@@ -13,13 +17,19 @@
{% endif %} {% endif %}
<div class="admin-section"> <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> <p>قم بإنشاء نسخة احتياطية من قاعدة البيانات الحالية.</p>
<a href="/auth/backup" class="btn btn-success">إنشاء نسخة احتياطية</a> <a href="/auth/backup" class="btn btn-success">إنشاء نسخة احتياطية</a>
</div> </div>
<div class="admin-section"> <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"> <form method="post" action="/auth/users/create" class="add-user-form">
<div class="form-group"> <div class="form-group">
<label for="username">اسم المستخدم</label> <label for="username">اسم المستخدم</label>
@@ -34,9 +44,12 @@
</div> </div>
<div class="admin-section"> <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"> <div class="table-wrapper">
<table class="results-table"> <table class="results-table responsive-table">
<thead> <thead>
<tr> <tr>
<th>الرقم</th> <th>الرقم</th>
@@ -48,11 +61,11 @@
<tbody> <tbody>
{% for user in users %} {% for user in users %}
<tr> <tr>
<td>{{ user.id }}</td> <td data-label="الرقم">{{ user.id }}</td>
<td>{{ user.username }}</td> <td data-label="اسم المستخدم">{{ user.username }}</td>
<td>{{ user.created_at }}</td> <td data-label="تاريخ الإنشاء">{{ user.created_at }}</td>
<td> <td data-label="الإجراءات">
<form method="post" action="/auth/users/delete/{{ user.id }}" onsubmit="return confirm('هل أنت متأكد من حذف هذا المستخدم؟');" style="display:inline;"> <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> <button type="submit" class="btn btn-sm btn-danger">حذف</button>
</form> </form>
</td> </td>